fix: 修正 logo 中 N 和 G 字母造型
N 添加对角线笔画(█▄ █),G 添加内横杠(█ ▀█), 避免与 O 字母造型雷同。同步更新 ui.ts 中的硬编码 wordmark。
This commit is contained in:
10
packages/opencode/.gitignore
vendored
Normal file
10
packages/opencode/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
research
|
||||
dist
|
||||
dist-*
|
||||
gen
|
||||
app.log
|
||||
script/build-*.ts
|
||||
temporary-*.md
|
||||
.artifacts
|
||||
hello.cpp
|
||||
cpp_*_project
|
||||
131
packages/opencode/AGENTS.md
Normal file
131
packages/opencode/AGENTS.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# opencode database guide
|
||||
|
||||
## Database
|
||||
|
||||
- **Schema**: Drizzle schema lives in `packages/core/src/**/*.sql.ts`.
|
||||
- **Migrations**: database migrations live in `packages/core` and are applied by core.
|
||||
|
||||
## Development server
|
||||
|
||||
- Running `bun dev` from `packages/opencode` starts the live interactive TUI. Do not run it as a blocking foreground command when you need to inspect the result.
|
||||
- Start it in `tmux` instead: `tmux new-session -d -s opencode-dev 'bun dev'`.
|
||||
- Capture the current TUI output with: `tmux capture-pane -pt opencode-dev`.
|
||||
- Stop the session explicitly when done: `tmux kill-session -t opencode-dev`.
|
||||
|
||||
# Module shape
|
||||
|
||||
Do not use `export namespace Foo { ... }` for module organization. It is not
|
||||
standard ESM, it prevents tree-shaking, and it breaks Node's native TypeScript
|
||||
runner. Use flat top-level exports combined with a self-reexport at the bottom
|
||||
of the file:
|
||||
|
||||
```ts
|
||||
// src/foo/foo.ts
|
||||
export interface Interface { ... }
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
|
||||
export const layer = Layer.effect(Service, ...)
|
||||
export const defaultLayer = layer.pipe(...)
|
||||
|
||||
export * as Foo from "./foo"
|
||||
```
|
||||
|
||||
Consumers import the namespace projection:
|
||||
|
||||
```ts
|
||||
import { Foo } from "@/foo/foo"
|
||||
|
||||
yield * Foo.Service
|
||||
Foo.layer
|
||||
Foo.defaultLayer
|
||||
```
|
||||
|
||||
Namespace-private helpers stay as non-exported top-level declarations in the
|
||||
same file — they remain inaccessible to consumers (they are not projected by
|
||||
`export * as`) but are usable by the file's own code.
|
||||
|
||||
## When the file is an `index.ts`
|
||||
|
||||
If the module is `foo/index.ts` (single-namespace directory), use `"."` for
|
||||
the self-reexport source rather than `"./index"`:
|
||||
|
||||
```ts
|
||||
// src/foo/index.ts
|
||||
export const thing = ...
|
||||
|
||||
export * as Foo from "."
|
||||
```
|
||||
|
||||
## Multi-sibling directories
|
||||
|
||||
For directories with several independent modules (e.g. `src/session/`,
|
||||
`src/config/`), keep each sibling as its own file with its own self-reexport,
|
||||
and do not add a barrel `index.ts`. Consumers import the specific sibling:
|
||||
|
||||
```ts
|
||||
import { SessionRetry } from "@/session/retry"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
```
|
||||
|
||||
Barrels in multi-sibling directories force every import through the barrel to
|
||||
evaluate every sibling, which defeats tree-shaking and slows module load.
|
||||
|
||||
# opencode Effect rules
|
||||
|
||||
Use these rules when writing or migrating Effect code.
|
||||
|
||||
See `specs/effect/migration.md` for the compact pattern reference and examples.
|
||||
|
||||
## Core
|
||||
|
||||
- Use `Effect.gen(function* () { ... })` for composition.
|
||||
- Use `Effect.fn("Domain.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers.
|
||||
- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary outer `.pipe()` wrappers.
|
||||
- Use `Effect.callback` for callback-based APIs.
|
||||
- Use `Effect.void` instead of `Effect.succeed(undefined)` or `Effect.succeed(void 0)`.
|
||||
- Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`.
|
||||
|
||||
## Module conventions
|
||||
|
||||
- In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module.
|
||||
|
||||
## Schemas and errors
|
||||
|
||||
- Use `Schema.Class` for multi-field data.
|
||||
- Use branded schemas (`Schema.brand`) for single-value types.
|
||||
- Use `Schema.TaggedErrorClass` for typed errors.
|
||||
- Use `Schema.Defect` instead of `unknown` for defect-like causes.
|
||||
- In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
|
||||
|
||||
## Runtime vs InstanceState
|
||||
|
||||
- Use `makeRuntime` (from `src/effect/run-service.ts`) for all services. It returns `{ runPromise, runFork, runCallback }` backed by a shared `memoMap` that deduplicates layers.
|
||||
- Use `InstanceState` (from `src/effect/instance-state.ts`) for per-directory or per-project state that needs per-instance cleanup. It uses `ScopedCache` keyed by directory — each open project gets its own state, automatically cleaned up on disposal.
|
||||
- If two open directories should not share one copy of the service, it needs `InstanceState`.
|
||||
- Do the work directly in the `InstanceState.make` closure — `ScopedCache` handles run-once semantics. Don't add fibers, `ensure()` callbacks, or `started` flags on top.
|
||||
- Use `Effect.addFinalizer` or `Effect.acquireRelease` inside the `InstanceState.make` closure for cleanup (subscriptions, process teardown, etc.).
|
||||
- Use `Effect.forkScoped` inside the closure for background stream consumers — the fiber is interrupted when the instance is disposed.
|
||||
- To make a service's `init()` non-blocking, fork `InstanceState.get(state)` at the `init()` call site (e.g. `Effect.forkIn(scope)`), not by forking work inside the `InstanceState.make` closure. Forking inside the closure leaves state incomplete for other methods that read it.
|
||||
- `src/project/bootstrap.ts` already wraps every service `init()` in `Effect.forkDetach`, so `init()` is fire-and-forget in production. Keep `init()` methods synchronous internally; the caller controls concurrency.
|
||||
|
||||
## Effect v4 beta API
|
||||
|
||||
- `Effect.fork` and `Effect.forkDaemon` do not exist. Use `Effect.forkIn(scope)` to fork a fiber into a specific scope.
|
||||
|
||||
## Preferred Effect services
|
||||
|
||||
- In effectified services, prefer yielding existing Effect services over dropping down to ad hoc platform APIs.
|
||||
- Prefer `FileSystem.FileSystem` instead of raw `fs/promises` for effectful file I/O.
|
||||
- Prefer `ChildProcessSpawner.ChildProcessSpawner` with `ChildProcess.make(...)` instead of custom process wrappers.
|
||||
- Prefer `HttpClient.HttpClient` instead of raw `fetch`.
|
||||
- Prefer `Path.Path`, `Config`, `Clock`, and `DateTime` when those concerns are already inside Effect code.
|
||||
- For background loops or scheduled tasks, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
|
||||
|
||||
## 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.
|
||||
|
||||
## Callback boundaries
|
||||
|
||||
Use `EffectBridge` for native or external callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, plugin callbacks, etc.) that need to re-enter Effect services with instance/workspace context.
|
||||
|
||||
Plain async code should pass explicit context or stay inside an Effect fiber; do not add ambient instance context shims.
|
||||
18
packages/opencode/Dockerfile
Normal file
18
packages/opencode/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM alpine AS base
|
||||
|
||||
# Disable the runtime transpiler cache by default inside Docker containers.
|
||||
# On ephemeral containers, the cache is not useful
|
||||
ARG BUN_RUNTIME_TRANSPILER_CACHE_PATH=0
|
||||
ENV BUN_RUNTIME_TRANSPILER_CACHE_PATH=${BUN_RUNTIME_TRANSPILER_CACHE_PATH}
|
||||
RUN apk add libgcc libstdc++ ripgrep
|
||||
|
||||
FROM base AS build-amd64
|
||||
COPY dist/opencode-linux-x64-baseline-musl/bin/opencode /usr/local/bin/opencode
|
||||
|
||||
FROM base AS build-arm64
|
||||
COPY dist/opencode-linux-arm64-musl/bin/opencode /usr/local/bin/opencode
|
||||
|
||||
ARG TARGETARCH
|
||||
FROM build-${TARGETARCH}
|
||||
RUN opencode --version
|
||||
ENTRYPOINT ["opencode"]
|
||||
15
packages/opencode/README.md
Normal file
15
packages/opencode/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# js
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.2.12. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
|
||||
199
packages/opencode/bin/opencode
Executable file
199
packages/opencode/bin/opencode
Executable file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const childProcess = require("child_process")
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const os = require("os")
|
||||
|
||||
const forwardedSignals = ["SIGINT", "SIGTERM", "SIGHUP"]
|
||||
|
||||
function run(target) {
|
||||
const child = childProcess.spawn(target, process.argv.slice(2), {
|
||||
stdio: "inherit",
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error(error.message)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
const forwarders = {}
|
||||
for (const signal of forwardedSignals) {
|
||||
forwarders[signal] = () => {
|
||||
try {
|
||||
child.kill(signal)
|
||||
} catch {
|
||||
// The child may have already exited.
|
||||
}
|
||||
}
|
||||
process.on(signal, forwarders[signal])
|
||||
}
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
for (const forwardedSignal of forwardedSignals) {
|
||||
process.removeListener(forwardedSignal, forwarders[forwardedSignal])
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
return
|
||||
}
|
||||
|
||||
process.exit(typeof code === "number" ? code : 0)
|
||||
})
|
||||
}
|
||||
|
||||
const envPath = process.env.OPENCODE_BIN_PATH
|
||||
|
||||
const scriptPath = fs.realpathSync(__filename)
|
||||
const scriptDir = path.dirname(scriptPath)
|
||||
|
||||
//
|
||||
const cached = path.join(scriptDir, ".opencode")
|
||||
|
||||
const platformMap = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
}
|
||||
const archMap = {
|
||||
x64: "x64",
|
||||
arm64: "arm64",
|
||||
arm: "arm",
|
||||
}
|
||||
|
||||
let platform = platformMap[os.platform()]
|
||||
if (!platform) {
|
||||
platform = os.platform()
|
||||
}
|
||||
let arch = archMap[os.arch()]
|
||||
if (!arch) {
|
||||
arch = os.arch()
|
||||
}
|
||||
const base = "opencode-" + platform + "-" + arch
|
||||
const binary = platform === "windows" ? "opencode.exe" : "opencode"
|
||||
|
||||
function supportsAvx2() {
|
||||
if (arch !== "x64") return false
|
||||
|
||||
if (platform === "linux") {
|
||||
try {
|
||||
return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8"))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === "darwin") {
|
||||
try {
|
||||
const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], {
|
||||
encoding: "utf8",
|
||||
timeout: 1500,
|
||||
})
|
||||
if (result.status !== 0) return false
|
||||
return (result.stdout || "").trim() === "1"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === "windows") {
|
||||
const cmd =
|
||||
'(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)'
|
||||
|
||||
for (const exe of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) {
|
||||
try {
|
||||
const result = childProcess.spawnSync(exe, ["-NoProfile", "-NonInteractive", "-Command", cmd], {
|
||||
encoding: "utf8",
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
})
|
||||
if (result.status !== 0) continue
|
||||
const out = (result.stdout || "").trim().toLowerCase()
|
||||
if (out === "true" || out === "1") return true
|
||||
if (out === "false" || out === "0") return false
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const names = (() => {
|
||||
const avx2 = supportsAvx2()
|
||||
const baseline = arch === "x64" && !avx2
|
||||
|
||||
if (platform === "linux") {
|
||||
const musl = (() => {
|
||||
try {
|
||||
if (fs.existsSync("/etc/alpine-release")) return true
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" })
|
||||
const text = ((result.stdout || "") + (result.stderr || "")).toLowerCase()
|
||||
if (text.includes("musl")) return true
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return false
|
||||
})()
|
||||
|
||||
if (musl) {
|
||||
if (arch === "x64") {
|
||||
if (baseline) return [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base]
|
||||
return [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]
|
||||
}
|
||||
return [`${base}-musl`, base]
|
||||
}
|
||||
|
||||
if (arch === "x64") {
|
||||
if (baseline) return [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`]
|
||||
return [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]
|
||||
}
|
||||
return [base, `${base}-musl`]
|
||||
}
|
||||
|
||||
if (arch === "x64") {
|
||||
if (baseline) return [`${base}-baseline`, base]
|
||||
return [base, `${base}-baseline`]
|
||||
}
|
||||
return [base]
|
||||
})()
|
||||
|
||||
function findBinary(startDir) {
|
||||
let current = startDir
|
||||
for (;;) {
|
||||
const modules = path.join(current, "node_modules")
|
||||
if (fs.existsSync(modules)) {
|
||||
for (const name of names) {
|
||||
const candidate = path.join(modules, name, "bin", binary)
|
||||
if (fs.existsSync(candidate)) return candidate
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(current)
|
||||
if (parent === current) {
|
||||
return
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir))
|
||||
if (!resolved) {
|
||||
console.error(
|
||||
"It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing " +
|
||||
names.map((n) => `\"${n}\"`).join(" or ") +
|
||||
" package",
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
run(resolved)
|
||||
7
packages/opencode/bunfig.toml
Normal file
7
packages/opencode/bunfig.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
preload = ["@opentui/solid/preload"]
|
||||
|
||||
[test]
|
||||
preload = ["@opentui/solid/preload", "./test/preload.ts"]
|
||||
# timeout is not actually parsed from bunfig.toml (see src/bunfig.zig in oven-sh/bun)
|
||||
# using --timeout in package.json scripts instead
|
||||
# https://github.com/oven-sh/bun/issues/7789
|
||||
0
packages/opencode/git
Normal file
0
packages/opencode/git
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `session` ADD `metadata` text;
|
||||
File diff suppressed because it is too large
Load Diff
155
packages/opencode/package.json
Normal file
155
packages/opencode/package.json
Normal file
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.17.4",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode effect --fail-on-missing --fail-on-skip",
|
||||
"bench:test": "bun run script/bench-test-suite.ts",
|
||||
"profile:test": "bun run script/profile-test-files.ts",
|
||||
"build": "bun run script/build.ts",
|
||||
"dev": "bun run --conditions=browser ./src/index.ts --pure",
|
||||
"dev:temporary": "bun run --conditions=browser ./src/temporary.ts"
|
||||
},
|
||||
"bin": {
|
||||
"aircoding": "./bin/opencode"
|
||||
},
|
||||
"exports": {
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"imports": {
|
||||
"#db": {
|
||||
"bun": "./src/storage/db.bun.ts",
|
||||
"node": "./src/storage/db.node.ts",
|
||||
"default": "./src/storage/db.bun.ts"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.4",
|
||||
"@octokit/webhooks-types": "7.6.1",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/http-recorder": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/babel__core": "7.20.5",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/cross-spawn": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/npm-package-arg": "6.1.4",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "catalog:",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"why-is-node-running": "3.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "1.11.1",
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.21.0",
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.112",
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google": "3.0.73",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/openai": "3.0.53",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/togetherai": "2.0.41",
|
||||
"@ai-sdk/vercel": "2.0.39",
|
||||
"@ai-sdk/xai": "3.0.82",
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@ff-labs/fff-bun": "0.9.4",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.9.0",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.6.1",
|
||||
"@opentelemetry/sdk-trace-node": "2.6.1",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/scheduled": "1.5.2",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@types/ws": "8.18.1",
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"ai": "catalog:",
|
||||
"ai-gateway-provider": "3.1.2",
|
||||
"bonjour-service": "1.3.0",
|
||||
"chokidar": "4.0.3",
|
||||
"cross-spawn": "catalog:",
|
||||
"decimal.js": "10.5.0",
|
||||
"diff": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "6.9.3",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
"htmlparser2": "8.0.2",
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.0.3",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"open": "10.1.2",
|
||||
"opencode-gitlab-auth": "2.1.0",
|
||||
"opencode-poe-auth": "0.0.1",
|
||||
"opentui-spinner": "catalog:",
|
||||
"partial-json": "0.1.7",
|
||||
"remeda": "catalog:",
|
||||
"semver": "^7.6.3",
|
||||
"solid-js": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"turndown": "7.2.0",
|
||||
"ulid": "catalog:",
|
||||
"venice-ai-sdk-provider": "2.0.2",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"ws": "8.21.0",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"overrides": {
|
||||
"drizzle-orm": "catalog:"
|
||||
}
|
||||
}
|
||||
1
packages/opencode/parsers-config.ts
Normal file
1
packages/opencode/parsers-config.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "@opencode-ai/tui/parsers-config"
|
||||
94
packages/opencode/script/bench-search.ts
Normal file
94
packages/opencode/script/bench-search.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Effect } from "effect"
|
||||
import { Fff } from "@opencode-ai/core/filesystem/fff.bun"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
|
||||
const dir = AbsolutePath.make(process.cwd())
|
||||
|
||||
const FILE_QUERIES = ["fff", "package.json", "tools/ experiment"]
|
||||
const GREP_QUERIES = ["FileFinder", "import", "grep", "autocomplete"]
|
||||
const GLOB_QUERIES = ["**/*.test.ts"]
|
||||
|
||||
const FILE_LIMIT = 100
|
||||
const GREP_LIMIT = 50
|
||||
const GLOB_LIMIT = 50
|
||||
|
||||
const run = <A, R>(effect: Effect.Effect<A, unknown, R>) =>
|
||||
AppRuntime.runPromise(
|
||||
InstanceStore.Service.use((store) => store.provide({ directory: dir }, effect as never)),
|
||||
) as Promise<A>
|
||||
|
||||
// --- raw Fff picker ---
|
||||
const t0 = performance.now()
|
||||
const made = Fff.create({ basePath: dir, aiMode: true })
|
||||
if (!made.ok) {
|
||||
console.error("Fff.create failed:", made.error)
|
||||
process.exit(1)
|
||||
}
|
||||
const picker = made.value
|
||||
console.log(`picker create: ${(performance.now() - t0).toFixed(1)}ms`)
|
||||
|
||||
const tw = performance.now()
|
||||
await picker.waitForScan(2_500)
|
||||
console.log(`wait for scan: ${(performance.now() - tw).toFixed(1)}ms`)
|
||||
|
||||
// warmup grep to let the content index build
|
||||
const tWarmup = performance.now()
|
||||
picker.grep("_warmup_", { mode: "regex", maxMatchesPerFile: 1, timeBudgetMs: 1_500 })
|
||||
console.log(`grep warmup: ${(performance.now() - tWarmup).toFixed(1)}ms`)
|
||||
|
||||
console.log()
|
||||
console.log("--- raw picker (warm) ---")
|
||||
|
||||
for (const q of FILE_QUERIES) {
|
||||
const t = performance.now()
|
||||
const r = picker.fileSearch(q, { pageSize: Math.max(FILE_LIMIT, 100) })
|
||||
const count = r.ok ? r.value.items.length : "err"
|
||||
console.log(`[picker] fileSearch "${q}": ${(performance.now() - t).toFixed(1)}ms (${count} results)`)
|
||||
}
|
||||
|
||||
for (const q of GREP_QUERIES) {
|
||||
const t = performance.now()
|
||||
const r = picker.grep(q, { mode: "regex", pageSize: GREP_LIMIT, timeBudgetMs: 1_500 })
|
||||
const count = r.ok ? r.value.items.length : "err"
|
||||
console.log(`[picker] grep "${q}": ${(performance.now() - t).toFixed(1)}ms (${count} matches)`)
|
||||
}
|
||||
|
||||
picker.destroy()
|
||||
|
||||
// --- Search service: init breakdown ---
|
||||
console.log()
|
||||
|
||||
// 1) runtime + InstanceState + picker create + scan poll
|
||||
const tRuntime = performance.now()
|
||||
console.log(`[Search] init file (runtime + picker + scan): ${(performance.now() - tRuntime).toFixed(1)}ms`)
|
||||
|
||||
// 2) grep warmup (content index cold-start inside the Search service picker)
|
||||
const tGrepWarmup = performance.now()
|
||||
await run(FileSystem.Service.use((svc) => svc.grep({ pattern: "_warmup_grep_", limit: 1 })))
|
||||
console.log(`[Search] init grep (content index warmup): ${(performance.now() - tGrepWarmup).toFixed(1)}ms`)
|
||||
|
||||
console.log()
|
||||
console.log("--- Search service (warm) ---")
|
||||
|
||||
for (const q of FILE_QUERIES) {
|
||||
const t = performance.now()
|
||||
const r = await run(FileSystem.Service.use((svc) => svc.find({ query: q, limit: FILE_LIMIT })))
|
||||
console.log(`[Search.find] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.length} results)`)
|
||||
}
|
||||
|
||||
for (const q of GREP_QUERIES) {
|
||||
const t = performance.now()
|
||||
const r = await run(FileSystem.Service.use((svc) => svc.grep({ pattern: q, limit: GREP_LIMIT })))
|
||||
console.log(`[Search.grep] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.length} matches)`)
|
||||
}
|
||||
|
||||
for (const q of GLOB_QUERIES) {
|
||||
const t = performance.now()
|
||||
const r = await run(FileSystem.Service.use((svc) => svc.glob({ pattern: q, limit: GLOB_LIMIT })))
|
||||
console.log(`[Search.glob] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.length} files)`)
|
||||
}
|
||||
|
||||
process.exit(0)
|
||||
52
packages/opencode/script/bench-test-suite.ts
Normal file
52
packages/opencode/script/bench-test-suite.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Full-suite timing harness for the test-speed research in ../../perf/test-suite.md.
|
||||
// Use this for periodic sanity checks; use profile-test-files.ts for discovery.
|
||||
// Env: BENCH_WARMUPS=0 BENCH_RUNS=1 bun run bench:test
|
||||
const warmups = Number(Bun.env.BENCH_WARMUPS ?? 0)
|
||||
const runs = Number(Bun.env.BENCH_RUNS ?? 1)
|
||||
const timings: number[] = []
|
||||
|
||||
if (!Number.isInteger(warmups) || warmups < 0) {
|
||||
console.error("BENCH_WARMUPS must be a non-negative integer")
|
||||
process.exit(1)
|
||||
}
|
||||
if (!Number.isInteger(runs) || runs < 1) {
|
||||
console.error("BENCH_RUNS must be a positive integer")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
for (const index of Array.from({ length: warmups + runs }, (_, index) => index)) {
|
||||
const measured = index >= warmups
|
||||
const label = measured ? `run ${index - warmups + 1}/${runs}` : `warmup ${index + 1}/${warmups}`
|
||||
const start = performance.now()
|
||||
console.log(`bench:test ${label}`)
|
||||
|
||||
const proc = Bun.spawn(["bun", "test", "--timeout", "30000"], {
|
||||
cwd: import.meta.dir + "/..",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
env: Bun.env,
|
||||
})
|
||||
|
||||
const exitCode = await proc.exited
|
||||
if (exitCode !== 0) {
|
||||
console.error(`bench:test failed during ${label} with exit code ${exitCode}`)
|
||||
process.exit(exitCode)
|
||||
}
|
||||
|
||||
const seconds = (performance.now() - start) / 1000
|
||||
console.log(`bench:test ${label} ${seconds.toFixed(3)}s`)
|
||||
if (measured) timings.push(seconds)
|
||||
}
|
||||
|
||||
const sorted = timings.toSorted((a, b) => a - b)
|
||||
const median = sorted[Math.floor(sorted.length / 2)]
|
||||
const mean = timings.reduce((sum, timing) => sum + timing, 0) / timings.length
|
||||
const best = sorted[0] ?? median
|
||||
const worst = sorted.at(-1) ?? median
|
||||
|
||||
console.log(
|
||||
`bench:test median=${median.toFixed(3)}s mean=${mean.toFixed(3)}s best=${best.toFixed(3)}s worst=${worst.toFixed(3)}s`,
|
||||
)
|
||||
console.log(`METRIC test_suite_seconds=${median.toFixed(3)}`)
|
||||
console.log(`METRIC test_suite_best_seconds=${best.toFixed(3)}`)
|
||||
console.log(`METRIC test_suite_worst_seconds=${worst.toFixed(3)}`)
|
||||
31
packages/opencode/script/build-node.ts
Executable file
31
packages/opencode/script/build-node.ts
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const dir = path.resolve(__dirname, "..")
|
||||
|
||||
process.chdir(dir)
|
||||
|
||||
const generated = await import("./generate.ts")
|
||||
|
||||
await Bun.build({
|
||||
target: "node",
|
||||
entrypoints: ["./src/node.ts"],
|
||||
outdir: "./dist/node",
|
||||
format: "esm",
|
||||
sourcemap: "linked",
|
||||
external: ["jsonc-parser", "@lydell/node-pty"],
|
||||
define: {
|
||||
OPENCODE_MODELS_DEV: generated.modelsData,
|
||||
OPENCODE_CHANNEL: `'${Script.channel}'`,
|
||||
},
|
||||
files: {
|
||||
"opencode-web-ui.gen.ts": "",
|
||||
},
|
||||
})
|
||||
|
||||
console.log("Build complete")
|
||||
243
packages/opencode/script/build.ts
Executable file
243
packages/opencode/script/build.ts
Executable file
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const dir = path.resolve(__dirname, "..")
|
||||
|
||||
process.chdir(dir)
|
||||
|
||||
const generated = await import("./generate.ts")
|
||||
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import pkg from "../package.json"
|
||||
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
const baselineFlag = process.argv.includes("--baseline")
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
const sourcemapsFlag = process.argv.includes("--sourcemaps")
|
||||
const plugin = createSolidTransformPlugin()
|
||||
const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui")
|
||||
|
||||
const createEmbeddedWebUIBundle = async () => {
|
||||
console.log(`Building Web UI to embed in the binary`)
|
||||
const appDir = path.join(import.meta.dirname, "../../app")
|
||||
const dist = path.join(appDir, "dist")
|
||||
await $`OPENCODE_CHANNEL=${Script.channel} bun run --cwd ${appDir} build`
|
||||
const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: dist })))
|
||||
.map((file) => file.replaceAll("\\", "/"))
|
||||
.filter((file) => !file.endsWith(".map"))
|
||||
.sort()
|
||||
const imports = files.map((file, i) => {
|
||||
const spec = path.relative(dir, path.join(dist, file)).replaceAll("\\", "/")
|
||||
return `import file_${i} from ${JSON.stringify(spec.startsWith(".") ? spec : `./${spec}`)} with { type: "file" };`
|
||||
})
|
||||
const entries = files.map((file, i) => ` ${JSON.stringify(file)}: file_${i},`)
|
||||
return [
|
||||
`// Import all files as file_$i with type: "file"`,
|
||||
...imports,
|
||||
`// Export with original mappings`,
|
||||
`export default {`,
|
||||
...entries,
|
||||
`}`,
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const embeddedFileMap = skipEmbedWebUi ? null : await createEmbeddedWebUIBundle()
|
||||
|
||||
const allTargets: {
|
||||
os: string
|
||||
arch: "arm64" | "x64"
|
||||
abi?: "musl"
|
||||
avx2?: false
|
||||
}[] = [
|
||||
{
|
||||
os: "linux",
|
||||
arch: "arm64",
|
||||
},
|
||||
{
|
||||
os: "linux",
|
||||
arch: "x64",
|
||||
},
|
||||
{
|
||||
os: "linux",
|
||||
arch: "x64",
|
||||
avx2: false,
|
||||
},
|
||||
{
|
||||
os: "linux",
|
||||
arch: "arm64",
|
||||
abi: "musl",
|
||||
},
|
||||
{
|
||||
os: "linux",
|
||||
arch: "x64",
|
||||
abi: "musl",
|
||||
},
|
||||
{
|
||||
os: "linux",
|
||||
arch: "x64",
|
||||
abi: "musl",
|
||||
avx2: false,
|
||||
},
|
||||
{
|
||||
os: "darwin",
|
||||
arch: "arm64",
|
||||
},
|
||||
{
|
||||
os: "darwin",
|
||||
arch: "x64",
|
||||
},
|
||||
{
|
||||
os: "darwin",
|
||||
arch: "x64",
|
||||
avx2: false,
|
||||
},
|
||||
{
|
||||
os: "win32",
|
||||
arch: "arm64",
|
||||
},
|
||||
{
|
||||
os: "win32",
|
||||
arch: "x64",
|
||||
},
|
||||
{
|
||||
os: "win32",
|
||||
arch: "x64",
|
||||
avx2: false,
|
||||
},
|
||||
]
|
||||
|
||||
const targets = singleFlag
|
||||
? allTargets.filter((item) => {
|
||||
if (item.os !== process.platform || item.arch !== process.arch) {
|
||||
return false
|
||||
}
|
||||
|
||||
// When building for the current platform, prefer a single native binary by default.
|
||||
// Baseline binaries require additional Bun artifacts and can be flaky to download.
|
||||
if (item.avx2 === false) {
|
||||
return baselineFlag
|
||||
}
|
||||
|
||||
// also skip abi-specific builds for the same reason
|
||||
if (item.abi !== undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
: allTargets
|
||||
|
||||
await $`rm -rf dist`
|
||||
|
||||
const binaries: Record<string, string> = {}
|
||||
if (!skipInstall) {
|
||||
await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
|
||||
await $`bun install --os="*" --cpu="*" @parcel/watcher@${pkg.dependencies["@parcel/watcher"]}`
|
||||
await $`bun install --os="*" --cpu="*" @ff-labs/fff-bun@${pkg.dependencies["@ff-labs/fff-bun"]}`
|
||||
}
|
||||
for (const item of targets) {
|
||||
const name = [
|
||||
pkg.name,
|
||||
// changing to win32 flags npm for some reason
|
||||
item.os === "win32" ? "windows" : item.os,
|
||||
item.arch,
|
||||
item.avx2 === false ? "baseline" : undefined,
|
||||
item.abi === undefined ? undefined : item.abi,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("-")
|
||||
console.log(`building ${name}`)
|
||||
await $`mkdir -p dist/${name}/bin`
|
||||
|
||||
const localPath = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
|
||||
const rootPath = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
|
||||
const parserWorker = fs.realpathSync(fs.existsSync(localPath) ? localPath : rootPath)
|
||||
const workerPath = "./src/cli/tui/worker.ts"
|
||||
|
||||
// Use platform-specific bunfs root path based on target OS
|
||||
const bunfsRoot = item.os === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/"
|
||||
const workerRelativePath = path.relative(dir, parserWorker).replaceAll("\\", "/")
|
||||
|
||||
await Bun.build({
|
||||
conditions: ["bun", "node"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [plugin],
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
sourcemap: sourcemapsFlag ? "linked" : "none",
|
||||
splitting: true,
|
||||
compile: {
|
||||
autoloadBunfig: false,
|
||||
autoloadDotenv: false,
|
||||
autoloadTsconfig: true,
|
||||
autoloadPackageJson: true,
|
||||
target: name.replace(pkg.name, "bun") as any,
|
||||
outfile: `dist/${name}/bin/opencode`,
|
||||
execArgv: [`--user-agent=opencode/${Script.version}`, "--use-system-ca", "--"],
|
||||
windows: {},
|
||||
},
|
||||
files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {},
|
||||
entrypoints: ["./src/index.ts", parserWorker, workerPath, ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : [])],
|
||||
define: {
|
||||
FFF_LIBC: JSON.stringify(item.abi === "musl" ? "musl" : "gnu"),
|
||||
OPENCODE_VERSION: `'${Script.version}'`,
|
||||
OPENCODE_MODELS_DEV: generated.modelsData,
|
||||
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath,
|
||||
OPENCODE_WORKER_PATH: workerPath,
|
||||
OPENCODE_CHANNEL: `'${Script.channel}'`,
|
||||
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "",
|
||||
...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
// Smoke test: only run if binary is for current platform
|
||||
if (item.os === process.platform && item.arch === process.arch && !item.abi) {
|
||||
const binaryPath = `dist/${name}/bin/opencode`
|
||||
console.log(`Running smoke test: ${binaryPath} --version`)
|
||||
try {
|
||||
const versionOutput = await $`${binaryPath} --version`.text()
|
||||
console.log(`Smoke test passed: ${versionOutput.trim()}`)
|
||||
} catch (e) {
|
||||
console.error(`Smoke test failed for ${name}:`, e)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
await $`rm -rf ./dist/${name}/bin/tui`
|
||||
await Bun.file(`dist/${name}/package.json`).write(
|
||||
JSON.stringify(
|
||||
{
|
||||
name,
|
||||
version: Script.version,
|
||||
preferUnplugged: true,
|
||||
os: [item.os],
|
||||
cpu: [item.arch],
|
||||
...(item.abi ? { libc: [item.abi] } : {}),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
binaries[name] = Script.version
|
||||
}
|
||||
|
||||
if (Script.release) {
|
||||
for (const key of Object.keys(binaries)) {
|
||||
if (key.includes("linux")) {
|
||||
await $`tar -czf ../../${key}.tar.gz *`.cwd(`dist/${key}/bin`)
|
||||
} else {
|
||||
await $`zip -r ../../${key}.zip *`.cwd(`dist/${key}/bin`)
|
||||
}
|
||||
}
|
||||
await $`gh release upload v${Script.version} ./dist/*.zip ./dist/*.tar.gz --clobber --repo ${process.env.GH_REPO}`
|
||||
}
|
||||
|
||||
export { binaries }
|
||||
14
packages/opencode/script/generate.ts
Normal file
14
packages/opencode/script/generate.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const dir = path.resolve(__dirname, "..")
|
||||
|
||||
process.chdir(dir)
|
||||
|
||||
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
|
||||
export const modelsData = process.env.MODELS_DEV_API_JSON
|
||||
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
|
||||
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
|
||||
console.log("Loaded models.dev snapshot")
|
||||
1
packages/opencode/script/httpapi-exercise.ts
Normal file
1
packages/opencode/script/httpapi-exercise.ts
Normal file
@@ -0,0 +1 @@
|
||||
await import("../test/server/httpapi-exercise/index")
|
||||
189
packages/opencode/script/postinstall.mjs
Normal file
189
packages/opencode/script/postinstall.mjs
Normal file
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import childProcess from "child_process"
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { createRequire } from "module"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const require = createRequire(import.meta.url)
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8"))
|
||||
|
||||
const platformMap = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
}
|
||||
const archMap = {
|
||||
x64: "x64",
|
||||
arm64: "arm64",
|
||||
arm: "arm",
|
||||
}
|
||||
|
||||
const platform = platformMap[os.platform()] ?? os.platform()
|
||||
const arch = archMap[os.arch()] ?? os.arch()
|
||||
const base = `opencode-${platform}-${arch}`
|
||||
const sourceBinary = platform === "windows" ? "opencode.exe" : "opencode"
|
||||
const targetBinary = path.join(__dirname, "bin", "opencode.exe")
|
||||
|
||||
function supportsAvx2() {
|
||||
if (arch !== "x64") return false
|
||||
|
||||
if (platform === "linux") {
|
||||
try {
|
||||
return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8"))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === "darwin") {
|
||||
try {
|
||||
const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], {
|
||||
encoding: "utf8",
|
||||
timeout: 1500,
|
||||
})
|
||||
if (result.status !== 0) return false
|
||||
return (result.stdout || "").trim() === "1"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === "windows") {
|
||||
const command =
|
||||
'(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)'
|
||||
|
||||
for (const executable of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) {
|
||||
try {
|
||||
const result = childProcess.spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", command], {
|
||||
encoding: "utf8",
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
})
|
||||
if (result.status !== 0) continue
|
||||
const output = (result.stdout || "").trim().toLowerCase()
|
||||
if (output === "true" || output === "1") return true
|
||||
if (output === "false" || output === "0") return false
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isMusl() {
|
||||
if (platform !== "linux") return false
|
||||
|
||||
try {
|
||||
if (fs.existsSync("/etc/alpine-release")) return true
|
||||
} catch {
|
||||
// Ignore filesystem probes that are blocked by the host.
|
||||
}
|
||||
|
||||
try {
|
||||
const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" })
|
||||
return `${result.stdout || ""}${result.stderr || ""}`.toLowerCase().includes("musl")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function packageNames() {
|
||||
const baseline = arch === "x64" && !supportsAvx2()
|
||||
|
||||
if (platform === "linux") {
|
||||
if (isMusl()) {
|
||||
if (arch === "x64")
|
||||
return baseline
|
||||
? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base]
|
||||
: [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]
|
||||
return [`${base}-musl`, base]
|
||||
}
|
||||
|
||||
if (arch === "x64")
|
||||
return baseline
|
||||
? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`]
|
||||
: [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]
|
||||
return [base, `${base}-musl`]
|
||||
}
|
||||
|
||||
if (arch === "x64") return baseline ? [`${base}-baseline`, base] : [base, `${base}-baseline`]
|
||||
return [base]
|
||||
}
|
||||
|
||||
function resolveBinary(name) {
|
||||
const packageJsonPath = require.resolve(`${name}/package.json`)
|
||||
const binaryPath = path.join(path.dirname(packageJsonPath), "bin", sourceBinary)
|
||||
if (!fs.existsSync(binaryPath)) throw new Error(`Binary not found at ${binaryPath}`)
|
||||
return binaryPath
|
||||
}
|
||||
|
||||
function installPackage(name) {
|
||||
const version = packageJson.optionalDependencies?.[name]
|
||||
if (!version) return
|
||||
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-install-"))
|
||||
try {
|
||||
const result = childProcess.spawnSync(
|
||||
"npm",
|
||||
["install", "--ignore-scripts", "--no-save", "--loglevel=error", "--prefix", temp, `${name}@${version}`],
|
||||
{ stdio: "inherit", windowsHide: true },
|
||||
)
|
||||
if (result.status !== 0) return
|
||||
const packageDir = path.join(temp, "node_modules", name)
|
||||
copyBinary(path.join(packageDir, "bin", sourceBinary), targetBinary)
|
||||
return true
|
||||
} finally {
|
||||
fs.rmSync(temp, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function copyBinary(source, target) {
|
||||
if (!fs.existsSync(source)) throw new Error(`Binary not found at ${source}`)
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||||
if (fs.existsSync(target)) fs.unlinkSync(target)
|
||||
try {
|
||||
fs.linkSync(source, target)
|
||||
} catch {
|
||||
fs.copyFileSync(source, target)
|
||||
}
|
||||
fs.chmodSync(target, 0o755)
|
||||
}
|
||||
|
||||
function verifyBinary() {
|
||||
const result = childProcess.spawnSync(targetBinary, ["--version"], {
|
||||
encoding: "utf8",
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
})
|
||||
return result.status === 0
|
||||
}
|
||||
|
||||
function main() {
|
||||
for (const name of packageNames()) {
|
||||
try {
|
||||
copyBinary(resolveBinary(name), targetBinary)
|
||||
if (verifyBinary()) return
|
||||
} catch {
|
||||
if (installPackage(name) && verifyBinary()) return
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`It seems your package manager failed to install the right opencode CLI package. Try manually installing ${packageNames()
|
||||
.map((name) => JSON.stringify(name))
|
||||
.join(" or ")}.`,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
console.error(error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
42
packages/opencode/script/profile-test-files.ts
Normal file
42
packages/opencode/script/profile-test-files.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// Per-file profiler for finding candidate test-speed work; see ../../perf/test-suite.md
|
||||
// for the benchmark notes, kept wins, and discarded experiments.
|
||||
// Example: TEST_PROFILE_GLOB='test/server/**/*.test.ts' TEST_PROFILE_TOP=15 bun run profile:test
|
||||
const pattern = Bun.env.TEST_PROFILE_GLOB ?? "test/**/*.test.{ts,tsx}"
|
||||
const limit = Number(Bun.env.TEST_PROFILE_LIMIT ?? 0)
|
||||
const timeout = Bun.env.TEST_PROFILE_TIMEOUT ?? "30000"
|
||||
const files = Array.fromAsync(new Bun.Glob(pattern).scan({ cwd: import.meta.dir + "/..", onlyFiles: true }))
|
||||
.then((files) => files.toSorted())
|
||||
.then((files) => (limit > 0 ? files.slice(0, limit) : files))
|
||||
|
||||
const results = []
|
||||
for (const file of await files) {
|
||||
const start = performance.now()
|
||||
const proc = Bun.spawn(["bun", "test", "--timeout", timeout, file], {
|
||||
cwd: import.meta.dir + "/..",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: Bun.env,
|
||||
})
|
||||
const [output, error, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
])
|
||||
const seconds = (performance.now() - start) / 1000
|
||||
results.push({ file, seconds, exitCode })
|
||||
console.log(`${exitCode === 0 ? "PASS" : "FAIL"} ${seconds.toFixed(3)}s ${file}`)
|
||||
if (exitCode !== 0) console.log((output + error).trim())
|
||||
}
|
||||
|
||||
const sorted = results.toSorted((a, b) => b.seconds - a.seconds)
|
||||
console.log("\nSlowest test files:")
|
||||
for (const result of sorted.slice(0, Number(Bun.env.TEST_PROFILE_TOP ?? 20))) {
|
||||
console.log(`${result.seconds.toFixed(3)}s ${result.exitCode === 0 ? "PASS" : "FAIL"} ${result.file}`)
|
||||
}
|
||||
|
||||
if (sorted[0]) {
|
||||
console.log(`METRIC slowest_test_file_seconds=${sorted[0].seconds.toFixed(3)}`)
|
||||
console.log(`METRIC profiled_test_files=${results.length}`)
|
||||
}
|
||||
|
||||
if (results.some((result) => result.exitCode !== 0)) process.exit(1)
|
||||
213
packages/opencode/script/publish.ts
Executable file
213
packages/opencode/script/publish.ts
Executable file
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env bun
|
||||
import { $ } from "bun"
|
||||
import pkg from "../package.json"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const dir = fileURLToPath(new URL("..", import.meta.url))
|
||||
process.chdir(dir)
|
||||
|
||||
async function published(name: string, version: string) {
|
||||
return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0
|
||||
}
|
||||
|
||||
async function publish(dir: string, name: string, version: string) {
|
||||
// GitHub artifact downloads can drop the executable bit, and Docker uses the
|
||||
// unpacked dist binaries directly rather than the published tarball.
|
||||
if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir)
|
||||
if (await published(name, version)) {
|
||||
console.log(`already published ${name}@${version}`)
|
||||
return
|
||||
}
|
||||
await $`bun pm pack`.cwd(dir)
|
||||
await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir)
|
||||
}
|
||||
|
||||
const binaries: Record<string, string> = {}
|
||||
for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" })) {
|
||||
const pkg = await Bun.file(`./dist/${filepath}`).json()
|
||||
binaries[pkg.name] = pkg.version
|
||||
}
|
||||
console.log("binaries", binaries)
|
||||
const version = Object.values(binaries)[0]
|
||||
|
||||
await $`mkdir -p ./dist/${pkg.name}`
|
||||
await $`mkdir -p ./dist/${pkg.name}/bin`
|
||||
await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs`
|
||||
await Bun.file(`./dist/${pkg.name}/LICENSE`).write(await Bun.file("../../LICENSE").text())
|
||||
await Bun.file(`./dist/${pkg.name}/bin/${pkg.name}.exe`).write(
|
||||
[
|
||||
`echo "Error: ${pkg.name}-ai's postinstall script was not run." >&2`,
|
||||
'echo "" >&2',
|
||||
'echo "This occurs when using --ignore-scripts during installation, or when using a" >&2',
|
||||
'echo "package manager like pnpm that does not run postinstall scripts by default." >&2',
|
||||
'echo "" >&2',
|
||||
'echo "To fix this, run the postinstall script manually:" >&2',
|
||||
`echo " cd node_modules/${pkg.name}-ai && node postinstall.mjs" >&2`,
|
||||
'echo "" >&2',
|
||||
`echo "Or reinstall ${pkg.name}-ai without the --ignore-scripts flag." >&2`,
|
||||
"exit 1",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||
JSON.stringify(
|
||||
{
|
||||
name: pkg.name + "-ai",
|
||||
bin: {
|
||||
[pkg.name]: `./bin/${pkg.name}.exe`,
|
||||
},
|
||||
scripts: {
|
||||
postinstall: "node ./postinstall.mjs",
|
||||
},
|
||||
version: version,
|
||||
license: pkg.license,
|
||||
os: ["darwin", "linux", "win32"],
|
||||
cpu: ["arm64", "x64"],
|
||||
optionalDependencies: binaries,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
const tasks = Object.entries(binaries).map(async ([name]) => {
|
||||
await publish(`./dist/${name}`, name, binaries[name])
|
||||
})
|
||||
await Promise.all(tasks)
|
||||
await publish(`./dist/${pkg.name}`, `${pkg.name}-ai`, version)
|
||||
|
||||
const image = "ghcr.io/anomalyco/opencode"
|
||||
const platforms = "linux/amd64,linux/arm64"
|
||||
const tags = [`${image}:${version}`, `${image}:${Script.channel}`]
|
||||
const tagFlags = tags.flatMap((t) => ["-t", t])
|
||||
|
||||
// registries
|
||||
if (!Script.preview) {
|
||||
await $`docker buildx build --platform ${platforms} ${tagFlags} --push .`
|
||||
// Calculate SHA values
|
||||
const arm64Sha = await $`sha256sum ./dist/opencode-linux-arm64.tar.gz | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||
const x64Sha = await $`sha256sum ./dist/opencode-linux-x64.tar.gz | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||
const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||
const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||
|
||||
const [pkgver, _subver = ""] = Script.version.split(/(-.*)/, 2)
|
||||
|
||||
// arch
|
||||
const binaryPkgbuild = [
|
||||
"# Maintainer: dax",
|
||||
"# Maintainer: adam",
|
||||
"",
|
||||
"pkgname='opencode-bin'",
|
||||
`pkgver=${pkgver}`,
|
||||
`_subver=${_subver}`,
|
||||
"options=('!debug' '!strip')",
|
||||
"pkgrel=1",
|
||||
"pkgdesc='The AI coding agent built for the terminal.'",
|
||||
"url='https://github.com/anomalyco/opencode'",
|
||||
"arch=('aarch64' 'x86_64')",
|
||||
"license=('MIT')",
|
||||
"provides=('opencode')",
|
||||
"conflicts=('opencode')",
|
||||
"depends=('ripgrep')",
|
||||
"",
|
||||
`source_aarch64=("\${pkgname}_\${pkgver}_aarch64.tar.gz::https://github.com/anomalyco/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-arm64.tar.gz")`,
|
||||
`sha256sums_aarch64=('${arm64Sha}')`,
|
||||
|
||||
`source_x86_64=("\${pkgname}_\${pkgver}_x86_64.tar.gz::https://github.com/anomalyco/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-x64.tar.gz")`,
|
||||
`sha256sums_x86_64=('${x64Sha}')`,
|
||||
"",
|
||||
"package() {",
|
||||
' install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode"',
|
||||
"}",
|
||||
"",
|
||||
].join("\n")
|
||||
|
||||
for (const [pkg, pkgbuild] of [["opencode-bin", binaryPkgbuild]]) {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
try {
|
||||
await $`rm -rf ./dist/aur-${pkg}`
|
||||
await $`git clone ssh://aur@aur.archlinux.org/${pkg}.git ./dist/aur-${pkg}`
|
||||
await $`cd ./dist/aur-${pkg} && git checkout master`
|
||||
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(pkgbuild)
|
||||
await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO`
|
||||
await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO`
|
||||
if ((await $`cd ./dist/aur-${pkg} && git diff --cached --quiet`.nothrow()).exitCode === 0) break
|
||||
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${Script.version}"`
|
||||
await $`cd ./dist/aur-${pkg} && git push`
|
||||
break
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Homebrew formula
|
||||
const homebrewFormula = [
|
||||
"# typed: false",
|
||||
"# frozen_string_literal: true",
|
||||
"",
|
||||
"# This file was generated by GoReleaser. DO NOT EDIT.",
|
||||
"class Opencode < Formula",
|
||||
` desc "The AI coding agent built for the terminal."`,
|
||||
` homepage "https://github.com/anomalyco/opencode"`,
|
||||
` version "${Script.version.split("-")[0]}"`,
|
||||
"",
|
||||
` depends_on "ripgrep"`,
|
||||
"",
|
||||
" on_macos do",
|
||||
" if Hardware::CPU.intel?",
|
||||
` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/opencode-darwin-x64.zip"`,
|
||||
` sha256 "${macX64Sha}"`,
|
||||
"",
|
||||
" def install",
|
||||
' bin.install "opencode"',
|
||||
" end",
|
||||
" end",
|
||||
" if Hardware::CPU.arm?",
|
||||
` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/opencode-darwin-arm64.zip"`,
|
||||
` sha256 "${macArm64Sha}"`,
|
||||
"",
|
||||
" def install",
|
||||
' bin.install "opencode"',
|
||||
" end",
|
||||
" end",
|
||||
" end",
|
||||
"",
|
||||
" on_linux do",
|
||||
" if Hardware::CPU.intel? and Hardware::CPU.is_64_bit?",
|
||||
` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/opencode-linux-x64.tar.gz"`,
|
||||
` sha256 "${x64Sha}"`,
|
||||
" def install",
|
||||
' bin.install "opencode"',
|
||||
" end",
|
||||
" end",
|
||||
" if Hardware::CPU.arm? and Hardware::CPU.is_64_bit?",
|
||||
` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/opencode-linux-arm64.tar.gz"`,
|
||||
` sha256 "${arm64Sha}"`,
|
||||
" def install",
|
||||
' bin.install "opencode"',
|
||||
" end",
|
||||
" end",
|
||||
" end",
|
||||
"end",
|
||||
"",
|
||||
"",
|
||||
].join("\n")
|
||||
|
||||
const token = process.env.GITHUB_TOKEN
|
||||
if (!token) {
|
||||
console.error("GITHUB_TOKEN is required to update homebrew tap")
|
||||
process.exit(1)
|
||||
}
|
||||
const tap = `https://x-access-token:${token}@github.com/anomalyco/homebrew-tap.git`
|
||||
await $`rm -rf ./dist/homebrew-tap`
|
||||
await $`git clone ${tap} ./dist/homebrew-tap`
|
||||
await Bun.file("./dist/homebrew-tap/opencode.rb").write(homebrewFormula)
|
||||
await $`cd ./dist/homebrew-tap && git add opencode.rb`
|
||||
if ((await $`cd ./dist/homebrew-tap && git diff --cached --quiet`.nothrow()).exitCode !== 0) {
|
||||
await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"`
|
||||
await $`cd ./dist/homebrew-tap && git push`
|
||||
}
|
||||
}
|
||||
106
packages/opencode/script/run-workspace-server
Executable file
106
packages/opencode/script/run-workspace-server
Executable file
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
// This script runs a separate OpenCode server to be used as a remote
|
||||
// workspace, simulating a remote environment but all local to make
|
||||
// debugger easier
|
||||
//
|
||||
// *Important*: make sure you add the debug workspace plugin first.
|
||||
// In `.opencode/opencode.jsonc` in the root of this project add:
|
||||
//
|
||||
// "plugin": ["../packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts"]
|
||||
//
|
||||
// Afterwards, run `./packages/opencode/script/run-workspace-server`
|
||||
|
||||
import { stat } from "node:fs/promises"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
const DEV_DATA_FILE = "/tmp/opencode-workspace-dev-data.json"
|
||||
const RESTART_POLL_INTERVAL = 250
|
||||
|
||||
async function readData() {
|
||||
return await Bun.file(DEV_DATA_FILE).json()
|
||||
}
|
||||
|
||||
async function readDataMtime() {
|
||||
return await stat(DEV_DATA_FILE)
|
||||
.then((info) => info.mtimeMs)
|
||||
.catch((error) => {
|
||||
if (typeof error === "object" && error && "code" in error && error.code === "ENOENT") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
async function readSnapshot() {
|
||||
while (true) {
|
||||
try {
|
||||
const before = await readDataMtime()
|
||||
if (before === undefined) {
|
||||
await sleep(RESTART_POLL_INTERVAL)
|
||||
continue
|
||||
}
|
||||
|
||||
const data = await readData()
|
||||
const after = await readDataMtime()
|
||||
|
||||
if (before === after) {
|
||||
return { data, mtime: after }
|
||||
}
|
||||
} catch (error) {
|
||||
if (typeof error === "object" && error && "code" in error && error.code === "ENOENT") {
|
||||
await sleep(RESTART_POLL_INTERVAL)
|
||||
continue
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startDevServer(data: any) {
|
||||
const env = Object.fromEntries(Object.entries(data.env ?? {}).filter(([, value]) => value !== undefined))
|
||||
|
||||
return Bun.spawn(["bun", "run", "dev", "serve", "--port", String(data.port), "--print-logs"], {
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
XDG_DATA_HOME: "/tmp/data",
|
||||
},
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForRestartSignal(mtime: number, signal: AbortSignal) {
|
||||
while (!signal.aborted) {
|
||||
await sleep(RESTART_POLL_INTERVAL)
|
||||
if (signal.aborted) return false
|
||||
if ((await readDataMtime()) !== mtime) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { data, mtime } = await readSnapshot()
|
||||
const proc = startDevServer(data)
|
||||
const restartAbort = new AbortController()
|
||||
|
||||
const result = await Promise.race([
|
||||
proc.exited.then((code) => ({ type: "exit" as const, code })),
|
||||
waitForRestartSignal(mtime, restartAbort.signal).then((restart) => ({ type: "restart" as const, restart })),
|
||||
])
|
||||
|
||||
restartAbort.abort()
|
||||
|
||||
if (result.type === "restart" && result.restart) {
|
||||
proc.kill()
|
||||
await proc.exited
|
||||
continue
|
||||
}
|
||||
|
||||
process.exit(result.code)
|
||||
}
|
||||
77
packages/opencode/script/schema.ts
Executable file
77
packages/opencode/script/schema.ts
Executable file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||
import { Schema } from "effect"
|
||||
|
||||
type JsonSchema = Record<string, unknown>
|
||||
const MODEL_REF = "https://models.dev/model-schema.json#/$defs/Model"
|
||||
|
||||
function generateEffect(schema: Schema.Top) {
|
||||
const document = Schema.toJsonSchemaDocument(schema)
|
||||
const normalized = normalize({
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
...document.schema,
|
||||
$defs: document.definitions,
|
||||
})
|
||||
if (!isRecord(normalized)) throw new Error("schema generator produced a non-object schema")
|
||||
const restored = restoreModelRefs(normalized)
|
||||
if (!isRecord(restored)) throw new Error("schema generator produced a non-object schema")
|
||||
restored.allowComments = true
|
||||
restored.allowTrailingCommas = true
|
||||
return restored
|
||||
}
|
||||
|
||||
function normalize(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(normalize)
|
||||
if (!isRecord(value)) return value
|
||||
|
||||
const schema = Object.fromEntries(Object.entries(value).map(([key, item]) => [key, normalize(item)]))
|
||||
|
||||
if (Array.isArray(schema.anyOf)) {
|
||||
const anyOf = schema.anyOf.filter((item) => !isRecord(item) || item.type !== "null")
|
||||
if (anyOf.length !== schema.anyOf.length) {
|
||||
const { anyOf: _, ...rest } = schema
|
||||
if (anyOf.length === 1 && isRecord(anyOf[0])) return normalize({ ...anyOf[0], ...rest })
|
||||
return { ...rest, anyOf }
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.allOf) && schema.allOf.length === 1 && isRecord(schema.allOf[0])) {
|
||||
const { allOf: _, ...rest } = schema
|
||||
return normalize({ ...schema.allOf[0], ...rest })
|
||||
}
|
||||
|
||||
if (schema.type === "integer" && schema.maximum === undefined) {
|
||||
return { ...schema, maximum: Number.MAX_SAFE_INTEGER }
|
||||
}
|
||||
|
||||
return schema
|
||||
}
|
||||
|
||||
function restoreModelRefs(value: unknown, key?: string): unknown {
|
||||
if (Array.isArray(value)) return value.map((item) => restoreModelRefs(item))
|
||||
if (!isRecord(value)) return value
|
||||
|
||||
const schema = Object.fromEntries(Object.entries(value).map(([name, item]) => [name, restoreModelRefs(item, name)]))
|
||||
if ((key === "model" || key === "small_model") && schema.type === "string") {
|
||||
return { ...schema, $ref: MODEL_REF }
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonSchema {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
const configFile = process.argv[2]
|
||||
const tuiFile = process.argv[3]
|
||||
|
||||
console.log(configFile)
|
||||
await Bun.write(configFile, JSON.stringify(generateEffect(ConfigV1.Info), null, 2))
|
||||
|
||||
if (tuiFile) {
|
||||
console.log(tuiFile)
|
||||
await Bun.write(tuiFile, JSON.stringify(generateEffect(TuiConfig.Info), null, 2))
|
||||
}
|
||||
6
packages/opencode/script/time.ts
Executable file
6
packages/opencode/script/time.ts
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import path from "path"
|
||||
const toDynamicallyImport = path.join(process.cwd(), process.argv[2])
|
||||
await import(toDynamicallyImport)
|
||||
console.log(performance.now())
|
||||
153
packages/opencode/script/trace-imports.ts
Executable file
153
packages/opencode/script/trace-imports.ts
Executable file
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env bun
|
||||
import * as path from "path"
|
||||
import * as ts from "typescript"
|
||||
|
||||
const BASE_DIR = "/home/thdxr/dev/projects/anomalyco/opencode/packages/opencode"
|
||||
|
||||
// Get entry file from command line arg or use default
|
||||
const ENTRY_FILE = process.argv[2] || "src/plugin/tui/runtime.ts"
|
||||
|
||||
const visited = new Set<string>()
|
||||
|
||||
function resolveImport(importPath: string, fromFile: string): string | null {
|
||||
if (importPath.startsWith("@/")) {
|
||||
return path.join(BASE_DIR, "src", importPath.slice(2))
|
||||
}
|
||||
|
||||
if (importPath.startsWith("./") || importPath.startsWith("../")) {
|
||||
const dir = path.dirname(fromFile)
|
||||
return path.resolve(dir, importPath)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function isInternalImport(importPath: string): boolean {
|
||||
return importPath.startsWith("@/") || importPath.startsWith("./") || importPath.startsWith("../")
|
||||
}
|
||||
|
||||
async function tryExtensions(filePath: string): Promise<string | null> {
|
||||
const extensions = [".ts", ".tsx", ".js", ".jsx"]
|
||||
|
||||
try {
|
||||
const file = Bun.file(filePath)
|
||||
const stat = await file.stat()
|
||||
|
||||
if (stat?.isDirectory()) {
|
||||
for (const ext of extensions) {
|
||||
const indexPath = path.join(filePath, "index" + ext)
|
||||
const indexFile = Bun.file(indexPath)
|
||||
if (await indexFile.exists()) return indexPath
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// It's a file
|
||||
return filePath
|
||||
} catch {
|
||||
// Path doesn't exist, try adding extensions
|
||||
for (const ext of extensions) {
|
||||
const withExt = filePath + ext
|
||||
const extFile = Bun.file(withExt)
|
||||
if (await extFile.exists()) return withExt
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function extractImports(sourceFile: ts.SourceFile): string[] {
|
||||
const imports: string[] = []
|
||||
|
||||
function visit(node: ts.Node) {
|
||||
// import x from "path" or import { x } from "path"
|
||||
if (ts.isImportDeclaration(node)) {
|
||||
// Skip type-only imports
|
||||
if (node.importClause?.isTypeOnly) return
|
||||
|
||||
const moduleSpec = node.moduleSpecifier
|
||||
if (ts.isStringLiteral(moduleSpec)) {
|
||||
imports.push(moduleSpec.text)
|
||||
}
|
||||
}
|
||||
|
||||
// export { x } from "path"
|
||||
if (ts.isExportDeclaration(node) && node.moduleSpecifier) {
|
||||
if (ts.isStringLiteral(node.moduleSpecifier)) {
|
||||
imports.push(node.moduleSpecifier.text)
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic import: import("path")
|
||||
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
||||
const arg = node.arguments[0]
|
||||
if (arg && ts.isStringLiteral(arg)) {
|
||||
imports.push(arg.text)
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
return imports
|
||||
}
|
||||
|
||||
async function traceFile(filePath: string, depth = 0): Promise<void> {
|
||||
const normalizedPath = path.relative(BASE_DIR, filePath)
|
||||
|
||||
if (visited.has(filePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Only trace TypeScript/JavaScript files
|
||||
if (!filePath.match(/\.(ts|tsx|js|jsx)$/)) {
|
||||
return
|
||||
}
|
||||
|
||||
visited.add(filePath)
|
||||
console.log("\t".repeat(depth) + normalizedPath)
|
||||
|
||||
let content: string
|
||||
try {
|
||||
content = await Bun.file(filePath).text()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true)
|
||||
|
||||
const imports = extractImports(sourceFile)
|
||||
const internalImports = imports.filter(isInternalImport)
|
||||
const externalImports = imports.filter((imp) => !isInternalImport(imp))
|
||||
|
||||
// Print external imports
|
||||
for (const imp of externalImports) {
|
||||
console.log("\t".repeat(depth + 1) + `[ext] ${imp}`)
|
||||
}
|
||||
|
||||
for (const imp of internalImports) {
|
||||
const resolved = resolveImport(imp, filePath)
|
||||
if (!resolved) continue
|
||||
|
||||
const actualPath = await tryExtensions(resolved)
|
||||
if (!actualPath) continue
|
||||
|
||||
await traceFile(actualPath, depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const entryPath = path.join(BASE_DIR, ENTRY_FILE)
|
||||
|
||||
// Check if file exists
|
||||
const file = Bun.file(entryPath)
|
||||
if (!(await file.exists())) {
|
||||
console.error(`File not found: ${ENTRY_FILE}`)
|
||||
console.error(`Resolved to: ${entryPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await traceFile(entryPath)
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
235
packages/opencode/specs/effect/error-boundaries-plan.md
Normal file
235
packages/opencode/specs/effect/error-boundaries-plan.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Error Boundaries Plan
|
||||
|
||||
Plan for removing `NamedError` as connective tissue while keeping public
|
||||
wire contracts stable.
|
||||
|
||||
## Desired Shape
|
||||
|
||||
```text
|
||||
Domain/service error
|
||||
Schema.TaggedErrorClass
|
||||
- catchable with catchTag / catchTags
|
||||
- appears in service method error type
|
||||
- no HTTP status
|
||||
- no toObject()
|
||||
|
||||
HTTP public error
|
||||
Schema.ErrorClass / TaggedErrorClass with httpApiStatus
|
||||
- endpoint-declared public contract
|
||||
- owns legacy { name, data } only when that is the SDK wire shape
|
||||
|
||||
CLI/user rendering
|
||||
FormatError and small format helpers
|
||||
- converts domain errors to text
|
||||
- preserves useful structured fields
|
||||
|
||||
Session/model-visible error
|
||||
first-class session/message error schema or helper
|
||||
- owns { name, data } event/message shape
|
||||
- not a service error class
|
||||
```
|
||||
|
||||
The important rule: a service error should not also be the HTTP body, CLI
|
||||
formatter, and session event body. Each seam adapts the error into the
|
||||
shape it owns.
|
||||
|
||||
## Concrete Example: Provider Model Not Found
|
||||
|
||||
Before:
|
||||
|
||||
```ts
|
||||
export const ModelNotFoundError = NamedError.create("ProviderModelNotFoundError", {
|
||||
providerID: ProviderID,
|
||||
modelID: ModelID,
|
||||
suggestions: Schema.optional(Schema.Array(Schema.String)),
|
||||
})
|
||||
```
|
||||
|
||||
Problems:
|
||||
|
||||
- Throwing it inside `Effect.fn` made it behave like a defect unless a
|
||||
compatibility bridge caught it.
|
||||
- HTTP middleware knew that this one domain error should be a `400`.
|
||||
- Callers read `.data.*`, which couples them to the legacy `{ name, data }`
|
||||
wire shape.
|
||||
|
||||
After:
|
||||
|
||||
```ts
|
||||
export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("ProviderModelNotFoundError", {
|
||||
providerID: ProviderID,
|
||||
modelID: ModelID,
|
||||
suggestions: Schema.optional(Schema.Array(Schema.String)),
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect<Model, ModelNotFoundError>
|
||||
}
|
||||
```
|
||||
|
||||
Boundary adapters:
|
||||
|
||||
```text
|
||||
CLI
|
||||
└─ FormatError sees _tag ProviderModelNotFoundError -> nice text
|
||||
|
||||
Session prompt
|
||||
└─ catch ModelNotFoundError -> publish Session.Event.Error as message/session wire shape
|
||||
|
||||
HTTP route
|
||||
└─ catch ModelNotFoundError -> declared BadRequest public API error when the endpoint needs it
|
||||
|
||||
HTTP middleware
|
||||
└─ no Provider.ModelNotFoundError knowledge
|
||||
```
|
||||
|
||||
## Refining Known Promise Failures
|
||||
|
||||
Use `EffectPromise.refineRejection(...)` when a Promise boundary can reject
|
||||
with many unknown values, but only one or two rejection classes are expected
|
||||
domain failures. Unknown rejections stay defects; the helper maps only known
|
||||
rejection shapes to typed errors.
|
||||
|
||||
```ts
|
||||
const language =
|
||||
yield *
|
||||
EffectPromise.refineRejection(
|
||||
async () => loadFromProvider(),
|
||||
(cause) => (cause instanceof NoSuchModelError ? new ModelNotFoundError({ providerID, modelID, cause }) : undefined),
|
||||
)
|
||||
```
|
||||
|
||||
Use this when the Promise can genuinely reject and most rejection values are
|
||||
still defects for the current module. Use `Effect.tryPromise({ try, catch })`
|
||||
when every rejection should become the same expected error type. Use
|
||||
`Effect.promise(...)` only when rejection means a defect and you do not need
|
||||
to refine known rejection classes.
|
||||
|
||||
## Helper Modules We Probably Want
|
||||
|
||||
Add helpers only when repeated call sites prove the seam is real.
|
||||
|
||||
### HTTP API Errors
|
||||
|
||||
Likely location: `src/server/routes/instance/httpapi/errors.ts`.
|
||||
|
||||
Purpose:
|
||||
|
||||
- construct public HTTP error bodies
|
||||
- preserve legacy `{ name, data }` where needed
|
||||
- attach `httpApiStatus`
|
||||
|
||||
Good helpers:
|
||||
|
||||
```ts
|
||||
notFound(message)
|
||||
badRequest(message)
|
||||
unknown()
|
||||
```
|
||||
|
||||
Avoid:
|
||||
|
||||
```ts
|
||||
mapAnyDomainError(error)
|
||||
```
|
||||
|
||||
That recreates the giant middleware mapper problem.
|
||||
|
||||
### Session / Message Error Wire Helpers
|
||||
|
||||
Likely location: near `src/session/message-error.ts` or a new narrow
|
||||
module such as `src/session/event-error.ts`.
|
||||
|
||||
Purpose:
|
||||
|
||||
- construct the `{ name, data }` shape used by `Session.Event.Error` and
|
||||
assistant message errors
|
||||
- replace `new NamedError.Unknown(...).toObject()` call sites
|
||||
- keep model-visible error bodies separate from service/domain errors
|
||||
|
||||
Good helpers:
|
||||
|
||||
```ts
|
||||
unknown(message)
|
||||
agentNotFound(agent, available)
|
||||
commandNotFound(command, available)
|
||||
modelNotFound(error: Provider.ModelNotFoundError)
|
||||
```
|
||||
|
||||
### CLI Formatters
|
||||
|
||||
Likely location: `src/cli/error.ts` until repetition demands domain-local
|
||||
format helpers.
|
||||
|
||||
Purpose:
|
||||
|
||||
- produce human-readable terminal messages from typed errors
|
||||
- support old `{ name, data }` shapes only while compatibility is needed
|
||||
|
||||
## Migration Queue
|
||||
|
||||
### Remove Domain Knowledge From HTTP Middleware
|
||||
|
||||
- [x] Storage not found no longer maps through defect fallback.
|
||||
- [x] Worktree expected errors moved to typed errors.
|
||||
- [x] Provider auth expected errors moved to typed errors.
|
||||
- [x] Provider model not found no longer needs an HTTP middleware status
|
||||
special case.
|
||||
- [ ] Convert `Session.BusyError` and map it at route boundaries.
|
||||
- [ ] Delete the broad `NamedError` middleware branch once no route relies
|
||||
on defect-wrapped legacy domain errors.
|
||||
- [ ] Keep one final unknown-defect fallback that logs `Cause.pretty(cause)`
|
||||
and returns a safe `500` body.
|
||||
|
||||
### Remaining `NamedError.create(...)` Service Errors
|
||||
|
||||
These should become `Schema.TaggedErrorClass` when touched:
|
||||
|
||||
- [ ] `src/provider/provider.ts` — `ProviderInitError`.
|
||||
- [ ] `src/storage/db.ts` — database `NotFoundError`.
|
||||
- [ ] `src/mcp/index.ts` — `MCPFailed`.
|
||||
- [ ] `src/skill/index.ts` — `SkillInvalidError`,
|
||||
`SkillNameMismatchError`.
|
||||
- [ ] `src/lsp/client.ts` — `LSPInitializeError`.
|
||||
- [ ] `src/ide/index.ts` — install errors.
|
||||
- [ ] `src/config/error.ts`, `src/config/config.ts`,
|
||||
`src/config/markdown.ts` — config errors. These already render well
|
||||
in the CLI, so migrate carefully and preserve diagnostics.
|
||||
|
||||
### Session / Message Wire Errors
|
||||
|
||||
These are not ordinary service errors. They mostly build `{ name, data }`
|
||||
objects for model-visible/session-visible output.
|
||||
|
||||
- [ ] Add a first-class session/message error wire helper.
|
||||
- [ ] Replace `new NamedError.Unknown(...).toObject()` in
|
||||
`src/session/prompt.ts`.
|
||||
- [ ] Replace `new NamedError.Unknown(...).toObject()` in config/skill/plugin
|
||||
session event publishing.
|
||||
- [ ] Move `src/session/message-error.ts` and `src/session/message-v2.ts`
|
||||
away from `NamedError.create(...)` once the wire helper exists.
|
||||
- [ ] Update retry/message tests to assert the wire schema/helper output,
|
||||
not `NamedError` instances.
|
||||
|
||||
### CLI Rendering
|
||||
|
||||
- [x] Tagged config errors render with useful diagnostics.
|
||||
- [x] Provider model not found renders from both old `{ name, data }` and
|
||||
new `_tag` shapes.
|
||||
- [ ] Add typed render cases as more `NamedError.create(...)` domains move
|
||||
to `Schema.TaggedErrorClass`.
|
||||
- [ ] Eventually remove old-shape compatibility branches when no callers can
|
||||
produce them.
|
||||
|
||||
## PR Checklist
|
||||
|
||||
For each migrated error:
|
||||
|
||||
- [ ] Domain error is `Schema.TaggedErrorClass`.
|
||||
- [ ] Service method exposes the typed error in its error channel.
|
||||
- [ ] No service error has `toObject()` just for compatibility.
|
||||
- [ ] CLI, HTTP, and session/message adapters each own their output shape.
|
||||
- [ ] HTTP middleware gets smaller or stays unchanged.
|
||||
- [ ] Focused tests cover the domain error and any public rendering/wire
|
||||
shape touched by the PR.
|
||||
207
packages/opencode/specs/effect/errors.md
Normal file
207
packages/opencode/specs/effect/errors.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# Typed Error Migration
|
||||
|
||||
This note expands the `ERR`, `RENDER`, and `HTTP` tracks from
|
||||
[`todo.md`](./todo.md). It is the current reference for expected failures,
|
||||
typed service errors, and HTTP error boundaries.
|
||||
|
||||
For the migration architecture and queue, see
|
||||
[`error-boundaries-plan.md`](./error-boundaries-plan.md).
|
||||
|
||||
## Goal
|
||||
|
||||
- Expected service failures live on the Effect error channel.
|
||||
- Service interfaces expose those failures in their return types.
|
||||
- Domain errors are authored with `Schema.TaggedErrorClass`.
|
||||
- `Effect.die(...)` is reserved for defects: bugs, impossible states,
|
||||
violated invariants, and final unknown-boundary fallbacks.
|
||||
- HTTP status codes and public wire bodies are handled at HTTP route
|
||||
boundaries, not inside service modules.
|
||||
- User-facing boundaries render useful structured error details instead of
|
||||
opaque `Error: SomeName` strings.
|
||||
|
||||
## Service Error Shape
|
||||
|
||||
```ts
|
||||
export class SessionBusyError extends Schema.TaggedErrorClass<SessionBusyError>()("SessionBusyError", {
|
||||
sessionID: SessionID,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export type Error = Storage.Error | SessionBusyError
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (id: SessionID) => Effect.Effect<Info, Error>
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Use `Schema.TaggedErrorClass` for expected domain failures.
|
||||
- Export a domain-level `Error` union from each service module.
|
||||
- Put expected errors in service method signatures.
|
||||
- Use `yield* new DomainError(...)` for direct early failures in
|
||||
`Effect.gen` / `Effect.fn`.
|
||||
- Use `Schema.Defect` for unknown cause fields when preserving the cause is
|
||||
useful for logs or callers.
|
||||
- Use `Effect.try(...)`, `Effect.tryPromise(...)`, `Effect.mapError`,
|
||||
`Effect.catchTag`, and `Effect.catchTags` to translate external
|
||||
failures into domain errors.
|
||||
- Do not use `throw`, `Effect.die(...)`, or `catchDefect` for expected
|
||||
user, IO, validation, missing-resource, auth, provider, worktree, or
|
||||
busy-state failures.
|
||||
|
||||
## HTTP Boundary Shape
|
||||
|
||||
Service modules stay transport-agnostic. They should not import HTTP
|
||||
status codes, `HttpApiError`, `HttpServerResponse`, or route-specific
|
||||
error schemas.
|
||||
|
||||
HTTP handlers translate service errors into public endpoint errors:
|
||||
|
||||
```ts
|
||||
const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) {
|
||||
return yield* session
|
||||
.get(ctx.params.sessionID)
|
||||
.pipe(Effect.catchTag("StorageNotFoundError", () => notFound("Session not found")))
|
||||
})
|
||||
```
|
||||
|
||||
Endpoint definitions declare which public errors can be emitted. Public
|
||||
HTTP error schemas carry their response status with `httpApiStatus` or the
|
||||
equivalent HttpApi schema annotation.
|
||||
|
||||
Effect's own HttpApi examples follow this pattern:
|
||||
|
||||
```ts
|
||||
export class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
|
||||
"Unauthorized",
|
||||
{ message: Schema.String },
|
||||
{ httpApiStatus: 401 },
|
||||
) {}
|
||||
|
||||
export class Authorization extends HttpApiMiddleware.Service<
|
||||
Authorization,
|
||||
{
|
||||
provides: CurrentUser
|
||||
}
|
||||
>()("app/Authorization", {
|
||||
security: { bearer: HttpApiSecurity.bearer },
|
||||
error: Unauthorized,
|
||||
}) {}
|
||||
```
|
||||
|
||||
Endpoint-level errors use the same idea:
|
||||
|
||||
```ts
|
||||
export class ConfigApiError extends Schema.ErrorClass<ConfigApiError>("ConfigApiError")(
|
||||
{
|
||||
name: Schema.Union(Schema.Literal("ConfigInvalidError"), Schema.Literal("ConfigJsonError")),
|
||||
data: Schema.Struct({ message: Schema.optional(Schema.String), path: Schema.String }),
|
||||
},
|
||||
{ httpApiStatus: 400 },
|
||||
) {}
|
||||
|
||||
HttpApiEndpoint.get("get", "/config", {
|
||||
success: Config.Info,
|
||||
error: ConfigApiError,
|
||||
})
|
||||
```
|
||||
|
||||
The service error and HTTP error may be the same class only when the wire
|
||||
shape is intentionally public. Use separate HTTP error schemas when the
|
||||
service error contains internals, low-level causes, retry hints, or data
|
||||
that should not be exposed to API clients.
|
||||
|
||||
Do not map every domain error into one universal HTTP error class. Prefer a
|
||||
small public error vocabulary by route group: shared shapes like
|
||||
`ApiNotFoundError`, route-specific shapes like `ConfigApiError`, and built-in
|
||||
empty `HttpApiError.*` only when an empty/no-content body is the intended SDK
|
||||
contract.
|
||||
|
||||
## Mapping Guidance
|
||||
|
||||
- Keep one-off translations inline in the handler.
|
||||
- Extract tiny shared helpers when the same translation repeats across a
|
||||
route group.
|
||||
- Do not create one giant `unknown -> status` mapper.
|
||||
- Do not grow generic HTTP middleware into a registry of domain errors.
|
||||
- Preserve existing public `{ name, data }` bodies until a deliberate
|
||||
breaking API change.
|
||||
- Use built-in `HttpApiError.*` only when its generated body and SDK
|
||||
surface are intentionally the public contract.
|
||||
- Prefer `Schema.ErrorClass` for public HTTP error bodies whose wire shape is
|
||||
not the same as the internal domain error shape.
|
||||
- Prefer `Schema.TaggedErrorClass` for service/domain errors and middleware
|
||||
errors that are naturally tagged by `_tag`.
|
||||
- If preserving a legacy `{ name, data }` body, model that shape explicitly in
|
||||
the public API error schema instead of relying on `NamedError.toObject()` in
|
||||
generic middleware.
|
||||
|
||||
## User-Facing Rendering
|
||||
|
||||
HTTP serialization and user rendering are separate boundaries. The server
|
||||
should send structured public errors; CLI and TUI code should format those
|
||||
structures through one shared formatter.
|
||||
|
||||
For SDK calls using `{ throwOnError: true }`, the generated client may wrap the
|
||||
decoded response body in an `Error`. The original body should remain available
|
||||
under `error.cause.body`; `FormatError` is the right place to unwrap and render
|
||||
that body. TUI aggregation helpers should call `FormatError` first, then fall
|
||||
back to generic `Error.message` / string rendering.
|
||||
|
||||
When several parallel startup requests fail from the same underlying issue,
|
||||
group identical rendered messages and list the affected request names once.
|
||||
For example:
|
||||
|
||||
```text
|
||||
Configuration is invalid at /path/to/opencode.json
|
||||
↳ Expected object, got "not-object" provider.bad.options
|
||||
Affected startup requests: config.providers, provider.list, app.agents, config.get
|
||||
```
|
||||
|
||||
## Middleware Guidance
|
||||
|
||||
HTTP middleware should be cross-cutting: auth, context, schema decode
|
||||
formatting, routing, and final unknown-defect fallback.
|
||||
|
||||
The current compatibility middleware still knows about some legacy domain
|
||||
errors. As route groups declare expected errors and handlers map them, that
|
||||
middleware should shrink. It should not gain new name checks.
|
||||
|
||||
Unknown `500` responses should log full details server-side with
|
||||
`Cause.pretty(cause)` and return a safe public body.
|
||||
|
||||
The config startup regression in #27056 is the failure mode this rule is meant
|
||||
to avoid: a user-authored invalid `opencode.json` crossed the HttpApi boundary
|
||||
as a defect, so middleware replaced a useful `ConfigInvalidError` with a safe
|
||||
generic `UnknownError`. The compatibility fix is to preserve config parse and
|
||||
validation errors as client-visible `400`s. The target architecture is better:
|
||||
config loading should fail on the typed error channel, config HTTP handlers
|
||||
should map those errors to declared `ConfigApiError` responses, and the generic
|
||||
middleware should never see them.
|
||||
|
||||
## Migration Order
|
||||
|
||||
Prefer small vertical slices:
|
||||
|
||||
1. Fix rendering at one user-visible boundary.
|
||||
2. Convert one service domain to `Schema.TaggedErrorClass` errors.
|
||||
3. Map those errors at the affected HTTP handlers.
|
||||
4. Remove the corresponding name-based middleware branch if possible.
|
||||
5. Add or update focused tests for both service error tags and HTTP wire
|
||||
bodies.
|
||||
|
||||
Good early domains are storage not-found, worktree errors, and provider
|
||||
auth validation errors because they currently drive HTTP behavior.
|
||||
|
||||
Config parse and validation errors are also a good early slice because they
|
||||
are startup-blocking and must be rendered clearly in both CLI and TUI flows.
|
||||
|
||||
## Checklist For A PR
|
||||
|
||||
- [ ] Expected failures are typed errors, not defects.
|
||||
- [ ] Service method signatures expose the expected error union.
|
||||
- [ ] HTTP handlers translate domain errors at the boundary.
|
||||
- [ ] Public HTTP error bodies preserve existing wire contracts.
|
||||
- [ ] Generic middleware gets smaller or stays unchanged.
|
||||
- [ ] Focused tests cover the service error and any public HTTP response.
|
||||
218
packages/opencode/specs/effect/facades.md
Normal file
218
packages/opencode/specs/effect/facades.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Facade removal checklist
|
||||
|
||||
Concrete inventory of the remaining `makeRuntime(...)`-backed facades in `packages/opencode`.
|
||||
|
||||
Current status on this branch:
|
||||
|
||||
- `src/` has 5 `makeRuntime(...)` call sites total.
|
||||
- 2 are intentionally excluded from this checklist: `src/bus/index.ts` and `src/effect/cross-spawn-spawner.ts`.
|
||||
- That leaves 2 live runtime-backed service facades still worth tracking here: `src/npm/index.ts` and `src/cli/cmd/tui/config/tui.ts`.
|
||||
|
||||
Recent progress:
|
||||
|
||||
- Wave 1 is merged: `Pty`, `Skill`, `Vcs`, `ToolRegistry`, `Auth`.
|
||||
- Wave 2 is merged: `Config`, `Provider`, `File`, `LSP`, `MCP`.
|
||||
|
||||
## Priority hotspots
|
||||
|
||||
- `src/cli/cmd/tui/config/tui.ts` still exports `makeRuntime(...)` plus async facade helpers for `get()` and `waitForDependencies()`.
|
||||
- `src/npm/index.ts` still exports `makeRuntime(...)` plus async facade helpers for `install()`, `add()`, `outdated()`, and `which()`.
|
||||
|
||||
## 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. `../core/src/filesystem.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 Effect.runPromise(FileSystem.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. Location-scoped services may need both the service layer and the right location fixture. `FileSystem` tests, for example, provide `Location.Service` plus `FileSystem.locationLayer`.
|
||||
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.
|
||||
|
||||
## Remaining work
|
||||
|
||||
Most of the original facade-removal backlog is already done. The practical remaining work is narrower now:
|
||||
|
||||
1. remove the `Npm` runtime-backed facade from `src/npm/index.ts`
|
||||
2. remove the `TuiConfig` runtime-backed facade from `src/cli/cmd/tui/config/tui.ts`
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `src/npm/index.ts` (`Npm`) - still exports runtime-backed async facade helpers on top of `Npm.Service`
|
||||
- [ ] `src/cli/cmd/tui/config/tui.ts` (`TuiConfig`) - still exports runtime-backed async facade helpers on top of `TuiConfig.Service`
|
||||
- [x] `src/session/session.ts` / `src/session/prompt.ts` / `src/session/revert.ts` / `src/session/summary.ts` - service-local facades removed
|
||||
- [x] `src/agent/agent.ts` (`Agent`) - service-local facades removed
|
||||
- [x] `src/permission/index.ts` (`Permission`) - service-local facades removed
|
||||
- [x] `src/worktree/index.ts` (`Worktree`) - service-local facades removed
|
||||
- [x] `src/plugin/index.ts` (`Plugin`) - service-local facades removed
|
||||
- [x] `src/snapshot/index.ts` (`Snapshot`) - service-local facades removed
|
||||
- [x] `../core/src/filesystem.ts` (`FileSystem`) - legacy opencode service removed
|
||||
- [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
|
||||
- [x] `src/auth/index.ts` (`Auth`) - facades removed and merged
|
||||
|
||||
## 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.
|
||||
247
packages/opencode/specs/effect/guide.md
Normal file
247
packages/opencode/specs/effect/guide.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# Effect Guide
|
||||
|
||||
How we write Effect code in `packages/opencode`. The companion roadmap is
|
||||
[`todo.md`](./todo.md).
|
||||
|
||||
This guide describes the preferred shape for new work and migrations. If a
|
||||
legacy file differs, migrate it only when it is already in scope.
|
||||
|
||||
## Service Shape
|
||||
|
||||
Use one module per service: flat top-level exports, traced Effect methods,
|
||||
explicit layers, and a self-reexport at the bottom.
|
||||
|
||||
```ts
|
||||
export interface Interface {
|
||||
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make<State>(Effect.fn("Foo.state")(() => Effect.succeed({})))
|
||||
|
||||
const get = Effect.fn("Foo.get")(function* (id: FooID) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* loadFoo(s, id)
|
||||
})
|
||||
|
||||
return Service.of({ get })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FooDep.defaultLayer))
|
||||
|
||||
export * as Foo from "./foo"
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Do not use `export namespace Foo { ... }`.
|
||||
- Use `Effect.fn("Foo.method")` for public service methods.
|
||||
- Use `Effect.fnUntraced` for small internal helpers that do not need a
|
||||
span.
|
||||
- Keep helpers as non-exported top-level declarations in the same file.
|
||||
- Self-reexport with `export * as Foo from "."` for `index.ts`, otherwise
|
||||
`export * as Foo from "./foo"`.
|
||||
- In `src/config`, keep the existing top-of-file self-export pattern.
|
||||
|
||||
## Runtime Boundaries
|
||||
|
||||
Most code should run through [`AppRuntime`](../../src/effect/app-runtime.ts).
|
||||
It hosts `AppLayer`, shares the global `memoMap`, and restores the current
|
||||
instance/workspace refs when crossing from non-Effect code.
|
||||
|
||||
Use `AppRuntime.runPromise(effect)` at app boundaries such as CLI commands,
|
||||
HTTP handlers, or plain async adapters.
|
||||
|
||||
`makeRuntime(...)` still exists for a few intentional service-local
|
||||
boundaries and migration leftovers. Do not add a new service-local runtime
|
||||
unless the service truly cannot live in `AppLayer`.
|
||||
|
||||
## Runtime Flags
|
||||
|
||||
Read opencode runtime flags through
|
||||
[`RuntimeFlags.Service`](../../src/effect/runtime-flags.ts), not through
|
||||
mutable `Flag` or late `process.env` reads.
|
||||
|
||||
Tests should vary behavior with explicit layer variants:
|
||||
|
||||
```ts
|
||||
const it = testEffect(MyService.defaultLayer.pipe(Layer.provide(RuntimeFlags.layer({ experimentalReferences: true }))))
|
||||
```
|
||||
|
||||
Do not mutate `process.env` or `Flag` after services/layers are built.
|
||||
|
||||
## Per-Instance State
|
||||
|
||||
Use [`InstanceState`](../../src/effect/instance-state.ts) when two open
|
||||
directories should not share one copy of a service's state. It is backed by
|
||||
a `ScopedCache`, keyed by directory, and disposed automatically when an
|
||||
instance is unloaded.
|
||||
|
||||
Put subscriptions, finalizers, and scoped background work inside the
|
||||
`InstanceState.make(...)` initializer:
|
||||
|
||||
```ts
|
||||
const cache =
|
||||
yield *
|
||||
InstanceState.make<State>(
|
||||
Effect.fn("Foo.state")(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
yield* bus.subscribeAll().pipe(
|
||||
Stream.runForEach((event) => handleEvent(event)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
yield* Effect.acquireRelease(openResource, closeResource)
|
||||
|
||||
return yield* loadInitialState()
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Do not add separate `started` flags on top of `InstanceState`. Let
|
||||
`ScopedCache` handle run-once and deduplication.
|
||||
|
||||
To make `init()` non-blocking, fork at the caller/bootstrap boundary. Do
|
||||
not fork inside `InstanceState.make(...)` just to return early with
|
||||
partially initialized state.
|
||||
|
||||
## Errors
|
||||
|
||||
Expected domain failures belong on the Effect error channel. Defects are
|
||||
for bugs, impossible states, and final unknown-boundary fallbacks.
|
||||
|
||||
```ts
|
||||
export class SessionBusyError extends Schema.TaggedErrorClass<SessionBusyError>()("SessionBusyError", {
|
||||
sessionID: SessionID,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export type Error = Storage.Error | SessionBusyError
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (id: SessionID) => Effect.Effect<Info, Error>
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Use `Schema.TaggedErrorClass` for new expected domain errors.
|
||||
- Export a domain-level `Error` union from service modules.
|
||||
- In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` for
|
||||
direct expected failures.
|
||||
- Use `Schema.Defect` for unknown cause fields.
|
||||
- Use `Effect.try(...)`, `Effect.tryPromise(...)`, `Effect.mapError`,
|
||||
`Effect.catchTag`, and `Effect.catchTags` to translate external
|
||||
failures into domain errors.
|
||||
- Do not use `Effect.die(...)` for user, IO, validation, missing-resource,
|
||||
auth, provider, or busy-state failures.
|
||||
|
||||
## HTTP Error Boundaries
|
||||
|
||||
Service modules stay HTTP-agnostic. They should not import HTTP status
|
||||
codes, `HttpApiError`, `HttpServerResponse`, or route-specific error
|
||||
schemas.
|
||||
|
||||
HTTP handlers translate service errors into endpoint-declared public error
|
||||
schemas. Keep mappings inline when they are one-off; extract tiny shared
|
||||
helpers only when the same translation repeats.
|
||||
|
||||
Do not turn generic middleware into a registry of domain errors. Middleware
|
||||
should handle cross-cutting concerns and the final unknown-defect fallback.
|
||||
|
||||
Preserve legacy public wire shapes, such as `{ name, data }`, until a
|
||||
deliberate breaking API change.
|
||||
|
||||
## Schemas
|
||||
|
||||
Use Effect Schema as the source of truth.
|
||||
|
||||
- Use `Schema.Class` for exported data objects with a clear identity.
|
||||
- Use `Schema.Struct` for local shapes and simple nested objects.
|
||||
- Use `Schema.brand` for single-value IDs.
|
||||
- Reuse named refinements instead of re-spelling constraints.
|
||||
- Prefer narrow boundary helpers over generic Schema-to-Zod bridges.
|
||||
|
||||
Intentional boundaries:
|
||||
|
||||
- Public plugin tools still expose Zod through `tool.schema = z`.
|
||||
- Tool parameter JSON Schema is generated through tool-specific helpers.
|
||||
- Public config and TUI schemas are generated through the schema script.
|
||||
|
||||
## Preferred Services
|
||||
|
||||
In effectified code, yield existing services instead of dropping to ad hoc
|
||||
platform APIs.
|
||||
|
||||
- Use `FSUtil.Service` instead of raw `fs/promises` for app file IO.
|
||||
- Use `AppProcess.Service` instead of direct `ChildProcessSpawner.spawn` or
|
||||
legacy process helpers.
|
||||
- Use `HttpClient.HttpClient` instead of raw `fetch` inside Effect code.
|
||||
- Use `Path.Path`, `Config`, `Clock`, and `DateTime` when already inside
|
||||
Effect.
|
||||
- Use `Effect.callback` for callback-based APIs.
|
||||
- Use `Effect.void` instead of `Effect.succeed(undefined)`.
|
||||
- Use `Effect.cached` when concurrent callers should share one in-flight
|
||||
computation.
|
||||
|
||||
For background loops, use `Effect.repeat` or `Effect.schedule` with
|
||||
`Effect.forkScoped` in the owning layer/state scope.
|
||||
|
||||
## Promise And ALS Bridges
|
||||
|
||||
[`EffectBridge`](../../src/effect/bridge.ts) is the sanctioned helper for
|
||||
Promise/callback interop that needs to preserve instance/workspace context.
|
||||
It preserves explicit `InstanceRef` / `WorkspaceRef` context for effects run
|
||||
through the bridge. Plain JS callbacks that need instance data should receive
|
||||
that data explicitly.
|
||||
|
||||
## Testing
|
||||
|
||||
Detailed test migration rules live in
|
||||
[`test/EFFECT_TEST_MIGRATION.md`](../../test/EFFECT_TEST_MIGRATION.md).
|
||||
|
||||
Core pattern:
|
||||
|
||||
```ts
|
||||
const it = testEffect(Layer.mergeAll(MyService.defaultLayer))
|
||||
|
||||
describe("my service", () => {
|
||||
it.instance("does the thing", () =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* MyService.Service
|
||||
expect(yield* svc.run()).toEqual("ok")
|
||||
}),
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Use `it.effect(...)` for TestClock/TestConsole tests.
|
||||
- Use `it.live(...)` for real timers, filesystem mtimes, child processes,
|
||||
git, locks, or other live integration behavior.
|
||||
- Use `it.instance(...)` for service tests that need a scoped instance.
|
||||
- Prefer Effect-aware fixtures from `test/fixture/fixture.ts`.
|
||||
- Avoid sleeps; wait for real events or deterministic state transitions.
|
||||
- Avoid mutable `process.env`, `Flag`, or module-global changes after
|
||||
layers are built.
|
||||
- Use `Layer.mock` for partial service stubs.
|
||||
- Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` test
|
||||
wrappers.
|
||||
|
||||
## Verification
|
||||
|
||||
From `packages/opencode`:
|
||||
|
||||
```bash
|
||||
bun run typecheck
|
||||
bun run test -- path/to/test.ts
|
||||
```
|
||||
|
||||
Do not run tests from the repo root; the repo has a guard for that.
|
||||
13
packages/opencode/specs/effect/instance-context.md
Normal file
13
packages/opencode/specs/effect/instance-context.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Instance Context
|
||||
|
||||
Instance selection is now Effect-provided context.
|
||||
|
||||
Use these APIs:
|
||||
|
||||
- `InstanceRef` for the current project context.
|
||||
- `WorkspaceRef` for the current workspace id.
|
||||
- `InstanceState.context` / `InstanceState.directory` inside Effect services that require an instance.
|
||||
- `InstanceStore` at entry boundaries that need to load, reload, or dispose project contexts.
|
||||
- `EffectBridge` for native, plugin, or plain JavaScript callback boundaries that need to re-enter Effect with captured refs.
|
||||
|
||||
Do not add new ambient instance globals. Promise and callback boundaries should either stay in Effect, use `EffectBridge`, or pass the required context explicitly.
|
||||
30
packages/opencode/specs/effect/loose-ends.md
Normal file
30
packages/opencode/specs/effect/loose-ends.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Effect loose ends
|
||||
|
||||
Small follow-ups that do not fit neatly into the main facade, route, tool, or schema migration checklists.
|
||||
|
||||
## Config / TUI
|
||||
|
||||
- [ ] `cli/cmd/tui/config/tui.ts` - finish the internal Effect migration.
|
||||
Keep the current precedence and migration semantics intact while converting the remaining internal async helpers (`loadState`, `mergeFile`, `loadFile`, `load`) to `Effect.gen(...)` / `Effect.fn(...)`.
|
||||
- [ ] `cli/cmd/tui/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`.
|
||||
- [x] `env/index.ts` - already uses `InstanceState.make(...)`.
|
||||
|
||||
## 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 `FSUtil.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.
|
||||
- [ ] `cli/cmd/tui/config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists.
|
||||
- [ ] `cli/cmd/tui/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.
|
||||
|
||||
## 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.
|
||||
62
packages/opencode/specs/effect/migration.md
Normal file
62
packages/opencode/specs/effect/migration.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Effect Migration Patterns
|
||||
|
||||
This is the compact reference for moving code toward the current Effect
|
||||
shape. The high-level roadmap is [`todo.md`](./todo.md); examples and
|
||||
rules are in [`guide.md`](./guide.md).
|
||||
|
||||
## Default Shape
|
||||
|
||||
- Service methods return `Effect`.
|
||||
- Service methods are named with `Effect.fn("Domain.method")`.
|
||||
- Expected failures are typed errors on the error channel.
|
||||
- Dependencies are yielded once at layer construction and closed over by
|
||||
methods.
|
||||
- `defaultLayer` wires production dependencies; tests can use open layers
|
||||
when replacing dependencies.
|
||||
|
||||
## Instance State
|
||||
|
||||
Use `InstanceState` for per-directory state, subscriptions, scoped
|
||||
background work, and per-instance cleanup.
|
||||
|
||||
Do not add ad hoc `started` flags on top of `InstanceState`; the scoped
|
||||
cache handles run-once and concurrent deduplication.
|
||||
|
||||
## Runtime Boundaries
|
||||
|
||||
Prefer `AppRuntime` for crossing from non-Effect code into the shared app
|
||||
layer.
|
||||
|
||||
`makeRuntime(...)` exists for intentional service-local boundaries and
|
||||
legacy facades. Do not add new service-local runtimes unless the service is
|
||||
genuinely outside `AppLayer`.
|
||||
|
||||
## Platform Edges
|
||||
|
||||
- Use `FSUtil.Service` instead of raw filesystem APIs in
|
||||
effectified services.
|
||||
- Use `AppProcess.Service` instead of raw process wrappers.
|
||||
- Use `HttpClient.HttpClient` instead of raw `fetch` in Effect code.
|
||||
- Use `Effect.cached` for shared in-flight work.
|
||||
- Use `Effect.callback` for callback APIs.
|
||||
|
||||
## Tests During Migration
|
||||
|
||||
When migrating code, migrate touched tests toward
|
||||
[`test/EFFECT_TEST_MIGRATION.md`](../../test/EFFECT_TEST_MIGRATION.md):
|
||||
|
||||
- `testEffect(...)`
|
||||
- `it.effect`, `it.live`, or `it.instance`
|
||||
- explicit layers for behavior changes
|
||||
- deterministic waits instead of sleeps
|
||||
- no mutable env/global flags after layers are built
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
- [ ] The code has a single Effect body instead of Promise wrappers around
|
||||
service calls.
|
||||
- [ ] Expected failures are typed errors, not thrown exceptions or defects.
|
||||
- [ ] Layer requirements are explicit.
|
||||
- [ ] Tests use Effect-aware fixtures and focused layers.
|
||||
- [ ] Public behavior and wire shapes are preserved unless intentionally
|
||||
changed.
|
||||
61
packages/opencode/specs/effect/routes.md
Normal file
61
packages/opencode/specs/effect/routes.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# HTTP Route Patterns
|
||||
|
||||
Current guidance for `packages/opencode/src/server/routes/instance/httpapi`.
|
||||
|
||||
## Handler Shape
|
||||
|
||||
Use `HttpApiBuilder.group(...)` for normal JSON and streaming HTTP API
|
||||
endpoints. Yield stable services once while building the handler layer,
|
||||
then close over those services in endpoint implementations.
|
||||
|
||||
```ts
|
||||
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
|
||||
return handlers.handle("list", () => session.list())
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Use raw `HttpRouter` only for routes that do not fit the request/response
|
||||
HttpApi model, such as WebSocket upgrades or catch-all fallback routes.
|
||||
|
||||
Do not rebuild stable layers inside request handlers. Provide stable
|
||||
services at the route/layer boundary and use request-level provisioning
|
||||
only for request-derived context.
|
||||
|
||||
## Error Boundaries
|
||||
|
||||
Expected service errors should be mapped at the handler boundary to
|
||||
endpoint-declared public HTTP errors. Keep one-off mappings inline. Extract
|
||||
small helpers when the same mapping repeats.
|
||||
|
||||
Generic middleware should not become a domain-error mapper. It should
|
||||
handle cross-cutting concerns and final unknown-defect fallback.
|
||||
|
||||
Public JSON errors should be explicit schema contracts declared on each
|
||||
endpoint or group. Built-in `HttpApiError.*` is fine only when its generated
|
||||
body is intentionally the public wire shape.
|
||||
|
||||
Preserve existing `{ name, data }` error bodies until a deliberate breaking
|
||||
API change.
|
||||
|
||||
## OpenAPI Compatibility
|
||||
|
||||
`public.ts` still owns SDK/OpenAPI compatibility transforms. Shrink those
|
||||
transforms by tightening source schemas one workaround at a time.
|
||||
|
||||
When an OpenAPI-visible source schema changes:
|
||||
|
||||
- verify the generated SDK diff is intentional
|
||||
- preserve legacy compatibility unless the PR explicitly changes it
|
||||
- prefer source-schema fixes over new post-processing rules
|
||||
|
||||
## Checklist For Route PRs
|
||||
|
||||
- [ ] Stable services are yielded at handler-layer construction.
|
||||
- [ ] Expected domain errors are translated at the route boundary.
|
||||
- [ ] Endpoint/group error schemas describe the public body and status.
|
||||
- [ ] Middleware does not gain new domain-specific name checks.
|
||||
- [ ] Raw routes are used only when HttpApi is the wrong abstraction.
|
||||
88
packages/opencode/specs/effect/schema.md
Normal file
88
packages/opencode/specs/effect/schema.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Schema Migration
|
||||
|
||||
Use Effect Schema as the source of truth for domain models, DTOs, IDs,
|
||||
inputs, outputs, and typed errors.
|
||||
|
||||
This is guidance, not an inventory. Do not use this file to track which
|
||||
schema modules are complete; verify current state with `git grep` before
|
||||
starting a migration.
|
||||
|
||||
## Preferred Shapes
|
||||
|
||||
Use `Schema.Class` for exported data objects with a clear domain identity:
|
||||
|
||||
```ts
|
||||
export class Info extends Schema.Class<Info>("Foo.Info")({
|
||||
id: FooID,
|
||||
name: Schema.String,
|
||||
enabled: Schema.Boolean,
|
||||
}) {}
|
||||
```
|
||||
|
||||
Use `Schema.Struct` for local shapes and simple nested objects:
|
||||
|
||||
```ts
|
||||
const Payload = Schema.Struct({
|
||||
id: FooID,
|
||||
value: Schema.String,
|
||||
})
|
||||
```
|
||||
|
||||
Use `Schema.TaggedErrorClass` for expected domain errors:
|
||||
|
||||
```ts
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("FooNotFoundError", {
|
||||
id: FooID,
|
||||
}) {}
|
||||
```
|
||||
|
||||
Use branded schema-backed IDs for single-value domain identifiers.
|
||||
|
||||
## Boundary Rule
|
||||
|
||||
Effect Schema should own the type. Boundaries should consume Effect Schema
|
||||
directly or use narrow boundary-specific helpers. Avoid reintroducing a
|
||||
generic Effect Schema -> Zod bridge.
|
||||
|
||||
Current intentional boundaries:
|
||||
|
||||
- Public plugin tools still expose Zod through `tool.schema = z`.
|
||||
- Tool parameters use tool-specific JSON Schema helpers.
|
||||
- Public config and TUI schema generation goes through the schema script.
|
||||
- AI SDK object generation uses Standard Schema / JSON Schema helpers.
|
||||
|
||||
When Zod must stay temporarily, leave a short note explaining the boundary
|
||||
or compatibility reason.
|
||||
|
||||
## Refinements
|
||||
|
||||
Reuse named refinements instead of re-spelling constraints:
|
||||
|
||||
```ts
|
||||
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
|
||||
const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))
|
||||
```
|
||||
|
||||
Prefer domain-named leaf schemas when the name improves callers or error
|
||||
messages. Avoid adding brands purely for novelty.
|
||||
|
||||
## Migration Order
|
||||
|
||||
For a domain that still has mixed schemas:
|
||||
|
||||
1. Shared leaf models and branded IDs.
|
||||
2. Exported `Info`, `Input`, `Output`, and event payload types.
|
||||
3. Expected domain errors.
|
||||
4. Service-local internal models.
|
||||
5. HTTP/tool/AI boundary validators.
|
||||
|
||||
Keep public wire shapes stable unless the PR is explicitly a breaking API
|
||||
change.
|
||||
|
||||
## Checklist For A PR
|
||||
|
||||
- [ ] There is one schema source of truth for each migrated type.
|
||||
- [ ] Remaining Zod is an intentional boundary choice.
|
||||
- [ ] Public JSON/OpenAPI output is unchanged or intentionally updated.
|
||||
- [ ] Derived helpers are narrow and boundary-specific.
|
||||
- [ ] Tests assert behavior, not duplicated schema implementation details.
|
||||
58
packages/opencode/specs/effect/server-package.md
Normal file
58
packages/opencode/specs/effect/server-package.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Server Package Extraction
|
||||
|
||||
Practical reference for a future `packages/server` split after the opencode
|
||||
server moved to the Effect HttpApi backend.
|
||||
|
||||
## Current State
|
||||
|
||||
- The server still lives in `packages/opencode`.
|
||||
- The runtime and app layer are centralized in `src/effect/app-runtime.ts` and
|
||||
`src/effect/run-service.ts`.
|
||||
- The route tree lives under `src/server/routes/instance/httpapi` and is hosted
|
||||
from `src/server/server.ts`.
|
||||
- OpenAPI generation is based on the HttpApi contract plus compatibility
|
||||
translation in `src/server/routes/instance/httpapi/public.ts`.
|
||||
- There is no standalone `packages/server` workspace yet.
|
||||
|
||||
## Future State
|
||||
|
||||
Target package layout:
|
||||
|
||||
- `packages/core` - shared domain services and schemas
|
||||
- `packages/server` - HTTP contracts, handlers, OpenAPI generation, and an
|
||||
embeddable server API
|
||||
- `packages/cli` - TUI and CLI entrypoints
|
||||
- `packages/sdk` - generated from the server OpenAPI spec
|
||||
- `packages/plugin` - plugin authoring surface
|
||||
|
||||
## Extraction Rule
|
||||
|
||||
Do not create a package cycle.
|
||||
|
||||
Until enough shared service code lives outside `packages/opencode`, a future
|
||||
`packages/server` should either:
|
||||
|
||||
- own pure HttpApi contracts only, or
|
||||
- accept host-provided services/layers/callbacks from `packages/opencode`
|
||||
|
||||
It should not import `packages/opencode` services while `packages/opencode`
|
||||
imports it to host routes.
|
||||
|
||||
## Suggested PR Sequence
|
||||
|
||||
1. Keep shrinking OpenAPI compatibility shims in `httpapi/public.ts`.
|
||||
2. Move stable domain schemas into shared packages only when they no longer
|
||||
depend on opencode-local runtime modules.
|
||||
3. Extract pure HttpApi contract modules into `packages/server` once the contract
|
||||
can compile without importing `packages/opencode` implementation details.
|
||||
4. Extract handler factories after their service dependencies can be supplied by
|
||||
a host layer instead of imported directly.
|
||||
5. Move server hosting last, after package ownership is clear.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not revive the old dual-backend migration shape.
|
||||
- Do not split server hosting before service dependencies have a clean package
|
||||
boundary.
|
||||
- Do not switch SDK generation to a new package until generated output is known
|
||||
to remain compatible.
|
||||
241
packages/opencode/specs/effect/todo.md
Normal file
241
packages/opencode/specs/effect/todo.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# Effect TODO
|
||||
|
||||
Short roadmap for Effect cleanup in `packages/opencode`.
|
||||
|
||||
Current patterns and examples live in [`guide.md`](./guide.md). Error
|
||||
boundary migration details live in
|
||||
[`error-boundaries-plan.md`](./error-boundaries-plan.md). Test migration rules live in
|
||||
[`test/EFFECT_TEST_MIGRATION.md`](../../test/EFFECT_TEST_MIGRATION.md).
|
||||
Older deep-dive notes in this directory may still be useful, but treat
|
||||
this roadmap and the guide as the current entry points.
|
||||
|
||||
This is a planning map, not a verified inventory. Before starting a task,
|
||||
re-run a targeted `git grep` from current `dev` and update this file if
|
||||
the inventory changed.
|
||||
|
||||
## Priorities
|
||||
|
||||
```text
|
||||
P0 ERR + RENDER + HTTP
|
||||
Make expected failures typed, render them well, and stop relying on
|
||||
generic HTTP error guesswork.
|
||||
|
||||
P1 TEST
|
||||
Convert touched tests to the ideal Effect test patterns from the guide.
|
||||
|
||||
P2 RF
|
||||
Move mutable runtime flags into typed runtime/config services.
|
||||
|
||||
P3 GLOBAL
|
||||
Make global paths explicit and remove import-time side effects.
|
||||
|
||||
P4 INST + BRIDGE
|
||||
Remove ambient Instance coupling while keeping Promise/callback interop.
|
||||
|
||||
P5 PROC + FS
|
||||
Replace raw process/filesystem edges with typed Effect services.
|
||||
|
||||
P6 OA
|
||||
Shrink OpenAPI compatibility shims as source schemas improve.
|
||||
```
|
||||
|
||||
## Work Paths
|
||||
|
||||
- `ERR` Typed errors — replace legacy `NamedError.create(...)` and
|
||||
`Effect.die(...)` for expected service failures with
|
||||
`Schema.TaggedErrorClass` errors on the Effect error channel.
|
||||
Shrinks: [`NamedError`](../../../core/src/util/error.ts) usage.
|
||||
- `RENDER` User-visible error rendering — preserve structured typed-error
|
||||
details at CLI, HTTP, and tool boundaries.
|
||||
Shrinks: opaque `Error: Name` rendering.
|
||||
- `HTTP` HTTP route cleanup — make route errors explicit instead of
|
||||
relying on generic middleware to guess status/body from error names.
|
||||
Shrinks: [`middleware/error.ts`](../../src/server/routes/instance/httpapi/middleware/error.ts)
|
||||
and route-level compatibility shims.
|
||||
- `TEST` Effect test migration — use `testEffect`, `it.live`, and
|
||||
`it.instance` with explicit layers.
|
||||
Shrinks: Promise-style tests, sleeps, mutable global test flags.
|
||||
- `RF` RuntimeFlags / Flag deletion — move mutable
|
||||
[`Flag`](../../../core/src/flag/flag.ts) reads into typed runtime/config
|
||||
services.
|
||||
Shrinks: [`flag.ts`](../../../core/src/flag/flag.ts),
|
||||
[`test/fixture/flag.ts`](../../test/fixture/flag.ts).
|
||||
- `GLOBAL` Global paths / import side effects — make global path state
|
||||
explicit and testable instead of mutable module state.
|
||||
Shrinks: [`global.ts`](../../../core/src/global.ts) import-time side
|
||||
effects, mutable `Global.Path` overrides, and its `Flag` dependency.
|
||||
- `INST` Instance context — keep project context explicit through Effect refs
|
||||
and bridge boundaries.
|
||||
- `BRIDGE` Promise/callback interop — keep bridge helpers, but reduce
|
||||
legacy ALS coupling.
|
||||
Shrinks: ad hoc Promise/callback re-entry code.
|
||||
- `PROC` AppProcess migration — prefer `AppProcess.Service` over raw
|
||||
process wrappers.
|
||||
Shrinks: direct spawn callsites and legacy process helpers.
|
||||
- `FS` FSUtil migration — prefer `FSUtil.Service` over raw
|
||||
filesystem APIs.
|
||||
Shrinks: direct `fs` / `Bun.file` service callsites where inappropriate.
|
||||
- `RT` Runtime/facade cleanup — remove service-local `makeRuntime`
|
||||
facades when not intentional.
|
||||
Shrinks: async facade exports around services and
|
||||
[`run-service.ts`](../../src/effect/run-service.ts) usage.
|
||||
- `OA` OpenAPI compatibility — tighten source schemas instead of
|
||||
post-processing generated OpenAPI.
|
||||
Shrinks: schema workaround blocks in
|
||||
[`public.ts`](../../src/server/routes/instance/httpapi/public.ts).
|
||||
|
||||
## P0: Errors, Rendering, And HTTP
|
||||
|
||||
This should be the next big cleanup theme. The codebase is moving toward
|
||||
typed Effect failures, but the user-facing boundaries still leak old
|
||||
shapes and sometimes collapse rich errors into opaque strings.
|
||||
|
||||
### Problems
|
||||
|
||||
- Some expected service failures still use `NamedError.create(...)` or
|
||||
collapse to `Effect.die(...)`. The storage/worktree/provider-auth
|
||||
conversions are done; an inventory sweep is needed for the rest.
|
||||
- HTTP error middleware still guesses status codes from error names —
|
||||
some entries (e.g. storage `NotFound`, provider auth) can now be
|
||||
removed, but the middleware overall has not shrunk.
|
||||
- Route handlers and route groups do not consistently declare the public
|
||||
error body they intend to expose.
|
||||
- Repeated route error translations do not yet have a clear home: some
|
||||
should stay inline, some deserve tiny shared mapper helpers.
|
||||
|
||||
### Target Shape
|
||||
|
||||
- Services define expected failures with `Schema.TaggedErrorClass`.
|
||||
- Services export an `Error` union and include it in method return types.
|
||||
- Expected failures stay on the Effect error channel.
|
||||
- `Effect.die(...)` is reserved for defects: bugs, impossible states,
|
||||
violated invariants, or final unknown-boundary fallbacks.
|
||||
- Inside `Effect.gen` / `Effect.fn`, use `yield* new MyError(...)` for
|
||||
direct expected failures.
|
||||
- Domain services do not import HTTP status codes, `HttpApiError`, or
|
||||
route-specific error schemas.
|
||||
- HTTP route groups make their public error contracts obvious.
|
||||
- Handlers map service errors to declared HTTP errors at the boundary.
|
||||
- Shared mapper helpers are only for repeated translations, not a giant
|
||||
central registry of every domain error.
|
||||
- Generic HTTP middleware should shrink; it should not accumulate more
|
||||
name-based domain knowledge.
|
||||
|
||||
### Recently completed
|
||||
|
||||
- [x] `RENDER-1` CLI tagged config error rendering (#27256, tests #27257).
|
||||
- [x] `ERR-1` [`storage/storage.ts`](../../src/storage/storage.ts) typed
|
||||
`NotFoundError` (#27265) and removal of the server defect fallback
|
||||
(#27287).
|
||||
- [x] `ERR-2` [`worktree/index.ts`](../../src/worktree/index.ts) typed
|
||||
errors (#27296).
|
||||
- [x] `ERR-3` [`provider/auth.ts`](../../src/provider/auth.ts) typed
|
||||
validation/oauth errors (#27301).
|
||||
- [x] `HTTP-1` Unknown-500 details no longer leaked (#27251); follow-up
|
||||
to stop exposing named defects (#27471).
|
||||
- [x] Session message reads typed and made effectful (#27269, #27275,
|
||||
#27280, #27291).
|
||||
- [x] Session HTTP error contracts tightened (#27308); busy-session
|
||||
mapping centralized (#27375, #27473).
|
||||
- [x] Provider init (#27484) and LSP init (#27494) errors typed.
|
||||
|
||||
### First PR Candidates
|
||||
|
||||
- [ ] `HTTP-2` Audit one route group for explicit error contracts and
|
||||
decide which mappings stay inline vs. shared helper.
|
||||
- [ ] `ERR-4` Sweep remaining `NamedError.create(...)` and
|
||||
`Effect.die(...)` callsites for expected failures — re-run `git
|
||||
grep` to build a current inventory.
|
||||
- [ ] `RENDER-2` Audit CLI and TUI surfaces for any remaining opaque
|
||||
`Error: Name` rendering of typed errors.
|
||||
|
||||
## P1: Tests
|
||||
|
||||
When touching tests, migrate them toward the ideal patterns in
|
||||
[`test/EFFECT_TEST_MIGRATION.md`](../../test/EFFECT_TEST_MIGRATION.md):
|
||||
|
||||
- Use `testEffect(...)` with explicit layers.
|
||||
- Prefer `it.instance(...)` for service tests that need an instance.
|
||||
- Prefer `it.live(...)` for real timers, filesystem mtimes, child
|
||||
processes, git, locks, or other live integration behavior.
|
||||
- Avoid sleeps; wait on real events or deterministic state transitions.
|
||||
- Do not mutate `process.env` or mutable globals after layers are built.
|
||||
- Use explicit layer variants, such as `RuntimeFlags.layer(...)`, for
|
||||
behavior changes.
|
||||
|
||||
## P2: RuntimeFlags / Flag Deletion
|
||||
|
||||
Recently completed:
|
||||
|
||||
- [x] Plugin/pure-mode flags moved to RuntimeFlags.
|
||||
- [x] Tool visibility flags moved to RuntimeFlags.
|
||||
- [x] Built-in websearch provider selection uses the same runtime flags as
|
||||
tool visibility.
|
||||
- [x] Removed global default-plugin disabling from test preload.
|
||||
- [x] `RF-1` Reference reads routed through runtime flags (#27318).
|
||||
- [x] `RF-2` Plan-mode prompt read routed through runtime flags (#27320).
|
||||
- [x] `RF-3` Event-system reads routed through runtime flags (#27323).
|
||||
- [x] `RF-4` Workspaces reads routed through runtime flags for session
|
||||
(#27335), sync (#27336), and control-plane (#27337).
|
||||
- [x] LLM client (#27368) and installation client (#27369) routed
|
||||
through runtime flags.
|
||||
- [x] TUI plugin runtime flags simplified (#27506).
|
||||
- [x] Background-subagents flag moved to RuntimeFlags, then removed
|
||||
(`refactor(task): use runtime flag for background subagents`,
|
||||
`refactor(flags): remove background subagents flag`).
|
||||
|
||||
Remaining cleanup:
|
||||
|
||||
- [ ] Sweep lingering `Flag.*` reads — many CLI/TUI/config/observability
|
||||
callsites still import [`flag.ts`](../../../core/src/flag/flag.ts).
|
||||
Decide per-callsite whether to route through RuntimeFlags, accept
|
||||
as legitimate env/config boundary, or migrate to typed `Config`.
|
||||
- [ ] Delete [`test/fixture/flag.ts`](../../test/fixture/flag.ts) once
|
||||
tests no longer mutate `Flag`.
|
||||
- [ ] Delete [`flag.ts`](../../../core/src/flag/flag.ts) once no packages
|
||||
import it.
|
||||
|
||||
## P3: Global Paths
|
||||
|
||||
[`global.ts`](../../../core/src/global.ts) is real connective tissue, not
|
||||
just cosmetic ugliness. It currently mixes path calculation, import-time
|
||||
directory creation, `Flock` setup, mutable exported `Path` state, and a
|
||||
`Flag` dependency.
|
||||
|
||||
Problems to reduce:
|
||||
|
||||
- Importing the module creates directories.
|
||||
- Tests override `Global.Path` by mutating exported module state.
|
||||
- Most callers use `Global.Path` directly instead of the Effect service.
|
||||
- `Global.make()` still reads mutable `Flag.OPENCODE_CONFIG_DIR`.
|
||||
|
||||
Next PR candidates:
|
||||
|
||||
- [ ] Replace mutable `Global.Path` test overrides with explicit test
|
||||
layers or scoped helpers.
|
||||
- [ ] Move directory creation and `Flock` setup behind an explicit init
|
||||
boundary where possible.
|
||||
- [ ] Remove the `Flag` dependency from global path resolution.
|
||||
|
||||
## P4: Instance And Bridge
|
||||
|
||||
Instance context migration is complete for the legacy sync shim. Promise and callback interop continues through [`effect/bridge.ts`](../../src/effect/bridge.ts).
|
||||
|
||||
Current rules:
|
||||
|
||||
- Effect services read instance data from `InstanceRef`, `WorkspaceRef`, `InstanceState`, or explicit arguments.
|
||||
- Plain JavaScript callback boundaries use `EffectBridge` or explicit context arguments.
|
||||
- Runtime entrypoints must provide refs explicitly when they are instance-scoped.
|
||||
|
||||
## Lower Priority Tracks
|
||||
|
||||
- `PROC` / `FS` — continue AppProcess and FSUtil migrations as
|
||||
focused PRs when touching relevant files.
|
||||
- `RT` — remove service-local runtime facades only when they are not an
|
||||
intentional boundary.
|
||||
- `OA` — shrink [`public.ts`](../../src/server/routes/instance/httpapi/public.ts)
|
||||
by tightening source schemas one workaround at a time.
|
||||
- `fetch` → `HttpClient` — migrate raw fetch callsites when the caller is
|
||||
already effectful or being effectified.
|
||||
- `Tools` — remaining tool cleanup is narrow: `webfetch` HTML extraction
|
||||
and `shell` raw stream/promise edges.
|
||||
88
packages/opencode/specs/effect/tools.md
Normal file
88
packages/opencode/specs/effect/tools.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# 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 `FSUtil`, `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 currently use `Tool.define(...)` in `src/tool`:
|
||||
|
||||
- [x] `apply_patch.ts`
|
||||
- [x] `bash.ts`
|
||||
- [x] `edit.ts`
|
||||
- [x] `glob.ts`
|
||||
- [x] `grep.ts`
|
||||
- [x] `invalid.ts`
|
||||
- [x] `lsp.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:
|
||||
|
||||
- There is no current `ls.ts` tool file on this branch.
|
||||
- `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:
|
||||
|
||||
- [x] `read.ts` — streams through `FSUtil.Service.stream` with `Stream.splitLines`; the legacy Node stream / `readline` helper is gone
|
||||
- [ ] `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 file-search routes
|
||||
- [x] `patch/index.ts` — apply path now returns `Effect` over `FSUtil.Service`; the parser and chunk replacer stay pure
|
||||
|
||||
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`
|
||||
- `websearch.ts`
|
||||
- `edit.ts`
|
||||
|
||||
## Filesystem notes
|
||||
|
||||
Current raw fs users that still appear relevant here:
|
||||
|
||||
- `file/ripgrep.ts` — `fs/promises`
|
||||
204
packages/opencode/specs/openapi-translation-cleanup.md
Normal file
204
packages/opencode/specs/openapi-translation-cleanup.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# OpenAPI Translation Cleanup Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Trim `packages/opencode/src/server/routes/instance/httpapi/public.ts` until OpenAPI generation is mostly a direct projection of the `HttpApi` route declarations, without breaking the generated SDK surface.
|
||||
|
||||
The main failure mode to eliminate is spec-only behavior: anything that appears in `/doc` or the SDK but is not accepted by runtime `HttpApi` validation.
|
||||
|
||||
## Current Culprit
|
||||
|
||||
`public.ts` exports `PublicApi` with a large `OpenApi.annotations({ transform })` hook. That hook rewrites the generated spec for legacy SDK compatibility.
|
||||
|
||||
The highest-risk rewrite is `InstanceQueryParameters`, which injected `directory` and `workspace` into every instance route in OpenAPI even when the runtime query schema did not accept them. This caused the SDK and `/doc` to advertise calls that could fail with `400` at runtime.
|
||||
|
||||
## Non-Negotiables
|
||||
|
||||
- Do not break the generated JavaScript SDK without an explicit versioned migration plan.
|
||||
- Runtime route schemas are the source of truth for accepted params, payloads, and responses.
|
||||
- `/doc`, generated SDK types, and runtime validation must agree for every endpoint.
|
||||
- Prefer endpoint or schema annotations over post-generation spec surgery.
|
||||
- Remove one category of rewrite at a time, with focused compatibility checks.
|
||||
|
||||
## PR Checklist
|
||||
|
||||
Status legend: `[x]` done locally, `[~]` in progress locally, `[ ]` not started.
|
||||
|
||||
Current combined PR scope:
|
||||
|
||||
- `[x]` PR 1 drift tests: added OpenAPI/runtime query assertions and a negative fixture in `test/server/httpapi-query-schema-drift.test.ts`.
|
||||
- `[x]` PR 2 injection removal: removed broad `directory` / `workspace` post-generation injection from `public.ts` and replaced it with explicit runtime query schemas on affected routes.
|
||||
- `[ ]` PR 3+ cleanup: leave query override, path pattern, error shape, auth, and component-shape rewrites for later PRs.
|
||||
|
||||
### PR 1: Add OpenAPI/Runtime Query Drift Tests
|
||||
|
||||
- `[x]` Add or extend `packages/opencode/test/server/httpapi-query-schema-drift.test.ts`.
|
||||
- `[x]` Import `OpenApi.fromApi` and `PublicApi`.
|
||||
- `[x]` Generate the public spec in-process with `OpenApi.fromApi(PublicApi)`.
|
||||
- `[x]` Add a route inventory for the existing runtime reproducers: `session`, `file`, `experimental`, and `instance` routes.
|
||||
- `[x]` For each inventory entry, assert every OpenAPI query parameter is declared by the runtime query schema.
|
||||
- `[x]` Add a negative regression fixture that fails on spec-only `directory` / `workspace` params.
|
||||
- `[x]` Keep this part test-only.
|
||||
|
||||
Verification:
|
||||
|
||||
- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`.
|
||||
- `[x]` `bun typecheck` from `packages/opencode`.
|
||||
|
||||
### PR 2: Delete Spec-Only Workspace Query Injection
|
||||
|
||||
- `[x]` Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`.
|
||||
- `[x]` Delete `InstanceQueryParameters`.
|
||||
- `[x]` Delete the `isInstanceRoute` constant.
|
||||
- `[x]` Delete the branch that prepends `directory` and `workspace` to every instance operation.
|
||||
- `[x]` Keep `normalizeParameter(param, route)` for parameters that are actually produced by `HttpApi`.
|
||||
- `[x]` Add `WorkspaceRoutingQuery` / `WorkspaceRoutingQueryFields` to runtime query schemas for affected routes.
|
||||
- `[x]` Regenerate SDK and inspect diff. Result: no `directory` / `workspace` request-param removals; generated SDK diff is declaration ordering only.
|
||||
|
||||
Notes:
|
||||
|
||||
- Added `WorkspaceRoutingQuery` in `middleware/workspace-routing.ts` as the canonical runtime schema for middleware-consumed query params.
|
||||
- Replaced v2 union-query schemas with plain struct query schemas so `OpenApi.fromApi` emits their query params directly. This intentionally exposes the beta `/api/session` pagination/filter params in the SDK; cursor mutual-exclusion rules now live in the handlers, while `directory` / `workspace` remain allowed with cursors for routing.
|
||||
|
||||
Expected code shape:
|
||||
|
||||
```ts
|
||||
for (const param of operation.parameters ?? []) normalizeParameter(param, `${method.toUpperCase()} ${path}`)
|
||||
```
|
||||
|
||||
Verification:
|
||||
|
||||
- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`.
|
||||
- `[x]` `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||
- `[x]` `./packages/sdk/js/script/build.ts` from repo root.
|
||||
- `[x]` Inspect SDK diff for removed `directory` / `workspace` params. Result: none after explicit runtime schemas; v2 list/message now also expose their existing beta pagination/filter query params in the SDK.
|
||||
- `[x]` `bun typecheck` from `packages/opencode`.
|
||||
|
||||
### PR 3: Replace Broad Query Type Override Sets With Route-Level Helpers
|
||||
|
||||
- Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`.
|
||||
- Remove broad name-based assumptions from `QueryNumberParameters` and `QueryBooleanParameters` one field at a time.
|
||||
- Add shared query schema helpers near route group code if needed, for example in `groups/metadata.ts` or a new `groups/query.ts`.
|
||||
- Prefer route declarations like `Schema.NumberFromString.check(...)` and boolean string decoders like the existing `QueryBoolean` in `groups/session.ts`.
|
||||
- Keep only route-specific `QueryParameterSchemas` entries when SDK compatibility requires a public encoded type that Effect OpenAPI cannot emit yet.
|
||||
|
||||
Concrete first targets:
|
||||
|
||||
- `[x]` Consolidate `roots` / `archived` onto an explicit shared route schema helper. Keep `QueryBooleanParameters` until route-level schema metadata can preserve the SDK's `boolean | "true" | "false"` call shape without a global transform.
|
||||
- `[x]` Replace broad `QueryNumberParameters` reliance for `start` / `cursor` / `limit` with route-specific SDK compatibility schemas. Keep improving route-level constraints where behavior is intentionally stricter.
|
||||
- Keep `GET /find/file limit`, `GET /session/{sessionID}/diff messageID`, and `GET /session/{sessionID}/message limit` overrides until their route schemas generate identical SDK types directly.
|
||||
|
||||
Verification:
|
||||
|
||||
- Focused HTTP tests for changed query fields.
|
||||
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||
- Inspect generated SDK request param types before deleting each override.
|
||||
- `bun typecheck` from `packages/opencode`.
|
||||
|
||||
### PR 4: Move Path Parameter Patterns Into ID Schemas
|
||||
|
||||
- Audit `PathParameterSchemas` and `pathParameterSchema()` in `public.ts`.
|
||||
- Check source schemas in files like `packages/opencode/src/session/schema.ts`, `packages/opencode/src/permission/schema.ts`, and pty schema definitions.
|
||||
- Add or fix OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides.
|
||||
- Delete one path override only after generated OpenAPI is unchanged for that param.
|
||||
|
||||
Concrete first targets:
|
||||
|
||||
- `[x]` `sessionID`
|
||||
- `[x]` `messageID`
|
||||
- `[x]` `partID`
|
||||
- `[x]` `permissionID`
|
||||
- `[x]` `ptyID`
|
||||
|
||||
- `[x]` Remove ambiguous workspace `id` path overrides once the endpoint source schema emits the `wrk` pattern.
|
||||
|
||||
Verification:
|
||||
|
||||
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||
- Inspect generated path param types and patterns.
|
||||
- `bun typecheck` from `packages/opencode`.
|
||||
|
||||
### PR 5: Replace Built-In Error Rewrites With Declared API Errors
|
||||
|
||||
- Edit route group files under `packages/opencode/src/server/routes/instance/httpapi/groups/`.
|
||||
- Replace SDK-visible `HttpApiError.BadRequest` / `HttpApiError.NotFound` with explicit error schemas from `packages/opencode/src/server/routes/instance/httpapi/errors.ts` or add new ones there.
|
||||
- Update handlers to fail with the declared API errors at the boundary.
|
||||
- Remove matching cases from `normalizeLegacyErrorResponses()` only after generated OpenAPI remains SDK-compatible.
|
||||
- Do this group by group, starting with one small route group.
|
||||
|
||||
Concrete first targets:
|
||||
|
||||
- `groups/config.ts` `PATCH /config` bad request.
|
||||
- `groups/session.ts` endpoints that already translate domain not-found errors.
|
||||
- `groups/file.ts` if any handler currently relies on built-in error shape.
|
||||
|
||||
Verification:
|
||||
|
||||
- Focused HTTP tests asserting response body shape for changed error paths.
|
||||
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||
- Inspect SDK error union diff.
|
||||
- `bun typecheck` from `packages/opencode`.
|
||||
|
||||
### PR 6: Remove Auth/Security Spec Rewrites If SDK Can Tolerate It
|
||||
|
||||
- Audit `delete operation.security`, `delete operation.responses?.["401"]`, and `delete spec.components?.securitySchemes` in `public.ts`.
|
||||
- Decide whether SDK should expose auth in generated operation metadata.
|
||||
- If preserving no-auth SDK surface is required, leave this rewrite and document it as intentional compatibility code.
|
||||
- If removing it, update SDK generation expectations and docs in the same PR.
|
||||
|
||||
Verification:
|
||||
|
||||
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||
- Inspect generated client call signatures and error unions.
|
||||
- Do not merge if auth churn changes normal SDK call ergonomics unintentionally.
|
||||
|
||||
### PR 7: Tackle Component Shape Rewrites One At A Time
|
||||
|
||||
- Audit these in `public.ts`: `normalizeComponentNames`, `collapseDuplicateComponents`, `applyLegacySchemaOverrides`, `normalizeComponentDescriptions`, `stripOptionalNull`, `fixSelfReferencingComponents`.
|
||||
- For each rewrite, make a tiny PR that removes or narrows only that rewrite.
|
||||
- If generated SDK type names churn broadly, stop and either keep the rewrite or fix `effect-smol` generation first.
|
||||
|
||||
Concrete first targets:
|
||||
|
||||
- Delete cosmetic `normalizeComponentDescriptions` if SDK output does not change materially.
|
||||
- Narrow `applyLegacySchemaOverrides` entries that correspond to schemas already fixed at the source.
|
||||
- Keep `stripOptionalNull` until there is an explicit SDK migration plan, because it likely affects many optional fields.
|
||||
|
||||
Verification:
|
||||
|
||||
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||
- Inspect generated SDK type-name and optionality diffs.
|
||||
|
||||
## Upstream Middleware Query Support
|
||||
|
||||
Long-term, `WorkspaceRoutingMiddleware` should declare the query fields it reads once, and `HttpApi` should use that declaration for both runtime validation and OpenAPI generation.
|
||||
|
||||
Target in `effect-smol`:
|
||||
|
||||
- Extend `HttpApiMiddleware.Service` config with optional query schema support, or add a dedicated middleware query annotation.
|
||||
- Make runtime request decoding include middleware query schemas.
|
||||
- Make `OpenApi.fromApi` emit middleware query params for endpoints using that middleware.
|
||||
|
||||
Once available, remove `WorkspaceRoutingQueryFields` spreads from route groups and declare `directory` / `workspace` only on `WorkspaceRoutingMiddleware`.
|
||||
|
||||
## Suggested PR Order
|
||||
|
||||
1. Add drift detection tests only.
|
||||
2. Remove `InstanceQueryParameters` spec injection; rely on `WorkspaceRoutingQueryFields` already present in runtime schemas.
|
||||
3. Convert query type overrides into route/schema-level helpers where possible.
|
||||
4. Convert path parameter overrides into schema annotations or upstream fixes.
|
||||
5. Replace built-in error response rewrites with explicit declared API errors by route group.
|
||||
6. Tackle component naming/nullability rewrites only after SDK compatibility snapshots are stable.
|
||||
|
||||
## Verification Checklist Per PR
|
||||
|
||||
- Focused HTTP tests for changed routes.
|
||||
- OpenAPI drift tests.
|
||||
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||
- Inspect generated SDK diff for public API churn.
|
||||
- `bun typecheck` from `packages/opencode`.
|
||||
544
packages/opencode/specs/tui-plugins.md
Normal file
544
packages/opencode/specs/tui-plugins.md
Normal file
@@ -0,0 +1,544 @@
|
||||
# TUI plugins
|
||||
|
||||
Technical reference for the current TUI plugin system.
|
||||
|
||||
## Overview
|
||||
|
||||
- TUI plugin config lives in `tui.json`.
|
||||
- Author package entrypoint is `@opencode-ai/plugin/tui`.
|
||||
- Internal plugins load inside the CLI app the same way external TUI plugins do.
|
||||
- Package plugins can be installed from CLI or TUI.
|
||||
- v1 plugin modules are target-exclusive: a module can export `server` or `tui`, never both.
|
||||
- Server runtime keeps v0 legacy fallback (function exports / enumerated exports) after v1 parsing.
|
||||
- npm packages can be TUI theme-only via `package.json["oc-themes"]` without a `./tui` entrypoint.
|
||||
|
||||
## TUI config
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"theme": "smoke-theme",
|
||||
"leader_timeout": 2000,
|
||||
"keybinds": {
|
||||
"leader": "ctrl+x",
|
||||
"command_list": "ctrl+p",
|
||||
"session_new": "<leader>n"
|
||||
},
|
||||
"plugin": ["@acme/opencode-plugin@1.2.3", ["./plugins/demo.tsx", { "label": "demo" }]],
|
||||
"plugin_enabled": {
|
||||
"acme.demo": false
|
||||
},
|
||||
"attention": {
|
||||
"enabled": true,
|
||||
"notifications": true,
|
||||
"sound": true,
|
||||
"volume": 0.4,
|
||||
"sound_pack": "opencode.default",
|
||||
"sounds": {
|
||||
"error": "/Users/me/sounds/error.mp3"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `plugin` entries can be either a string spec or `[spec, options]`.
|
||||
- Plugin specs can be npm specs, `file://` URLs, relative paths, or absolute paths.
|
||||
- Relative path specs are resolved relative to the config file that declared them.
|
||||
- A file module listed in `tui.json` must be a TUI module (`default export { id?, tui }`) and must not export `server`.
|
||||
- Duplicate npm plugins are deduped by package name; higher-precedence config wins.
|
||||
- Duplicate file plugins are deduped by exact resolved file spec. This happens while merging config, before plugin modules are loaded.
|
||||
- `plugin_enabled` is keyed by plugin id, not by plugin spec.
|
||||
- For file plugins, that id must come from the plugin module's exported `id`. For npm plugins, it is the exported `id` or the package name if `id` is omitted.
|
||||
- Plugins are enabled by default. `plugin_enabled` is only for explicit overrides, usually to disable a plugin with `false`.
|
||||
- Internal plugins can declare `enabled: false` to be registered but inactive by default; `plugin_enabled` and runtime KV can still enable them by id.
|
||||
- `plugin_enabled` is merged across config layers.
|
||||
- Runtime enable/disable state is also stored in KV under `plugin_enabled`; that KV state overrides config on startup.
|
||||
- `attention.enabled` defaults to `false`; when `false`, it disables all `api.attention.notify(...)` delivery.
|
||||
- `attention.notifications` and `attention.sound` independently control terminal-mediated desktop notifications and built-in sounds.
|
||||
- `attention.volume` sets the default built-in sound volume from `0` to `1`.
|
||||
- `attention.sound_pack` selects the initial semantic sound pack. Persisted runtime selection in KV can override it.
|
||||
- `attention.sounds` overrides individual semantic sound slots such as `error`, `done`, or `subagent_done`.
|
||||
- `leader_timeout` is a top-level TUI setting.
|
||||
- `keybinds` is a flat object keyed by command id; values are key binding values (`false`, `"none"`, a key string/object, a binding object, or an array of key strings/objects/binding objects).
|
||||
- `keybinds.leader` sets the key used by `<leader>` shortcuts.
|
||||
|
||||
## Author package shape
|
||||
|
||||
Package entrypoint:
|
||||
|
||||
- Import types from `@opencode-ai/plugin/tui`.
|
||||
- `@opencode-ai/plugin` exports `./tui` and declares optional peer deps on `@opentui/core` and `@opentui/solid`.
|
||||
|
||||
Minimal module shape:
|
||||
|
||||
```tsx
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
|
||||
const tui: TuiPlugin = async (api, options, meta) => {
|
||||
api.keymap.registerLayer({
|
||||
commands: [
|
||||
{
|
||||
name: "demo.open",
|
||||
title: "Demo",
|
||||
category: "Plugin",
|
||||
namespace: "palette",
|
||||
slashName: "demo",
|
||||
run() {
|
||||
api.route.navigate("demo")
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: [{ key: "ctrl+shift+m", cmd: "demo.open", desc: "Open demo" }],
|
||||
})
|
||||
|
||||
api.route.register([
|
||||
{
|
||||
name: "demo",
|
||||
render: () => (
|
||||
<box>
|
||||
<text>demo</text>
|
||||
</box>
|
||||
),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const plugin: TuiPluginModule & { id: string } = {
|
||||
id: "acme.demo",
|
||||
tui,
|
||||
}
|
||||
|
||||
export default plugin
|
||||
```
|
||||
|
||||
- Loader only reads the module default export object. Named exports are ignored.
|
||||
- TUI shape is `default export { id?, tui }`; including `server` is rejected.
|
||||
- A single module cannot export both `server` and `tui`.
|
||||
- `tui` signature is `(api, options, meta) => Promise<void>`.
|
||||
- If package `exports` contains `./tui`, the loader resolves that entrypoint.
|
||||
- If package `exports` exists, loader only resolves `./tui` or `./server`; it never falls back to `exports["."]`.
|
||||
- For npm package specs, TUI does not use `package.json` `main` as a fallback entry.
|
||||
- `package.json` `main` is only used for server plugin entrypoint resolution.
|
||||
- If a configured TUI package has no `./tui` entrypoint and no valid `oc-themes`, it is skipped with a warning (not a load failure).
|
||||
- If a configured TUI package has no `./tui` entrypoint but has valid `oc-themes`, runtime creates a no-op module record and still loads it for theme sync and plugin state.
|
||||
- If a package supports both server and TUI, use separate files and package `exports` (`./server` and `./tui`) so each target resolves to a target-only module.
|
||||
- File/path plugins must export a non-empty `id`.
|
||||
- npm plugins may omit `id`; package `name` is used.
|
||||
- Runtime identity is the resolved plugin id. Later plugins with the same id are rejected, including collisions with internal plugin ids.
|
||||
- If a path spec points at a directory, server loading can use `package.json` `main`.
|
||||
- TUI path loading never uses `package.json` `main`.
|
||||
- Legacy compatibility: path specs like `./plugin` can resolve to `./plugin/index.ts` (or `index.js`) when `package.json` is missing.
|
||||
- The `./plugin -> ./plugin/index.*` fallback applies to both server and TUI v1 loading.
|
||||
- There is no directory auto-discovery for TUI plugins; they must be listed in `tui.json`.
|
||||
|
||||
## Package manifest and install
|
||||
|
||||
Install target detection is inferred from `package.json` entrypoints and theme metadata:
|
||||
|
||||
- `server` target when `exports["./server"]` exists or `main` is set.
|
||||
- `tui` target when `exports["./tui"]` exists.
|
||||
- `tui` target when `oc-themes` exists and resolves to a non-empty set of valid package-relative theme paths.
|
||||
|
||||
`oc-themes` rules:
|
||||
|
||||
- `oc-themes` is an array of relative paths.
|
||||
- Absolute paths and `file://` paths are rejected.
|
||||
- Resolved theme paths must stay inside the package directory.
|
||||
- Invalid `oc-themes` causes manifest read failure for install.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@acme/opencode-plugin",
|
||||
"type": "module",
|
||||
"main": "./dist/server.js",
|
||||
"exports": {
|
||||
"./server": {
|
||||
"import": "./dist/server.js",
|
||||
"config": { "custom": true }
|
||||
},
|
||||
"./tui": {
|
||||
"import": "./dist/tui.js",
|
||||
"config": { "compact": true }
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"opencode": "^1.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Version compatibility
|
||||
|
||||
npm plugins can declare a version compatibility range in `package.json` using the standard `engines` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"engines": {
|
||||
"opencode": "^1.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- The value is a semver range checked against the running OpenCode version.
|
||||
- If the range is not satisfied, the plugin is skipped with a warning and a session error.
|
||||
- If `engines.opencode` is absent, no check is performed (backward compatible).
|
||||
- File plugins are never checked; only npm package plugins are validated.
|
||||
|
||||
- Install flow is shared by CLI and TUI in `src/plugin/install.ts`.
|
||||
- Shared helpers are `installPlugin`, `readPluginManifest`, and `patchPluginConfig`.
|
||||
- `opencode plugin <module>` and TUI install both run install → manifest read → config patch.
|
||||
- Alias: `opencode plug <module>`.
|
||||
- `-g` / `--global` writes into the global config dir.
|
||||
- Local installs resolve target dir inside `patchPluginConfig`.
|
||||
- For local scope, path is `<worktree>/.opencode` only when VCS is git and `worktree !== "/"`; otherwise `<directory>/.opencode`.
|
||||
- Root-worktree fallback (`worktree === "/"` uses `<directory>/.opencode`) is covered by regression tests.
|
||||
- `patchPluginConfig` applies all detected targets (`server` and/or `tui`) in one call.
|
||||
- `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors.
|
||||
- `patchPluginConfig` serializes per-target config writes with `Flock.acquire(...)`.
|
||||
- `patchPluginConfig` uses targeted `jsonc-parser` edits, so existing JSONC comments are preserved when plugin entries are added or replaced.
|
||||
- npm plugin package installs are executed with `--ignore-scripts`, so package `install` / `postinstall` lifecycle scripts are not run.
|
||||
- `exports["./server"].config` and `exports["./tui"].config` can provide default plugin options written on first install.
|
||||
- Without `--force`, an already-configured npm package name is a no-op.
|
||||
- With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept.
|
||||
- Explicit npm specs with a version suffix (for example `pkg@1.2.3`) are pinned. Runtime install requests that exact version and does not run stale/latest checks for newer registry versions.
|
||||
- Bare npm specs (`pkg`) are treated as `latest` and can refresh when the cached version is stale.
|
||||
- Tuple targets in `oc-plugin` provide default options written into config.
|
||||
- A package can target `server`, `tui`, or both.
|
||||
- If a package targets both, each target must still resolve to a separate target-only module. Do not export `{ server, tui }` from one module.
|
||||
- There is no uninstall, list, or update CLI command for external plugins.
|
||||
- Local file plugins are configured directly in `tui.json`.
|
||||
|
||||
When `plugin` entries exist in a writable `.opencode` dir or `OPENCODE_CONFIG_DIR`, OpenCode installs `@opencode-ai/plugin` into that dir and writes:
|
||||
|
||||
- `package.json`
|
||||
- `bun.lock`
|
||||
- `node_modules/`
|
||||
- `.gitignore`
|
||||
|
||||
That is what makes local config-scoped plugins able to import `@opencode-ai/plugin/tui`.
|
||||
|
||||
## TUI plugin API
|
||||
|
||||
Top-level API groups exposed to `tui(api, options, meta)`:
|
||||
|
||||
- `api.app.version`
|
||||
- `api.attention.notify(input)`
|
||||
- `api.keys.formatSequence(parts)`, `formatBindings(bindings)`
|
||||
- `api.keymap`
|
||||
- `api.mode.current()`, `api.mode.push(mode)`
|
||||
- `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current`
|
||||
- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `Slot`, `Prompt`, `ui.toast`, `ui.dialog`
|
||||
- `api.tuiConfig`
|
||||
- `api.kv.get`, `set`, `ready`
|
||||
- `api.state`
|
||||
- `api.theme.current`, `selected`, `has`, `set`, `install`, `mode`, `ready`
|
||||
- `api.client`
|
||||
- `api.event.on(type, handler)`
|
||||
- `api.renderer`
|
||||
- `api.slots.register(plugin)`
|
||||
- `api.plugins.list()`, `activate(id)`, `deactivate(id)`, `add(spec)`, `install(spec, options?)`
|
||||
- `api.lifecycle.signal`, `api.lifecycle.onDispose(fn)`
|
||||
|
||||
### Keymap
|
||||
|
||||
- `api.keymap` exposes the raw `Keymap<Renderable, KeyEvent>` instance from the host.
|
||||
- The host already installs the default OpenTUI bundle (`default keys`, metadata fields, and enabled fields) plus OpenCode's comma bindings, leader token, base layout fallback, pending-sequence helpers, and managed textarea layer.
|
||||
- Register commands with `api.keymap.registerLayer({ commands: [...] })`.
|
||||
- Register key bindings with `bindings: [{ key, cmd, desc }]` in the same layer or a separate layer.
|
||||
- Use `api.keymap.acquireResource(...)` for shared plugin addon setup that should ref-count against the host keymap.
|
||||
- To surface a command in the host command palette, set `namespace: "palette"` and provide metadata such as `title`, `category`, `desc`, `suggested`, `hidden`, `enabled`, `slashName`, and `slashAliases` on the command.
|
||||
- Use `api.keymap.dispatchCommand(name)` for user-style execution semantics and `api.keymap.runCommand(name)` only for forced programmatic execution.
|
||||
- Disposers returned by `api.keymap` registrations and `acquireResource(...)` are automatically cleaned up when the plugin deactivates. You do not need to add those disposers to `api.lifecycle.onDispose(...)` yourself.
|
||||
- Built-in which-key shortcuts are resolved from flat `keybinds` command ids such as `which_key_toggle`, not plugin options.
|
||||
|
||||
#### Mode-aware layers
|
||||
|
||||
OpenCode registers a `mode` layer field on the host keymap. Plugins can use it to keep bindings active only in the relevant UI state.
|
||||
|
||||
Built-in modes:
|
||||
|
||||
- `base`: normal app, route, and prompt interaction.
|
||||
- `modal`: host dialog stack is open, including dialogs rendered through `api.ui.dialog` and `api.ui.Dialog*` components.
|
||||
- `autocomplete`: host prompt autocomplete is open.
|
||||
- `api.mode.current()` returns the active top mode, or `base` when no pushed mode is active.
|
||||
|
||||
Example: register a command and shortcut that are active only in normal app mode:
|
||||
|
||||
```tsx
|
||||
api.keymap.registerLayer({
|
||||
mode: "base",
|
||||
commands: [
|
||||
{
|
||||
name: "demo.open",
|
||||
title: "Demo",
|
||||
category: "Plugin",
|
||||
namespace: "palette",
|
||||
run() {
|
||||
api.route.navigate("demo")
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: [{ key: "ctrl+shift+m", cmd: "demo.open", desc: "Open demo" }],
|
||||
})
|
||||
```
|
||||
|
||||
Layers without `mode` are not mode-gated and can remain active while dialogs or autocomplete are open. Use that only for intentionally global commands or low-level keymap extensions.
|
||||
|
||||
Plugins that own a full-screen route or modal-like UI can temporarily push a plugin-specific mode with `api.mode.push(...)`. Use a plugin-scoped mode name. The returned disposer pops that specific stack entry and is idempotent, so popping an older mode while a newer mode is on top leaves the newer mode active.
|
||||
|
||||
```tsx
|
||||
import { onCleanup } from "solid-js"
|
||||
|
||||
api.route.register([
|
||||
{
|
||||
name: "demo",
|
||||
render: () => {
|
||||
const popMode = api.mode.push("acme.demo")
|
||||
onCleanup(popMode)
|
||||
|
||||
return (
|
||||
<box>
|
||||
<text>demo</text>
|
||||
</box>
|
||||
)
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
api.keymap.registerLayer({
|
||||
mode: "acme.demo",
|
||||
bindings: [{ key: "escape", cmd: () => api.route.navigate("home"), desc: "Close demo" }],
|
||||
})
|
||||
```
|
||||
|
||||
Mode pushes are automatically tracked by the plugin runtime. If a plugin is disabled, fails during activation, or the TUI shuts down before the plugin calls the disposer, OpenCode pops the plugin's pushed modes during plugin cleanup. Calling the disposer yourself is still recommended for component lifetimes; cleanup remains idempotent.
|
||||
|
||||
### Keys
|
||||
|
||||
- `api.keys` exposes host-formatted shortcut display helpers for plugin UI.
|
||||
- `formatSequence(parts)` formats parsed key sequence parts using the host's display policy.
|
||||
- `formatBindings(bindings)` formats binding lists and returns `undefined` when there is nothing to show.
|
||||
- For generic config-to-bindings helpers, import `createBindingLookup` from `@opencode-ai/plugin/tui`.
|
||||
|
||||
### Attention
|
||||
|
||||
- `api.attention.notify({ title?, message, notification?, sound? })` requests user attention while keeping terminal focus, notifications, and audio owned by the host.
|
||||
- `message` is required; `title` defaults to `"opencode"`; `notification` defaults to enabled with `when: "blurred"`; `sound` defaults to enabled with `when: "always"`.
|
||||
- `when: "always"` requests delivery regardless of terminal focus state.
|
||||
- `when: "focused"` only requests delivery after the terminal is known focused; `when: "blurred"` only requests delivery after the terminal is known blurred.
|
||||
- Example: `notification: { when: "blurred" }, sound: { name: "question", when: "always" }` plays sound while focused but only triggers system notifications when blurred.
|
||||
- Semantic sound names are `"default"`, `"question"`, `"permission"`, `"error"`, `"done"`, and `"subagent_done"`.
|
||||
- `sound: true` plays the `"default"` sound; `sound: { name: "question" }` plays a named semantic sound.
|
||||
- `sound: { volume }` overrides volume for that call; `sound: false` disables sound for that call; `notification: false` disables system notification for that call.
|
||||
- `api.attention.soundboard.registerPack({ id, name?, sounds })` registers a sound pack and returns a disposer. Relative paths resolve from the plugin root and are cleaned up on plugin deactivation.
|
||||
- `api.attention.soundboard.activate(id, { persist })` selects the active pack. `persist: true` writes the selected pack id to TUI KV state, not `tui.json`.
|
||||
- `api.attention.soundboard.current()` and `list()` expose the active/registered packs for plugin UX.
|
||||
- Config `attention.sounds` overrides active-pack sounds by slot. Failed loads fall back to the active pack and then `opencode.default`.
|
||||
- The host strips ANSI/control characters and collapses newlines before sending text to the terminal notification API.
|
||||
- Terminal and OS settings decide whether a requested notification is visibly displayed.
|
||||
- Prefer privacy-safe messages such as `"A question needs your input"`; avoid full commands, paths, prompts, errors, secrets, or file contents unless the plugin intentionally exposes them.
|
||||
|
||||
### Routes
|
||||
|
||||
- Reserved route names: `home` and `session`.
|
||||
- Any other name is treated as a plugin route.
|
||||
- `api.route.current` returns one of:
|
||||
- `{ name: "home" }`
|
||||
- `{ name: "session", params: { sessionID, initialPrompt? } }`
|
||||
- `{ name: string, params?: Record<string, unknown> }`
|
||||
- `api.route.navigate("session", params)` only uses `params.sessionID`. It cannot set `initialPrompt`.
|
||||
- If multiple plugins register the same route name, the last registered route wins.
|
||||
- Unknown plugin routes render a fallback screen with a `go home` action.
|
||||
|
||||
### Dialogs and toast
|
||||
|
||||
- `ui.Dialog` is the base dialog wrapper.
|
||||
- `ui.DialogAlert`, `ui.DialogConfirm`, `ui.DialogPrompt`, `ui.DialogSelect` are built-in dialog components.
|
||||
- `ui.Slot` renders host or plugin-defined slots by name from plugin JSX.
|
||||
- `ui.Prompt` renders the same prompt component used by the host app and accepts `sessionID`, `workspaceID`, `ref`, and `right` for the prompt meta row's right side.
|
||||
- `ui.toast(...)` shows a toast.
|
||||
- `ui.dialog` exposes the host dialog stack:
|
||||
- `replace(render, onClose?)`
|
||||
- `clear()`
|
||||
- `setSize("medium" | "large" | "xlarge")`
|
||||
- readonly `size`, `depth`, `open`
|
||||
|
||||
### KV, state, client, events
|
||||
|
||||
- `api.kv` is the shared app KV store backed by `state/kv.json`. It is not plugin-namespaced.
|
||||
- `api.kv` exposes `ready`.
|
||||
- `api.tuiConfig` and `api.state` are live host objects/getters, not frozen snapshots.
|
||||
- `api.state` exposes synced TUI state:
|
||||
- `ready`
|
||||
- `config`
|
||||
- `provider`
|
||||
- `path.{state,config,worktree,directory}`
|
||||
- `vcs?.branch`
|
||||
- `session.count()`
|
||||
- `session.diff(sessionID)`
|
||||
- `session.todo(sessionID)`
|
||||
- `session.messages(sessionID)`
|
||||
- `session.status(sessionID)`
|
||||
- `session.permission(sessionID)`
|
||||
- `session.question(sessionID)`
|
||||
- `part(messageID)`
|
||||
- `lsp()`
|
||||
- `mcp()`
|
||||
- `api.client` always reflects the current runtime client.
|
||||
- `api.event.on(type, handler)` subscribes to the TUI event stream and returns an unsubscribe function.
|
||||
- `api.renderer` exposes the raw `CliRenderer`.
|
||||
|
||||
### Theme
|
||||
|
||||
- `api.theme.current` exposes the resolved current theme tokens.
|
||||
- `api.theme.selected` is the selected theme name.
|
||||
- `api.theme.has(name)` checks for an installed theme.
|
||||
- `api.theme.set(name)` switches theme and returns `boolean`.
|
||||
- `api.theme.mode()` returns `"dark" | "light"`.
|
||||
- `api.theme.install(jsonPath)` installs a theme JSON file.
|
||||
- `api.theme.ready` reports theme readiness.
|
||||
|
||||
Theme install behavior:
|
||||
|
||||
- Relative theme paths are resolved from the plugin root.
|
||||
- Theme name is the JSON basename.
|
||||
- `api.theme.install(...)` and `oc-themes` auto-sync share the same installer path.
|
||||
- Theme copy/write runs under cross-process lock key `tui-theme:<dest>`.
|
||||
- First install writes only when the destination file is missing.
|
||||
- If the theme name already exists, install is skipped unless plugin metadata state is `updated`.
|
||||
- On `updated`, host skips rewrite when tracked `mtime`/`size` is unchanged.
|
||||
- When a theme already exists and state is not `updated`, host can still persist theme metadata when destination already exists.
|
||||
- Local plugins persist installed themes under the local `.opencode/themes` area near the plugin config source.
|
||||
- Global plugins persist installed themes under the global `themes` dir.
|
||||
- Invalid or unreadable theme files are ignored.
|
||||
|
||||
### Slots
|
||||
|
||||
Current host slot names:
|
||||
|
||||
- `app`
|
||||
- `app_bottom`
|
||||
- `home_logo`
|
||||
- `home_prompt` with props `{ workspace_id?, ref? }`
|
||||
- `home_prompt_right` with props `{ workspace_id? }`
|
||||
- `session_prompt` with props `{ session_id, visible?, disabled?, on_submit?, ref? }`
|
||||
- `session_prompt_right` with props `{ session_id }`
|
||||
- `home_bottom`
|
||||
- `home_footer`
|
||||
- `sidebar_title` with props `{ session_id, title, share_url? }`
|
||||
- `sidebar_content` with props `{ session_id }`
|
||||
- `sidebar_footer` with props `{ session_id }`
|
||||
|
||||
Slot notes:
|
||||
|
||||
- Slot context currently exposes only `theme`.
|
||||
- `api.slots.register(plugin)` returns the host-assigned slot plugin id.
|
||||
- `api.slots.register(plugin)` does not return an unregister function.
|
||||
- Returned ids are `pluginId`, `pluginId:1`, `pluginId:2`, and so on.
|
||||
- Plugin-provided `id` is not allowed.
|
||||
- The current host renders `home_logo`, `home_prompt`, and `session_prompt` with `replace`, `home_footer`, `sidebar_title`, and `sidebar_footer` with `single_winner`, and `app`, `app_bottom`, `home_prompt_right`, `session_prompt_right`, `home_bottom`, and `sidebar_content` with the slot library default mode.
|
||||
- `app_bottom` is rendered in normal layout flow below the active route, while `app` is rendered afterward for global app-level UI.
|
||||
- Plugins can define custom slot names in `api.slots.register(...)` and render them from plugin UI with `ui.Slot`.
|
||||
|
||||
### Plugin control and lifecycle
|
||||
|
||||
- `api.plugins.list()` returns `{ id, source, spec, target, enabled, active }[]`.
|
||||
- `enabled` is the persisted desired state. `active` means the plugin is currently initialized.
|
||||
- `api.plugins.activate(id)` sets `enabled=true`, persists it into KV, and initializes the plugin.
|
||||
- `api.plugins.deactivate(id)` sets `enabled=false`, persists it into KV, and disposes the plugin scope.
|
||||
- `api.plugins.add(spec)` trims the input and returns `false` for an empty string.
|
||||
- `api.plugins.add(spec)` treats the input as the runtime plugin spec and loads it without re-reading `tui.json`.
|
||||
- `api.plugins.add(spec)` no-ops when that resolved spec (or resolved plugin id) is already loaded.
|
||||
- `api.plugins.add(spec)` assumes enabled and always attempts initialization (it does not consult config/KV enable state).
|
||||
- `api.plugins.add(spec)` can load theme-only packages (`oc-themes` with no `./tui`) as runtime entries.
|
||||
- `api.plugins.install(spec, { global? })` runs install -> manifest read -> config patch using the same helper flow as CLI install.
|
||||
- `api.plugins.install(...)` returns either `{ ok: false, message, missing? }` or `{ ok: true, dir, tui }`.
|
||||
- `api.plugins.install(...)` does not load plugins into the current session. Call `api.plugins.add(spec)` to load after install.
|
||||
- If activation fails, the plugin can remain `enabled=true` and `active=false`.
|
||||
- `api.lifecycle.signal` is aborted before cleanup runs.
|
||||
- `api.lifecycle.onDispose(fn)` registers cleanup and returns an unregister function.
|
||||
|
||||
## Plugin metadata
|
||||
|
||||
`meta` passed to `tui(api, options, meta)` contains:
|
||||
|
||||
- `state`: `first | updated | same`
|
||||
- `id`, `source`, `spec`, `target`
|
||||
- npm-only fields when available: `requested`, `version`
|
||||
- file-only field when available: `modified`
|
||||
- `first_time`, `last_time`, `time_changed`, `load_count`, `fingerprint`
|
||||
|
||||
Metadata is persisted by plugin id.
|
||||
|
||||
- File plugin fingerprint is `target|modified`.
|
||||
- npm plugin fingerprint is `target|requested|version`.
|
||||
- Internal plugins get synthetic metadata with `state: "same"`.
|
||||
|
||||
## Runtime behavior
|
||||
|
||||
- Internal TUI plugins load first.
|
||||
- External TUI plugins load from `tuiConfig.plugin`.
|
||||
- `--pure` / `OPENCODE_PURE` skips external TUI plugins only.
|
||||
- External plugin resolution and import are parallel.
|
||||
- Packages with no `./tui` entrypoint and valid `oc-themes` are loaded as synthetic no-op TUI plugin modules.
|
||||
- Theme-only packages loaded this way appear in `api.plugins.list()` and plugin manager rows like other external plugins.
|
||||
- Packages with no `./tui` entrypoint and no valid `oc-themes` are skipped with warning.
|
||||
- External plugin activation is sequential to keep command, route, and side-effect order deterministic.
|
||||
- Theme auto-sync from `oc-themes` runs before plugin `tui(...)` execution and only on metadata state `first` or `updated`.
|
||||
- File plugins that fail initially are retried once after waiting for config dependency installation.
|
||||
- Runtime add uses the same external loader path, including the file-plugin retry after dependency wait.
|
||||
- Runtime add skips duplicates by resolved spec and returns `true` when the spec is already loaded.
|
||||
- Runtime install and runtime add are separate operations.
|
||||
- Plugin init failure rolls back that plugin's tracked registrations and loading continues.
|
||||
- TUI runtime tracks and disposes:
|
||||
- command registrations
|
||||
- route registrations
|
||||
- event subscriptions
|
||||
- slot registrations
|
||||
- explicit `lifecycle.onDispose(...)` handlers
|
||||
- Cleanup runs in reverse order.
|
||||
- Cleanup is awaited.
|
||||
- Total cleanup budget per plugin is 5 seconds; timeout/error is logged and shutdown continues.
|
||||
|
||||
## Built-in plugins
|
||||
|
||||
- `internal:home-tips`
|
||||
- `internal:sidebar-context`
|
||||
- `internal:sidebar-mcp`
|
||||
- `internal:sidebar-lsp`
|
||||
- `internal:sidebar-todo`
|
||||
- `internal:sidebar-files`
|
||||
- `internal:sidebar-footer`
|
||||
- `internal:plugin-manager`
|
||||
|
||||
Sidebar content order is currently: context `100`, mcp `200`, lsp `300`, todo `400`, files `500`.
|
||||
|
||||
The plugin manager is exposed as a command with title `Plugins` and value `plugins.list`.
|
||||
|
||||
- Keybind name is `plugin_manager`.
|
||||
- Default keybind is `none`.
|
||||
- It lists both internal and external plugins.
|
||||
- It toggles based on `active`.
|
||||
- Its own row is disabled only inside the manager dialog.
|
||||
- It also exposes command `plugins.install` with title `Install plugin`.
|
||||
- Inside the Plugins dialog, key `shift+i` opens the install prompt.
|
||||
- Install prompt asks for npm package name.
|
||||
- Scope defaults to local, and `tab` toggles local/global.
|
||||
- Install is blocked until `api.state.path.directory` is available; current guard message is `Paths are still syncing. Try again in a moment.`.
|
||||
- Manager install uses `api.plugins.install(spec, { global })`.
|
||||
- If the installed package has no `tui` target (`tui=false`), manager reports that and does not expect a runtime load.
|
||||
- `tui` target detection includes `exports["./tui"]` and valid `oc-themes`.
|
||||
- If install reports `tui=true`, manager then calls `api.plugins.add(spec)`.
|
||||
- If runtime add fails, TUI shows a warning and restart remains the fallback.
|
||||
|
||||
## Current in-repo examples
|
||||
|
||||
- Local smoke plugin: `.opencode/plugins/tui-smoke.tsx`
|
||||
- Local vim plugin: `.opencode/plugins/tui-vim.tsx`
|
||||
- Local smoke config: `.opencode/tui.json`
|
||||
- Local smoke theme: `.opencode/plugins/smoke-theme.json`
|
||||
67
packages/opencode/specs/v2/api.ts
Normal file
67
packages/opencode/specs/v2/api.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import { OpenCode } from "@opencode-ai/core"
|
||||
import { ReadTool } from "@opencode-ai/core/tools"
|
||||
|
||||
const opencode = OpenCode.make({})
|
||||
|
||||
opencode.tool.add(ReadTool)
|
||||
|
||||
opencode.tool.add({
|
||||
name: "bash",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
command: {
|
||||
type: "string",
|
||||
description: "The command to run.",
|
||||
},
|
||||
},
|
||||
required: ["command"],
|
||||
},
|
||||
execute(input, ctx) {},
|
||||
})
|
||||
|
||||
opencode.auth.add({
|
||||
provider: "openai",
|
||||
type: "api",
|
||||
value: process.env.OPENAI_API_KEY,
|
||||
})
|
||||
|
||||
opencode.agent.add({
|
||||
name: "build",
|
||||
permissions: [],
|
||||
model: {
|
||||
id: "gpt-5-5",
|
||||
provider: "openai",
|
||||
variant: "xhigh",
|
||||
},
|
||||
})
|
||||
|
||||
const sessionID = await opencode.session.create({
|
||||
agent: "build",
|
||||
})
|
||||
|
||||
opencode.subscribe((event) => {
|
||||
console.log(event)
|
||||
})
|
||||
|
||||
await opencode.session.prompt({
|
||||
sessionID,
|
||||
text: "hey what is up",
|
||||
})
|
||||
|
||||
await opencode.session.prompt({
|
||||
sessionID,
|
||||
text: "what is up with this",
|
||||
files: [
|
||||
{
|
||||
mime: "image/png",
|
||||
uri: "data:image/png;base64,xxxx",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await opencode.session.wait()
|
||||
|
||||
console.log(await opencode.session.messages(sessionID))
|
||||
136
packages/opencode/specs/v2/message-shape.md
Normal file
136
packages/opencode/specs/v2/message-shape.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Message Shape
|
||||
|
||||
Problem:
|
||||
|
||||
- stored messages need enough data to replay and resume a session later
|
||||
- prompt hooks often just want to append a synthetic user/assistant message
|
||||
- today that means faking ids, timestamps, and request metadata
|
||||
|
||||
## Option 1: Two Message Shapes
|
||||
|
||||
Keep `User` / `Assistant` for stored history, but clean them up.
|
||||
|
||||
```ts
|
||||
type User = {
|
||||
role: "user"
|
||||
time: { created: number }
|
||||
request: {
|
||||
agent: string
|
||||
model: ModelRef
|
||||
variant?: string
|
||||
format?: OutputFormat
|
||||
system?: string
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
}
|
||||
|
||||
type Assistant = {
|
||||
role: "assistant"
|
||||
run: { agent: string; model: ModelRef; path: { cwd: string; root: string } }
|
||||
usage: { cost: number; tokens: Tokens }
|
||||
result: { finish?: string; error?: Error; structured?: unknown; kind: "reply" | "summary" }
|
||||
}
|
||||
```
|
||||
|
||||
Add a separate transient `PromptMessage` for prompt surgery.
|
||||
|
||||
```ts
|
||||
type PromptMessage = {
|
||||
role: "user" | "assistant"
|
||||
parts: PromptPart[]
|
||||
}
|
||||
```
|
||||
|
||||
Plugin hook example:
|
||||
|
||||
```ts
|
||||
prompt.push({
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
|
||||
})
|
||||
```
|
||||
|
||||
Tradeoff: prompt hooks get easy lightweight messages, but there are now two message shapes.
|
||||
|
||||
## Option 2: Prompt Mutators
|
||||
|
||||
Keep `User` / `Assistant` as the stored history model.
|
||||
|
||||
Prompt hooks do not build messages directly. The runtime gives them prompt mutators.
|
||||
|
||||
```ts
|
||||
type PromptEditor = {
|
||||
append(input: { role: "user" | "assistant"; parts: PromptPart[] }): void
|
||||
prepend(input: { role: "user" | "assistant"; parts: PromptPart[] }): void
|
||||
appendTo(target: "last-user" | "last-assistant", parts: PromptPart[]): void
|
||||
insertAfter(messageID: string, input: { role: "user" | "assistant"; parts: PromptPart[] }): void
|
||||
insertBefore(messageID: string, input: { role: "user" | "assistant"; parts: PromptPart[] }): void
|
||||
}
|
||||
```
|
||||
|
||||
Plugin hook examples:
|
||||
|
||||
```ts
|
||||
prompt.append({
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
|
||||
})
|
||||
```
|
||||
|
||||
```ts
|
||||
prompt.appendTo("last-user", [{ type: "text", text: BUILD_SWITCH }])
|
||||
```
|
||||
|
||||
Tradeoff: avoids a second full message type and avoids fake ids/timestamps, but moves more magic into the hook API.
|
||||
|
||||
## Option 3: Separate Turn State
|
||||
|
||||
Move execution settings out of `User` and into a separate turn/request object.
|
||||
|
||||
```ts
|
||||
type Turn = {
|
||||
id: string
|
||||
request: {
|
||||
agent: string
|
||||
model: ModelRef
|
||||
variant?: string
|
||||
format?: OutputFormat
|
||||
system?: string
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
}
|
||||
|
||||
type User = {
|
||||
role: "user"
|
||||
turnID: string
|
||||
time: { created: number }
|
||||
}
|
||||
|
||||
type Assistant = {
|
||||
role: "assistant"
|
||||
turnID: string
|
||||
usage: { cost: number; tokens: Tokens }
|
||||
result: { finish?: string; error?: Error; structured?: unknown; kind: "reply" | "summary" }
|
||||
}
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```ts
|
||||
const turn = {
|
||||
request: {
|
||||
agent: "build",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
const msg = {
|
||||
role: "user",
|
||||
turnID: turn.id,
|
||||
parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
|
||||
}
|
||||
```
|
||||
|
||||
Tradeoff: stored messages get much smaller and cleaner, but replay now has to join messages with turn state and prompt hooks still need a way to pick which turn they belong to.
|
||||
13
packages/opencode/specs/v2/notifications.md
Normal file
13
packages/opencode/specs/v2/notifications.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# TUI Notifications Default
|
||||
|
||||
Problem:
|
||||
|
||||
- v1 defaults `attention.enabled` to `false`
|
||||
- users can opt in with `attention.enabled = true`
|
||||
- v2 should make core TUI notifications a default behavior
|
||||
|
||||
## v2 Target
|
||||
|
||||
Flip `attention.enabled` to `true` by default in v2.
|
||||
|
||||
Keep `attention.enabled = false` as the explicit opt-out.
|
||||
67
packages/opencode/specs/v2/tui-command-shim.md
Normal file
67
packages/opencode/specs/v2/tui-command-shim.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# TUI Command Shim Removal
|
||||
|
||||
Problem:
|
||||
|
||||
- v1 keeps a deprecated `api.command` TUI plugin shim so older plugins do not fail during initialization
|
||||
- v2 should expose only the keymap command API
|
||||
- tests and fixtures should not encode legacy command behavior as expected behavior
|
||||
|
||||
## Remove Public Types
|
||||
|
||||
In `packages/plugin/src/tui.ts`, remove:
|
||||
|
||||
- `TuiCommand`
|
||||
- `TuiCommandApi`
|
||||
- `TuiPluginApi.command`
|
||||
|
||||
Keep `api.keymap` as the only TUI command registration and execution surface.
|
||||
|
||||
## Remove Runtime Shim
|
||||
|
||||
Delete `packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts`.
|
||||
|
||||
In `packages/opencode/src/cli/cmd/tui/plugin/api.tsx`, remove:
|
||||
|
||||
- the `createCommandShim` import
|
||||
- the `command: createCommandShim(...)` field from `createTuiApi(...)`
|
||||
|
||||
In `packages/opencode/src/cli/cmd/tui/plugin/runtime.ts`, remove:
|
||||
|
||||
- the `createCommandShim` import
|
||||
- the `command: createCommandShim(...)` field from `pluginApi(...)`
|
||||
|
||||
## Migration Target
|
||||
|
||||
Plugin authors should replace old calls with keymap calls:
|
||||
|
||||
```ts
|
||||
api.keymap.registerLayer({
|
||||
commands: [
|
||||
{
|
||||
name: "plugin.command",
|
||||
title: "Plugin Command",
|
||||
namespace: "palette",
|
||||
slashName: "plugin",
|
||||
run() {
|
||||
api.ui.dialog.clear()
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: [{ key: "ctrl+shift+p", cmd: "plugin.command" }],
|
||||
})
|
||||
```
|
||||
|
||||
Direct replacements:
|
||||
|
||||
- `api.command.register(cb)` -> `api.keymap.registerLayer({ commands, bindings })`
|
||||
- `api.command.trigger(name)` -> `api.keymap.dispatchCommand(name)`
|
||||
- `api.command.show()` -> `api.keymap.dispatchCommand("command.palette.show")`
|
||||
- `onSelect(dialog)` -> use `api.ui.dialog` from the plugin API closure
|
||||
|
||||
## Verification
|
||||
|
||||
After removal, run from package directories:
|
||||
|
||||
- `bun typecheck` in `packages/plugin`
|
||||
- `bun typecheck` in `packages/opencode`
|
||||
- TUI plugin loader tests in `packages/opencode` if runtime plugin API wiring changed
|
||||
463
packages/opencode/src/account/account.ts
Normal file
463
packages/opencode/src/account/account.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { httpClient } from "@opencode-ai/core/effect/layer-node-platform"
|
||||
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
HttpClient,
|
||||
HttpClientError,
|
||||
HttpClientRequest,
|
||||
HttpClientResponse,
|
||||
} from "effect/unstable/http"
|
||||
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { AccountRepo, type AccountRow } from "./repo"
|
||||
import { normalizeServerUrl } from "./url"
|
||||
import {
|
||||
type AccountError,
|
||||
AccessToken,
|
||||
AccountID,
|
||||
DeviceCode,
|
||||
Info,
|
||||
RefreshToken,
|
||||
AccountServiceError,
|
||||
AccountTransportError,
|
||||
Login,
|
||||
Org,
|
||||
OrgID,
|
||||
PollDenied,
|
||||
PollError,
|
||||
PollExpired,
|
||||
PollPending,
|
||||
type PollResult,
|
||||
PollSlow,
|
||||
PollSuccess,
|
||||
UserCode,
|
||||
} from "./schema"
|
||||
|
||||
export {
|
||||
AccountID,
|
||||
type AccountError,
|
||||
AccountRepoError,
|
||||
AccountServiceError,
|
||||
AccountTransportError,
|
||||
AccessToken,
|
||||
RefreshToken,
|
||||
DeviceCode,
|
||||
UserCode,
|
||||
Info,
|
||||
Org,
|
||||
OrgID,
|
||||
Login,
|
||||
PollSuccess,
|
||||
PollPending,
|
||||
PollSlow,
|
||||
PollExpired,
|
||||
PollDenied,
|
||||
PollError,
|
||||
PollResult,
|
||||
} from "./schema"
|
||||
|
||||
export type AccountOrgs = {
|
||||
account: Info
|
||||
orgs: readonly Org[]
|
||||
}
|
||||
|
||||
export type ActiveOrg = {
|
||||
account: Info
|
||||
org: Org
|
||||
}
|
||||
|
||||
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
|
||||
config: Schema.Record(Schema.String, Schema.Json),
|
||||
}) {}
|
||||
|
||||
const DurationFromSeconds = Schema.Number.pipe(
|
||||
Schema.decodeTo(Schema.Duration, {
|
||||
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
|
||||
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
|
||||
}),
|
||||
)
|
||||
|
||||
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
||||
device_code: DeviceCode,
|
||||
user_code: UserCode,
|
||||
verification_uri_complete: Schema.String,
|
||||
expires_in: DurationFromSeconds,
|
||||
interval: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
token_type: Schema.Literal("Bearer"),
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
|
||||
error: Schema.String,
|
||||
error_description: Schema.String,
|
||||
}) {
|
||||
toPollResult(): PollResult {
|
||||
if (this.error === "authorization_pending") return new PollPending()
|
||||
if (this.error === "slow_down") return new PollSlow()
|
||||
if (this.error === "expired_token") return new PollExpired()
|
||||
if (this.error === "access_denied") return new PollDenied()
|
||||
return new PollError({ cause: this.error })
|
||||
}
|
||||
}
|
||||
|
||||
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
|
||||
|
||||
class User extends Schema.Class<User>("User")({
|
||||
id: AccountID,
|
||||
email: Schema.String,
|
||||
}) {}
|
||||
|
||||
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
|
||||
|
||||
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
|
||||
grant_type: Schema.String,
|
||||
device_code: DeviceCode,
|
||||
client_id: Schema.String,
|
||||
}) {}
|
||||
|
||||
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
|
||||
grant_type: Schema.String,
|
||||
refresh_token: RefreshToken,
|
||||
client_id: Schema.String,
|
||||
}) {}
|
||||
|
||||
const clientId = "opencode-cli"
|
||||
const eagerRefreshThreshold = Duration.minutes(5)
|
||||
const eagerRefreshThresholdMs = Duration.toMillis(eagerRefreshThreshold)
|
||||
|
||||
const isTokenFresh = (tokenExpiry: number | null, now: number) =>
|
||||
tokenExpiry != null && tokenExpiry > now + eagerRefreshThresholdMs
|
||||
|
||||
const mapAccountServiceError =
|
||||
(message = "Account service operation failed") =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountError, R> =>
|
||||
effect.pipe(Effect.mapError((cause) => accountErrorFromCause(cause, message)))
|
||||
|
||||
const accountErrorFromCause = (cause: unknown, message: string): AccountError => {
|
||||
if (cause instanceof AccountServiceError || cause instanceof AccountTransportError) {
|
||||
return cause
|
||||
}
|
||||
|
||||
if (HttpClientError.isHttpClientError(cause)) {
|
||||
switch (cause.reason._tag) {
|
||||
case "TransportError": {
|
||||
return AccountTransportError.fromHttpClientError(cause.reason)
|
||||
}
|
||||
default: {
|
||||
return new AccountServiceError({ message, cause })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new AccountServiceError({ message, cause })
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
|
||||
readonly activeOrg: () => Effect.Effect<Option.Option<ActiveOrg>, AccountError>
|
||||
readonly list: () => Effect.Effect<Info[], AccountError>
|
||||
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
|
||||
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
|
||||
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
|
||||
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
|
||||
readonly config: (
|
||||
accountID: AccountID,
|
||||
orgID: OrgID,
|
||||
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
|
||||
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
|
||||
readonly login: (url: string) => Effect.Effect<Login, AccountError>
|
||||
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Account") {}
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
export const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient.HttpClient> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const repo = yield* AccountRepo.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const httpRead = withTransientReadRetry(http)
|
||||
const httpOk = HttpClient.filterStatusOk(http)
|
||||
const httpReadOk = HttpClient.filterStatusOk(httpRead)
|
||||
|
||||
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
||||
|
||||
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
||||
|
||||
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||
request.pipe(
|
||||
Effect.flatMap((req) => httpOk.execute(req)),
|
||||
mapAccountServiceError("HTTP request failed"),
|
||||
)
|
||||
|
||||
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||
request.pipe(
|
||||
Effect.flatMap((req) => http.execute(req)),
|
||||
mapAccountServiceError("HTTP request failed"),
|
||||
)
|
||||
|
||||
const refreshToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
|
||||
const response = yield* executeEffectOk(
|
||||
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
|
||||
new TokenRefreshRequest({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: row.refresh_token,
|
||||
client_id: clientId,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
|
||||
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
|
||||
|
||||
yield* repo.persistToken({
|
||||
accountID: row.id,
|
||||
accessToken: parsed.access_token,
|
||||
refreshToken: parsed.refresh_token,
|
||||
expiry,
|
||||
})
|
||||
|
||||
return parsed.access_token
|
||||
})
|
||||
|
||||
const refreshTokenCache = yield* Cache.make<AccountID, AccessToken, AccountError>({
|
||||
capacity: Number.POSITIVE_INFINITY,
|
||||
timeToLive: Duration.zero,
|
||||
lookup: Effect.fnUntraced(function* (accountID) {
|
||||
const maybeAccount = yield* repo.getRow(accountID)
|
||||
if (Option.isNone(maybeAccount)) {
|
||||
return yield* Effect.fail(new AccountServiceError({ message: "Account not found during token refresh" }))
|
||||
}
|
||||
|
||||
const account = maybeAccount.value
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (isTokenFresh(account.token_expiry, now)) {
|
||||
return account.access_token
|
||||
}
|
||||
|
||||
return yield* refreshToken(account)
|
||||
}),
|
||||
})
|
||||
|
||||
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (isTokenFresh(row.token_expiry, now)) {
|
||||
return row.access_token
|
||||
}
|
||||
|
||||
return yield* Cache.get(refreshTokenCache, row.id)
|
||||
})
|
||||
|
||||
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
|
||||
const maybeAccount = yield* repo.getRow(accountID)
|
||||
if (Option.isNone(maybeAccount)) return Option.none()
|
||||
|
||||
const account = maybeAccount.value
|
||||
const accessToken = yield* resolveToken(account)
|
||||
return Option.some({ account, accessToken })
|
||||
})
|
||||
|
||||
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
||||
const response = yield* executeReadOk(
|
||||
HttpClientRequest.get(`${url}/api/orgs`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
),
|
||||
)
|
||||
|
||||
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
})
|
||||
|
||||
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
||||
const response = yield* executeReadOk(
|
||||
HttpClientRequest.get(`${url}/api/user`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
),
|
||||
)
|
||||
|
||||
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
})
|
||||
|
||||
const token = Effect.fn("Account.token")((accountID: AccountID) =>
|
||||
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
|
||||
)
|
||||
|
||||
const activeOrg = Effect.fn("Account.activeOrg")(function* () {
|
||||
const activeAccount = yield* repo.active()
|
||||
if (Option.isNone(activeAccount)) return Option.none<ActiveOrg>()
|
||||
|
||||
const account = activeAccount.value
|
||||
if (!account.active_org_id) return Option.none<ActiveOrg>()
|
||||
|
||||
const accountOrgs = yield* orgs(account.id)
|
||||
const org = accountOrgs.find((item) => item.id === account.active_org_id)
|
||||
if (!org) return Option.none<ActiveOrg>()
|
||||
|
||||
return Option.some({ account, org })
|
||||
})
|
||||
|
||||
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
|
||||
const accounts = yield* repo.list()
|
||||
return yield* Effect.forEach(
|
||||
accounts,
|
||||
(account) =>
|
||||
orgs(account.id).pipe(
|
||||
Effect.catch(() => Effect.succeed([] as readonly Org[])),
|
||||
Effect.map((orgs) => ({ account, orgs })),
|
||||
),
|
||||
{ concurrency: 3 },
|
||||
)
|
||||
})
|
||||
|
||||
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
|
||||
const resolved = yield* resolveAccess(accountID)
|
||||
if (Option.isNone(resolved)) return []
|
||||
|
||||
const { account, accessToken } = resolved.value
|
||||
|
||||
return yield* fetchOrgs(account.url, accessToken)
|
||||
})
|
||||
|
||||
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
|
||||
const resolved = yield* resolveAccess(accountID)
|
||||
if (Option.isNone(resolved)) return Option.none()
|
||||
|
||||
const { account, accessToken } = resolved.value
|
||||
|
||||
const response = yield* executeRead(
|
||||
HttpClientRequest.get(`${account.url}/api/config`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
|
||||
),
|
||||
)
|
||||
|
||||
if (response.status === 404) return Option.none()
|
||||
|
||||
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
return Option.some(parsed.config)
|
||||
})
|
||||
|
||||
const login = Effect.fn("Account.login")(function* (server: string) {
|
||||
const normalizedServer = normalizeServerUrl(server)
|
||||
const response = yield* executeEffectOk(
|
||||
HttpClientRequest.post(`${normalizedServer}/auth/device/code`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
|
||||
),
|
||||
)
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
return new Login({
|
||||
code: parsed.device_code,
|
||||
user: parsed.user_code,
|
||||
url: `${normalizedServer}${parsed.verification_uri_complete}`,
|
||||
server: normalizedServer,
|
||||
expiry: parsed.expires_in,
|
||||
interval: parsed.interval,
|
||||
})
|
||||
})
|
||||
|
||||
const poll = Effect.fn("Account.poll")(function* (input: Login) {
|
||||
const response = yield* executeEffect(
|
||||
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
|
||||
new DeviceTokenRequest({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
device_code: input.code,
|
||||
client_id: clientId,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
|
||||
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
|
||||
const accessToken = parsed.access_token
|
||||
|
||||
const user = fetchUser(input.server, accessToken)
|
||||
const orgs = fetchOrgs(input.server, accessToken)
|
||||
|
||||
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
|
||||
|
||||
// TODO: When there are multiple orgs, let the user choose
|
||||
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
|
||||
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const expiry = now + Duration.toMillis(parsed.expires_in)
|
||||
const refreshToken = parsed.refresh_token
|
||||
|
||||
yield* repo.persistAccount({
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
url: input.server,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiry,
|
||||
orgID: firstOrgID,
|
||||
})
|
||||
|
||||
return new PollSuccess({ email: account.email })
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
active: repo.active,
|
||||
activeOrg,
|
||||
list: repo.list,
|
||||
orgsByAccount,
|
||||
remove: repo.remove,
|
||||
use: repo.use,
|
||||
orgs,
|
||||
config,
|
||||
token,
|
||||
login,
|
||||
poll,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.defaultLayer), Layer.provide(FetchHttpClient.layer))
|
||||
|
||||
export const node = LayerNode.make(layer, [AccountRepo.node, httpClient])
|
||||
|
||||
export * as Account from "./account"
|
||||
173
packages/opencode/src/account/repo.ts
Normal file
173
packages/opencode/src/account/repo.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { Effect, Layer, Option, Schema, Context } from "effect"
|
||||
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AccountStateTable, AccountTable } from "@opencode-ai/core/account/sql"
|
||||
import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema"
|
||||
import { normalizeServerUrl } from "./url"
|
||||
|
||||
export type AccountRow = (typeof AccountTable)["$inferSelect"]
|
||||
|
||||
const ACCOUNT_STATE_ID = 1
|
||||
|
||||
export interface Interface {
|
||||
readonly active: () => Effect.Effect<Option.Option<Info>, AccountRepoError>
|
||||
readonly list: () => Effect.Effect<Info[], AccountRepoError>
|
||||
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
|
||||
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
|
||||
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
|
||||
readonly persistToken: (input: {
|
||||
accountID: AccountID
|
||||
accessToken: AccessToken
|
||||
refreshToken: RefreshToken
|
||||
expiry: Option.Option<number>
|
||||
}) => Effect.Effect<void, AccountRepoError>
|
||||
readonly persistAccount: (input: {
|
||||
id: AccountID
|
||||
email: string
|
||||
url: string
|
||||
accessToken: AccessToken
|
||||
refreshToken: RefreshToken
|
||||
expiry: number
|
||||
orgID: Option.Option<OrgID>
|
||||
}) => Effect.Effect<void, AccountRepoError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AccountRepo") {}
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
const query = <A, E>(effect: Effect.Effect<A, E>) =>
|
||||
effect.pipe(Effect.mapError((cause) => new AccountRepoError({ message: "Database operation failed", cause })))
|
||||
|
||||
const current = Effect.fnUntraced(function* () {
|
||||
const state = yield* db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get()
|
||||
if (!state?.active_account_id) return
|
||||
const account = yield* db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get()
|
||||
if (!account) return
|
||||
return { ...account, active_org_id: state.active_org_id ?? null }
|
||||
})
|
||||
|
||||
const state = (accountID: AccountID, orgID: Option.Option<OrgID>) => {
|
||||
const id = Option.getOrNull(orgID)
|
||||
return db
|
||||
.insert(AccountStateTable)
|
||||
.values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: id })
|
||||
.onConflictDoUpdate({
|
||||
target: AccountStateTable.id,
|
||||
set: { active_account_id: accountID, active_org_id: id },
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
const active = Effect.fn("AccountRepo.active")(() =>
|
||||
query(current()).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))),
|
||||
)
|
||||
|
||||
const list = Effect.fn("AccountRepo.list")(() =>
|
||||
query(
|
||||
db
|
||||
.select()
|
||||
.from(AccountTable)
|
||||
.all()
|
||||
.pipe(Effect.map((rows) => rows.map((row: AccountRow) => decode({ ...row, active_org_id: null })))),
|
||||
),
|
||||
)
|
||||
|
||||
const remove = Effect.fn("AccountRepo.remove")((accountID: AccountID) =>
|
||||
query(
|
||||
db.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx
|
||||
.update(AccountStateTable)
|
||||
.set({ active_account_id: null, active_org_id: null })
|
||||
.where(eq(AccountStateTable.active_account_id, accountID))
|
||||
.run()
|
||||
yield* tx.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()
|
||||
}),
|
||||
),
|
||||
).pipe(Effect.asVoid),
|
||||
)
|
||||
|
||||
const use = Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
|
||||
query(state(accountID, orgID)).pipe(Effect.asVoid),
|
||||
)
|
||||
|
||||
const getRow = Effect.fn("AccountRepo.getRow")((accountID: AccountID) =>
|
||||
query(db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
|
||||
Effect.map(Option.fromNullishOr),
|
||||
),
|
||||
)
|
||||
|
||||
const persistToken = Effect.fn("AccountRepo.persistToken")((input) =>
|
||||
query(
|
||||
db
|
||||
.update(AccountTable)
|
||||
.set({
|
||||
access_token: input.accessToken,
|
||||
refresh_token: input.refreshToken,
|
||||
token_expiry: Option.getOrNull(input.expiry),
|
||||
})
|
||||
.where(eq(AccountTable.id, input.accountID))
|
||||
.run(),
|
||||
).pipe(Effect.asVoid),
|
||||
)
|
||||
|
||||
const persistAccount = Effect.fn("AccountRepo.persistAccount")((input) =>
|
||||
query(
|
||||
db.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
const url = normalizeServerUrl(input.url)
|
||||
|
||||
yield* tx
|
||||
.insert(AccountTable)
|
||||
.values({
|
||||
id: input.id,
|
||||
email: input.email,
|
||||
url,
|
||||
access_token: input.accessToken,
|
||||
refresh_token: input.refreshToken,
|
||||
token_expiry: input.expiry,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: AccountTable.id,
|
||||
set: {
|
||||
email: input.email,
|
||||
url,
|
||||
access_token: input.accessToken,
|
||||
refresh_token: input.refreshToken,
|
||||
token_expiry: input.expiry,
|
||||
},
|
||||
})
|
||||
.run()
|
||||
yield* state(input.id, input.orgID)
|
||||
}),
|
||||
),
|
||||
).pipe(Effect.asVoid),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
active,
|
||||
list,
|
||||
remove,
|
||||
use,
|
||||
getRow,
|
||||
persistToken,
|
||||
persistAccount,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||
|
||||
export const node = LayerNode.make(layer, [Database.node])
|
||||
|
||||
export * as AccountRepo from "./repo"
|
||||
99
packages/opencode/src/account/schema.ts
Normal file
99
packages/opencode/src/account/schema.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Schema } from "effect"
|
||||
import type * as HttpClientError from "effect/unstable/http/HttpClientError"
|
||||
|
||||
export const AccountID = Schema.String.pipe(Schema.brand("AccountID"))
|
||||
export type AccountID = Schema.Schema.Type<typeof AccountID>
|
||||
|
||||
export const OrgID = Schema.String.pipe(Schema.brand("OrgID"))
|
||||
export type OrgID = Schema.Schema.Type<typeof OrgID>
|
||||
|
||||
export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken"))
|
||||
export type AccessToken = Schema.Schema.Type<typeof AccessToken>
|
||||
|
||||
export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken"))
|
||||
export type RefreshToken = Schema.Schema.Type<typeof RefreshToken>
|
||||
|
||||
export const DeviceCode = Schema.String.pipe(Schema.brand("DeviceCode"))
|
||||
export type DeviceCode = Schema.Schema.Type<typeof DeviceCode>
|
||||
|
||||
export const UserCode = Schema.String.pipe(Schema.brand("UserCode"))
|
||||
export type UserCode = Schema.Schema.Type<typeof UserCode>
|
||||
|
||||
export class Info extends Schema.Class<Info>("Account")({
|
||||
id: AccountID,
|
||||
email: Schema.String,
|
||||
url: Schema.String,
|
||||
active_org_id: Schema.NullOr(OrgID),
|
||||
}) {}
|
||||
|
||||
export class Org extends Schema.Class<Org>("Org")({
|
||||
id: OrgID,
|
||||
name: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class AccountRepoError extends Schema.TaggedErrorClass<AccountRepoError>()("AccountRepoError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceError>()("AccountServiceError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export class AccountTransportError extends Schema.TaggedErrorClass<AccountTransportError>()("AccountTransportError", {
|
||||
method: Schema.String,
|
||||
url: Schema.String,
|
||||
description: Schema.optional(Schema.String),
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {
|
||||
static fromHttpClientError(error: HttpClientError.TransportError): AccountTransportError {
|
||||
return new AccountTransportError({
|
||||
method: error.request.method,
|
||||
url: error.request.url,
|
||||
description: error.description,
|
||||
cause: error.cause,
|
||||
})
|
||||
}
|
||||
|
||||
override get message(): string {
|
||||
return [
|
||||
`Could not reach ${this.method} ${this.url}.`,
|
||||
`This failed before the server returned an HTTP response.`,
|
||||
this.description,
|
||||
`Check your network, proxy, or VPN configuration and try again.`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
export type AccountError = AccountRepoError | AccountServiceError | AccountTransportError
|
||||
|
||||
export class Login extends Schema.Class<Login>("Login")({
|
||||
code: DeviceCode,
|
||||
user: UserCode,
|
||||
url: Schema.String,
|
||||
server: Schema.String,
|
||||
expiry: Schema.Duration,
|
||||
interval: Schema.Duration,
|
||||
}) {}
|
||||
|
||||
export class PollSuccess extends Schema.TaggedClass<PollSuccess>()("PollSuccess", {
|
||||
email: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class PollPending extends Schema.TaggedClass<PollPending>()("PollPending", {}) {}
|
||||
|
||||
export class PollSlow extends Schema.TaggedClass<PollSlow>()("PollSlow", {}) {}
|
||||
|
||||
export class PollExpired extends Schema.TaggedClass<PollExpired>()("PollExpired", {}) {}
|
||||
|
||||
export class PollDenied extends Schema.TaggedClass<PollDenied>()("PollDenied", {}) {}
|
||||
|
||||
export class PollError extends Schema.TaggedClass<PollError>()("PollError", {
|
||||
cause: Schema.Defect,
|
||||
}) {}
|
||||
|
||||
export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError])
|
||||
export type PollResult = Schema.Schema.Type<typeof PollResult>
|
||||
8
packages/opencode/src/account/url.ts
Normal file
8
packages/opencode/src/account/url.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const normalizeServerUrl = (input: string): string => {
|
||||
const url = new URL(input)
|
||||
url.search = ""
|
||||
url.hash = ""
|
||||
|
||||
const pathname = url.pathname.replace(/\/+$/, "")
|
||||
return pathname.length === 0 ? url.origin : `${url.origin}${pathname}`
|
||||
}
|
||||
95
packages/opencode/src/acp/agent.ts
Normal file
95
packages/opencode/src/acp/agent.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
RequestError,
|
||||
type Agent as ACPAgent,
|
||||
type AgentSideConnection,
|
||||
type AuthenticateRequest,
|
||||
type CancelNotification,
|
||||
type CloseSessionRequest,
|
||||
type ForkSessionRequest,
|
||||
type InitializeRequest,
|
||||
type ListSessionsRequest,
|
||||
type LoadSessionRequest,
|
||||
type NewSessionRequest,
|
||||
type PromptRequest,
|
||||
type ResumeSessionRequest,
|
||||
type SetSessionConfigOptionRequest,
|
||||
type SetSessionModelRequest,
|
||||
type SetSessionModeRequest,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { Effect } from "effect"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import * as ACPError from "./error"
|
||||
import * as ACPService from "./service"
|
||||
|
||||
export function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
|
||||
return {
|
||||
create: (connection: AgentSideConnection) => {
|
||||
return new Agent(ACPService.make({ sdk: _sdk, connection }))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export class Agent implements ACPAgent {
|
||||
constructor(private readonly service: ACPService.Interface) {}
|
||||
|
||||
initialize(params: InitializeRequest) {
|
||||
return run(this.service.initialize(params))
|
||||
}
|
||||
|
||||
authenticate(params: AuthenticateRequest) {
|
||||
return run(this.service.authenticate(params))
|
||||
}
|
||||
|
||||
newSession(params: NewSessionRequest) {
|
||||
return run(this.service.newSession(params))
|
||||
}
|
||||
|
||||
loadSession(params: LoadSessionRequest) {
|
||||
return run(this.service.loadSession(params))
|
||||
}
|
||||
|
||||
listSessions(params: ListSessionsRequest) {
|
||||
return run(this.service.listSessions(params))
|
||||
}
|
||||
|
||||
resumeSession(params: ResumeSessionRequest) {
|
||||
return run(this.service.resumeSession(params))
|
||||
}
|
||||
|
||||
closeSession(params: CloseSessionRequest) {
|
||||
return run(this.service.closeSession(params))
|
||||
}
|
||||
|
||||
unstable_forkSession(params: ForkSessionRequest) {
|
||||
return run(this.service.forkSession(params))
|
||||
}
|
||||
|
||||
setSessionConfigOption(params: SetSessionConfigOptionRequest) {
|
||||
return run(this.service.setSessionConfigOption(params))
|
||||
}
|
||||
|
||||
setSessionMode(params: SetSessionModeRequest) {
|
||||
return run(this.service.setSessionMode(params))
|
||||
}
|
||||
|
||||
unstable_setSessionModel(params: SetSessionModelRequest) {
|
||||
return run(this.service.setSessionModel(params))
|
||||
}
|
||||
|
||||
prompt(params: PromptRequest) {
|
||||
return run(this.service.prompt(params))
|
||||
}
|
||||
|
||||
cancel(params: CancelNotification) {
|
||||
return run(this.service.cancel(params))
|
||||
}
|
||||
}
|
||||
|
||||
function run<A>(effect: Effect.Effect<A, ACPService.Error>) {
|
||||
return Effect.runPromise(effect.pipe(Effect.mapError(ACPError.toRequestError))).catch((defect: unknown) => {
|
||||
if (defect instanceof RequestError) throw defect
|
||||
throw ACPError.toRequestError(ACPError.fromUnknownDefect(defect))
|
||||
})
|
||||
}
|
||||
|
||||
export * as ACP from "./agent"
|
||||
203
packages/opencode/src/acp/config-option.ts
Normal file
203
packages/opencode/src/acp/config-option.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
|
||||
export const DEFAULT_VARIANT_VALUE = "default"
|
||||
|
||||
export type ConfigOptionModel = {
|
||||
id: string
|
||||
name: string
|
||||
variants?: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
export type ConfigOptionProvider = {
|
||||
id: string
|
||||
name: string
|
||||
models: Record<string, ConfigOptionModel>
|
||||
}
|
||||
|
||||
export type ConfigOptionMode = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type ModelSelection = {
|
||||
model: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
variant?: string
|
||||
}
|
||||
|
||||
export function buildModelSelectOption(input: {
|
||||
providers: readonly ConfigOptionProvider[]
|
||||
currentModel: ModelSelection["model"]
|
||||
currentVariant?: string
|
||||
includeVariants?: boolean
|
||||
}): SessionConfigOption {
|
||||
return {
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: formatCurrentModelId({
|
||||
model: input.currentModel,
|
||||
variant: input.currentVariant,
|
||||
variants: variantsForModel(input.providers, input.currentModel),
|
||||
includeVariant: input.includeVariants ?? false,
|
||||
}),
|
||||
options: buildModelSelectOptions(input.providers, { includeVariants: input.includeVariants ?? false }),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildEffortSelectOption(input: {
|
||||
variants: readonly string[]
|
||||
currentVariant?: string
|
||||
}): SessionConfigOption | undefined {
|
||||
if (input.variants.length === 0) return undefined
|
||||
|
||||
return {
|
||||
id: "effort",
|
||||
name: "Effort",
|
||||
description: "Available effort levels for this model",
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue: selectVariant(input.currentVariant, input.variants),
|
||||
options: input.variants.map((variant) => ({
|
||||
value: variant,
|
||||
name: formatVariantName(variant),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildModeSelectOption(input: {
|
||||
modes: readonly ConfigOptionMode[]
|
||||
currentModeId: string
|
||||
}): SessionConfigOption {
|
||||
return {
|
||||
id: "mode",
|
||||
name: "Session Mode",
|
||||
category: "mode",
|
||||
type: "select",
|
||||
currentValue: input.currentModeId,
|
||||
options: input.modes.map((mode) => ({
|
||||
value: mode.id,
|
||||
name: mode.name,
|
||||
...(mode.description ? { description: mode.description } : {}),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildConfigOptions(input: {
|
||||
providers: readonly ConfigOptionProvider[]
|
||||
currentModel: ModelSelection["model"]
|
||||
currentVariant?: string
|
||||
includeModelVariants?: boolean
|
||||
modes?: readonly ConfigOptionMode[]
|
||||
currentModeId?: string
|
||||
}): SessionConfigOption[] {
|
||||
const variants = variantsForModel(input.providers, input.currentModel)
|
||||
const effort = buildEffortSelectOption({ variants, currentVariant: input.currentVariant })
|
||||
|
||||
return [
|
||||
buildModelSelectOption({
|
||||
providers: input.providers,
|
||||
currentModel: input.currentModel,
|
||||
currentVariant: input.currentVariant,
|
||||
includeVariants: input.includeModelVariants ?? false,
|
||||
}),
|
||||
...(effort ? [effort] : []),
|
||||
...(input.modes && input.currentModeId
|
||||
? [buildModeSelectOption({ modes: input.modes, currentModeId: input.currentModeId })]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
|
||||
export function parseModelSelection(modelId: string, providers: readonly ConfigOptionProvider[]): ModelSelection {
|
||||
const provider = providers.find((item) => modelId.startsWith(`${item.id}/`))
|
||||
if (provider) {
|
||||
const modelID = modelId.slice(provider.id.length + 1)
|
||||
if (provider.models[modelID]) {
|
||||
return { model: { providerID: provider.id, modelID } }
|
||||
}
|
||||
|
||||
const separator = modelID.lastIndexOf("/")
|
||||
if (separator > -1) {
|
||||
const baseModelID = modelID.slice(0, separator)
|
||||
const variant = modelID.slice(separator + 1)
|
||||
if (provider.models[baseModelID]?.variants?.[variant]) {
|
||||
return { model: { providerID: provider.id, modelID: baseModelID }, variant }
|
||||
}
|
||||
}
|
||||
|
||||
return { model: { providerID: provider.id, modelID } }
|
||||
}
|
||||
|
||||
const separator = modelId.indexOf("/")
|
||||
if (separator === -1) {
|
||||
return { model: { providerID: modelId, modelID: "" } }
|
||||
}
|
||||
|
||||
return {
|
||||
model: {
|
||||
providerID: modelId.slice(0, separator),
|
||||
modelID: modelId.slice(separator + 1),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCurrentModelId(input: {
|
||||
model: ModelSelection["model"]
|
||||
variant?: string
|
||||
variants?: readonly string[]
|
||||
includeVariant?: boolean
|
||||
}) {
|
||||
const base = `${input.model.providerID}/${input.model.modelID}`
|
||||
if (!input.includeVariant || !input.variants?.length) return base
|
||||
return `${base}/${selectVariant(input.variant, input.variants)}`
|
||||
}
|
||||
|
||||
export function formatVariantName(variant: string) {
|
||||
return variant
|
||||
.split(/[_-]/)
|
||||
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
function buildModelSelectOptions(
|
||||
providers: readonly ConfigOptionProvider[],
|
||||
options: { includeVariants: boolean },
|
||||
): Array<{ value: string; name: string }> {
|
||||
return providers.flatMap((provider) =>
|
||||
Object.values(provider.models)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.flatMap((model) => {
|
||||
const base = {
|
||||
value: `${provider.id}/${model.id}`,
|
||||
name: `${provider.name}/${model.name}`,
|
||||
}
|
||||
if (!options.includeVariants || !model.variants) return [base]
|
||||
|
||||
return [
|
||||
base,
|
||||
...Object.keys(model.variants)
|
||||
.filter((variant) => variant !== DEFAULT_VARIANT_VALUE)
|
||||
.map((variant) => ({
|
||||
value: `${provider.id}/${model.id}/${variant}`,
|
||||
name: `${provider.name}/${model.name} (${formatVariantName(variant)})`,
|
||||
})),
|
||||
]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function variantsForModel(providers: readonly ConfigOptionProvider[], model: ModelSelection["model"]) {
|
||||
return Object.keys(
|
||||
providers.find((provider) => provider.id === model.providerID)?.models[model.modelID]?.variants ?? {},
|
||||
)
|
||||
}
|
||||
|
||||
function selectVariant(variant: string | undefined, variants: readonly string[]) {
|
||||
if (variant && variants.includes(variant)) return variant
|
||||
if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE
|
||||
return variants[0]
|
||||
}
|
||||
250
packages/opencode/src/acp/content.ts
Normal file
250
packages/opencode/src/acp/content.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import type { ContentBlock, ContentChunk, ResourceLink, Role } from "@agentclientprotocol/sdk"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
|
||||
export type PromptPart = SessionV1.TextPartInput | SessionV1.FilePartInput
|
||||
|
||||
export type ReplayPart =
|
||||
| {
|
||||
type: "text"
|
||||
text: string
|
||||
synthetic?: boolean
|
||||
ignored?: boolean
|
||||
}
|
||||
| {
|
||||
type: "file"
|
||||
url: string
|
||||
mime: string
|
||||
filename?: string
|
||||
}
|
||||
| {
|
||||
type: "reasoning"
|
||||
text: string
|
||||
}
|
||||
|
||||
export function promptContentToParts(content: readonly ContentBlock[]): PromptPart[] {
|
||||
return content.flatMap(contentBlockToParts)
|
||||
}
|
||||
|
||||
export function contentBlockToParts(block: ContentBlock): PromptPart[] {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
text: block.text,
|
||||
...audienceFlags(block.annotations?.audience ?? undefined),
|
||||
},
|
||||
]
|
||||
|
||||
case "image":
|
||||
if (block.data) {
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
url: `data:${block.mimeType};base64,${block.data}`,
|
||||
filename: filenameFromUri(block.uri ?? undefined) ?? "image",
|
||||
mime: block.mimeType,
|
||||
},
|
||||
]
|
||||
}
|
||||
if (block.uri?.startsWith("data:")) {
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
url: block.uri,
|
||||
filename: filenameFromUri(block.uri) ?? "image",
|
||||
mime: block.mimeType,
|
||||
},
|
||||
]
|
||||
}
|
||||
if (block.uri?.startsWith("http://") || block.uri?.startsWith("https://")) {
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
url: block.uri,
|
||||
filename: filenameFromUri(block.uri) ?? "image",
|
||||
mime: block.mimeType,
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
|
||||
case "resource_link":
|
||||
return [resourceLinkToPart(block)]
|
||||
|
||||
case "resource":
|
||||
if ("text" in block.resource) {
|
||||
return [{ type: "text", text: block.resource.text }]
|
||||
}
|
||||
if (block.resource.mimeType) {
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
url: block.resource.uri.startsWith("data:")
|
||||
? block.resource.uri
|
||||
: `data:${block.resource.mimeType};base64,${block.resource.blob}`,
|
||||
filename: filenameFromUri(block.resource.uri) ?? "file",
|
||||
mime: block.resource.mimeType,
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function partsToContentChunks(parts: readonly ReplayPart[]): ContentChunk[] {
|
||||
return parts.flatMap(partToContentChunks)
|
||||
}
|
||||
|
||||
export function partToContentChunks(part: ReplayPart): ContentChunk[] {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
if (!part.text) return []
|
||||
return [
|
||||
{
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.text,
|
||||
...partAudience(part),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
case "file":
|
||||
return filePartToContentChunks(part)
|
||||
|
||||
case "reasoning":
|
||||
if (!part.text) return []
|
||||
return [
|
||||
{
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.text,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function resourceLinkToPart(link: ResourceLink): PromptPart {
|
||||
const parsed = uriToFilePart(link.uri, link.mimeType ?? "text/plain", link.name)
|
||||
if (parsed.type === "file") return parsed
|
||||
return { type: "text", text: parsed.text }
|
||||
}
|
||||
|
||||
function uriToFilePart(
|
||||
uri: string,
|
||||
mime: string,
|
||||
filename?: string,
|
||||
): SessionV1.FilePartInput | SessionV1.TextPartInput {
|
||||
try {
|
||||
if (uri.startsWith("file://")) {
|
||||
return {
|
||||
type: "file",
|
||||
url: uri,
|
||||
filename: filename ?? filenameFromUri(uri) ?? "file",
|
||||
mime,
|
||||
}
|
||||
}
|
||||
if (uri.startsWith("zed://")) {
|
||||
const pathname = new URL(uri).searchParams.get("path")
|
||||
if (pathname) {
|
||||
return {
|
||||
type: "file",
|
||||
url: pathToFileURL(pathname).href,
|
||||
filename: filename ?? (path.basename(pathname) || "file"),
|
||||
mime,
|
||||
}
|
||||
}
|
||||
}
|
||||
return { type: "text", text: uri }
|
||||
} catch {
|
||||
return { type: "text", text: uri }
|
||||
}
|
||||
}
|
||||
|
||||
function filePartToContentChunks(part: Extract<ReplayPart, { type: "file" }>): ContentChunk[] {
|
||||
if (part.url.startsWith("file://")) {
|
||||
return [
|
||||
{
|
||||
content: {
|
||||
type: "resource_link",
|
||||
uri: part.url,
|
||||
name: part.filename ?? "file",
|
||||
mimeType: part.mime,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
if (!part.url.startsWith("data:")) return []
|
||||
|
||||
const data = decodeDataUrl(part.url)
|
||||
if (!data) return []
|
||||
if (data.mime.startsWith("image/")) {
|
||||
return [
|
||||
{
|
||||
content: {
|
||||
type: "image",
|
||||
mimeType: data.mime,
|
||||
data: data.base64,
|
||||
uri: pathToFileURL(part.filename ?? "image").href,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
content: {
|
||||
type: "resource",
|
||||
resource:
|
||||
data.mime.startsWith("text/") || data.mime === "application/json"
|
||||
? {
|
||||
uri: pathToFileURL(part.filename ?? "file").href,
|
||||
mimeType: data.mime,
|
||||
text: Buffer.from(data.base64, "base64").toString("utf8"),
|
||||
}
|
||||
: {
|
||||
uri: pathToFileURL(part.filename ?? "file").href,
|
||||
mimeType: data.mime,
|
||||
blob: data.base64,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function decodeDataUrl(url: string) {
|
||||
const match = /^data:([^;]+);base64,(.*)$/.exec(url)
|
||||
if (!match) return
|
||||
return { mime: match[1], base64: match[2] }
|
||||
}
|
||||
|
||||
function audienceFlags(audience: readonly Role[] | null | undefined) {
|
||||
if (audience?.length === 1 && audience[0] === "assistant") return { synthetic: true }
|
||||
if (audience?.length === 1 && audience[0] === "user") return { ignored: true }
|
||||
return {}
|
||||
}
|
||||
|
||||
function partAudience(part: Extract<ReplayPart, { type: "text" }>) {
|
||||
const audience: Role[] | undefined = part.synthetic ? ["assistant"] : part.ignored ? ["user"] : undefined
|
||||
if (!audience) return {}
|
||||
return { annotations: { audience } }
|
||||
}
|
||||
|
||||
function filenameFromUri(uri: string | undefined) {
|
||||
if (!uri) return
|
||||
if (uri.startsWith("data:")) return
|
||||
try {
|
||||
const parsed = new URL(uri)
|
||||
const name = path.basename(parsed.pathname)
|
||||
return name || undefined
|
||||
} catch {
|
||||
return path.basename(uri) || undefined
|
||||
}
|
||||
}
|
||||
210
packages/opencode/src/acp/directory.ts
Normal file
210
packages/opencode/src/acp/directory.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Command } from "@/command"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Context, Effect, Layer, SynchronizedRef } from "effect"
|
||||
import type * as ACPError from "./error"
|
||||
|
||||
export type ModelOption = {
|
||||
readonly providerID: ProviderV2.ID
|
||||
readonly providerName: string
|
||||
readonly modelID: ModelV2.ID
|
||||
readonly modelName: string
|
||||
}
|
||||
|
||||
export type ModeOption = {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
}
|
||||
|
||||
export type ModelVariants = NonNullable<Provider.Model["variants"]>
|
||||
|
||||
export type DefaultModel = {
|
||||
readonly providerID: ProviderV2.ID
|
||||
readonly modelID: ModelV2.ID
|
||||
}
|
||||
|
||||
export type Snapshot = {
|
||||
readonly directory: string
|
||||
readonly providers: Record<ProviderV2.ID, Provider.Info>
|
||||
readonly modelOptions: readonly ModelOption[]
|
||||
readonly variantsByModel: Readonly<Record<string, ModelVariants>>
|
||||
readonly availableModes: readonly ModeOption[]
|
||||
readonly defaultModeID: string
|
||||
readonly availableCommands: readonly Command.Info[]
|
||||
readonly defaultModel?: DefaultModel
|
||||
}
|
||||
|
||||
export interface LoaderInterface {
|
||||
readonly load: (directory: string) => Effect.Effect<Snapshot, ACPError.Error>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (directory: string) => Effect.Effect<Snapshot, ACPError.Error>
|
||||
readonly refresh: (directory: string) => Effect.Effect<Snapshot, ACPError.Error>
|
||||
readonly variants: (snapshot: Snapshot, model: DefaultModel) => ModelVariants | undefined
|
||||
}
|
||||
|
||||
export class Loader extends Context.Service<Loader, LoaderInterface>()("@opencode/ACPDirectoryLoader") {}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ACPDirectory") {}
|
||||
|
||||
export const modelKey = (model: DefaultModel) => `${model.providerID}/${model.modelID}`
|
||||
|
||||
export const variants = (snapshot: Snapshot, model: DefaultModel) => snapshot.variantsByModel[modelKey(model)]
|
||||
|
||||
export const build = (input: {
|
||||
readonly directory: string
|
||||
readonly providers: Record<ProviderV2.ID, Provider.Info>
|
||||
readonly modes: readonly ModeOption[]
|
||||
readonly defaultModeID: string
|
||||
readonly commands: readonly Command.Info[]
|
||||
readonly defaultModel?: DefaultModel
|
||||
}): Snapshot => {
|
||||
const modelOptions = Provider.sort(
|
||||
Object.values(input.providers).flatMap((provider) =>
|
||||
Object.values(provider.models).map((model) => ({
|
||||
id: model.id,
|
||||
providerID: provider.id,
|
||||
providerName: provider.name,
|
||||
modelID: model.id,
|
||||
modelName: model.name,
|
||||
})),
|
||||
),
|
||||
).map((model) => ({
|
||||
providerID: model.providerID,
|
||||
providerName: model.providerName,
|
||||
modelID: model.modelID,
|
||||
modelName: model.modelName,
|
||||
}))
|
||||
|
||||
return {
|
||||
directory: input.directory,
|
||||
providers: input.providers,
|
||||
modelOptions,
|
||||
variantsByModel: Object.fromEntries(
|
||||
Object.values(input.providers).flatMap((provider) =>
|
||||
Object.values(provider.models).flatMap((model) =>
|
||||
model.variants ? [[modelKey({ providerID: provider.id, modelID: model.id }), model.variants]] : [],
|
||||
),
|
||||
),
|
||||
),
|
||||
availableModes: input.modes,
|
||||
defaultModeID: input.modes.some((mode) => mode.id === input.defaultModeID)
|
||||
? input.defaultModeID
|
||||
: (input.modes[0]?.id ?? input.defaultModeID),
|
||||
availableCommands: input.commands,
|
||||
...(input.defaultModel ? { defaultModel: input.defaultModel } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export const loaderLayer = Layer.effect(
|
||||
Loader,
|
||||
Effect.gen(function* () {
|
||||
const store = yield* InstanceStore.Service
|
||||
const provider = yield* Provider.Service
|
||||
const agent = yield* Agent.Service
|
||||
const command = yield* Command.Service
|
||||
|
||||
return Loader.of({
|
||||
load: Effect.fn("ACPDirectoryLoader.load")(function* (directory) {
|
||||
const ctx = yield* store.load({ directory })
|
||||
return yield* Effect.gen(function* () {
|
||||
const providers = yield* provider.list()
|
||||
const [agents, defaultAgent, commands, defaultModel] = yield* Effect.all(
|
||||
[agent.list(), agent.defaultInfo(), command.list(), provider.defaultModel().pipe(Effect.option)],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return build({
|
||||
directory,
|
||||
providers,
|
||||
modes: agents
|
||||
.filter((item) => item.mode !== "subagent" && item.hidden !== true)
|
||||
.map((item) => ({
|
||||
id: item.name,
|
||||
name: item.name,
|
||||
...(item.description ? { description: item.description } : {}),
|
||||
})),
|
||||
defaultModeID: defaultAgent.name,
|
||||
commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||
...(defaultModel._tag === "Some" ? { defaultModel: defaultModel.value } : {}),
|
||||
})
|
||||
}).pipe(Effect.provideService(InstanceRef, ctx))
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const loader = yield* Loader
|
||||
const snapshots = yield* SynchronizedRef.make(new Map<string, Effect.Effect<Snapshot, ACPError.Error>>())
|
||||
|
||||
const cached = Effect.fnUntraced(function* (directory: string) {
|
||||
return yield* SynchronizedRef.modifyEffect(
|
||||
snapshots,
|
||||
Effect.fnUntraced(function* (items) {
|
||||
const current = items.get(directory)
|
||||
if (current) return [current, items] as const
|
||||
const next = yield* Effect.cached(
|
||||
loader.load(directory).pipe(
|
||||
Effect.tapError(() =>
|
||||
SynchronizedRef.update(snapshots, (state) => {
|
||||
const next = new Map(state)
|
||||
next.delete(directory)
|
||||
return next
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return [next, new Map(items).set(directory, next)] as const
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const get = Effect.fn("ACPDirectory.get")(function* (directory: string) {
|
||||
return yield* yield* cached(directory)
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("ACPDirectory.refresh")(function* (directory: string) {
|
||||
return yield* SynchronizedRef.modifyEffect(
|
||||
snapshots,
|
||||
Effect.fnUntraced(function* (items) {
|
||||
const next = yield* Effect.cached(
|
||||
loader.load(directory).pipe(
|
||||
Effect.tapError(() =>
|
||||
SynchronizedRef.update(snapshots, (state) => {
|
||||
const next = new Map(state)
|
||||
next.delete(directory)
|
||||
return next
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return [next, new Map(items).set(directory, next)] as const
|
||||
}),
|
||||
).pipe(Effect.flatten)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
get,
|
||||
refresh,
|
||||
variants,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(loaderLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Command.defaultLayer),
|
||||
Layer.provide(InstanceStore.defaultLayer),
|
||||
)
|
||||
|
||||
export * as Directory from "./directory"
|
||||
90
packages/opencode/src/acp/error.ts
Normal file
90
packages/opencode/src/acp/error.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { RequestError } from "@agentclientprotocol/sdk"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()("ACPSessionNotFoundError", {
|
||||
sessionId: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class InvalidConfigOptionError extends Schema.TaggedErrorClass<InvalidConfigOptionError>()(
|
||||
"ACPInvalidConfigOptionError",
|
||||
{
|
||||
configId: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class InvalidModelError extends Schema.TaggedErrorClass<InvalidModelError>()("ACPInvalidModelError", {
|
||||
modelId: Schema.String,
|
||||
providerId: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export class InvalidEffortError extends Schema.TaggedErrorClass<InvalidEffortError>()("ACPInvalidEffortError", {
|
||||
effort: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class InvalidModeError extends Schema.TaggedErrorClass<InvalidModeError>()("ACPInvalidModeError", {
|
||||
mode: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class AuthRequiredError extends Schema.TaggedErrorClass<AuthRequiredError>()("ACPAuthRequiredError", {
|
||||
providerId: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export class UnknownAuthMethodError extends Schema.TaggedErrorClass<UnknownAuthMethodError>()(
|
||||
"ACPUnknownAuthMethodError",
|
||||
{
|
||||
methodId: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class UnsupportedOperationError extends Schema.TaggedErrorClass<UnsupportedOperationError>()(
|
||||
"ACPUnsupportedOperationError",
|
||||
{
|
||||
method: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPServiceFailureError", {
|
||||
safeMessage: Schema.String,
|
||||
service: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export type Error =
|
||||
| SessionNotFoundError
|
||||
| InvalidConfigOptionError
|
||||
| InvalidModelError
|
||||
| InvalidEffortError
|
||||
| InvalidModeError
|
||||
| AuthRequiredError
|
||||
| UnknownAuthMethodError
|
||||
| UnsupportedOperationError
|
||||
| ServiceFailureError
|
||||
|
||||
export function toRequestError(error: Error) {
|
||||
switch (error._tag) {
|
||||
case "ACPSessionNotFoundError":
|
||||
return RequestError.invalidParams({ sessionId: error.sessionId }, `session not found: ${error.sessionId}`)
|
||||
case "ACPInvalidConfigOptionError":
|
||||
return RequestError.invalidParams({ configId: error.configId }, `unknown config option: ${error.configId}`)
|
||||
case "ACPInvalidModelError":
|
||||
return RequestError.invalidParams(
|
||||
{ providerId: error.providerId, modelId: error.modelId },
|
||||
`model not found: ${error.modelId}`,
|
||||
)
|
||||
case "ACPInvalidEffortError":
|
||||
return RequestError.invalidParams({ effort: error.effort }, `effort not found: ${error.effort}`)
|
||||
case "ACPInvalidModeError":
|
||||
return RequestError.invalidParams({ mode: error.mode }, `mode not found: ${error.mode}`)
|
||||
case "ACPAuthRequiredError":
|
||||
return RequestError.authRequired({ providerId: error.providerId }, "provider authentication required")
|
||||
case "ACPUnknownAuthMethodError":
|
||||
return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`)
|
||||
case "ACPUnsupportedOperationError":
|
||||
return RequestError.methodNotFound(error.method)
|
||||
case "ACPServiceFailureError":
|
||||
return RequestError.internalError({ service: error.service }, error.safeMessage)
|
||||
}
|
||||
}
|
||||
|
||||
export function fromUnknownDefect(_defect: unknown, safeMessage = "Internal service failure") {
|
||||
return new ServiceFailureError({ safeMessage })
|
||||
}
|
||||
336
packages/opencode/src/acp/event.ts
Normal file
336
packages/opencode/src/acp/event.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||
import type {
|
||||
Event,
|
||||
EventMessagePartDelta,
|
||||
EventMessagePartUpdated,
|
||||
OpencodeClient,
|
||||
Part,
|
||||
SessionMessageResponse,
|
||||
ToolPart,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { Effect } from "effect"
|
||||
import { ACPSession } from "./session"
|
||||
import { ACPPermission } from "./permission"
|
||||
import { partsToContentChunks, type ReplayPart } from "./content"
|
||||
import {
|
||||
duplicateRunningToolUpdate,
|
||||
errorToolUpdate,
|
||||
pendingToolCall,
|
||||
runningToolUpdate,
|
||||
shellOutputSnapshot,
|
||||
completedToolUpdate,
|
||||
} from "./tool"
|
||||
|
||||
type Connection = Pick<AgentSideConnection, "sessionUpdate"> &
|
||||
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
|
||||
type GlobalEventEnvelope = {
|
||||
payload?: Event
|
||||
}
|
||||
type GlobalEventStream = {
|
||||
stream: AsyncIterable<GlobalEventEnvelope>
|
||||
}
|
||||
|
||||
export function start(input: { sdk: OpencodeClient; connection: Connection; session: ACPSession.Interface }) {
|
||||
const subscription = new Subscription(input)
|
||||
subscription.start()
|
||||
return subscription
|
||||
}
|
||||
|
||||
export class Subscription {
|
||||
private readonly abort = new AbortController()
|
||||
private readonly shellSnapshots = new Map<string, string>()
|
||||
private readonly toolStarts = new Set<string>()
|
||||
private readonly permission: ACPPermission.Handler
|
||||
private started = false
|
||||
|
||||
constructor(
|
||||
private readonly input: {
|
||||
sdk: OpencodeClient
|
||||
connection: Connection
|
||||
session: ACPSession.Interface
|
||||
},
|
||||
) {
|
||||
this.permission = new ACPPermission.Handler(input)
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.started) return
|
||||
this.started = true
|
||||
this.run().catch(() => {
|
||||
if (this.abort.signal.aborted) return
|
||||
})
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.abort.abort()
|
||||
}
|
||||
|
||||
async handle(event: Event) {
|
||||
switch (event.type) {
|
||||
case "permission.asked":
|
||||
this.permission.handle(event)
|
||||
return
|
||||
case "message.part.updated":
|
||||
return this.handlePartUpdated(event)
|
||||
case "message.part.delta":
|
||||
return this.handlePartDelta(event)
|
||||
}
|
||||
}
|
||||
|
||||
async replayMessage(message: SessionMessageResponse) {
|
||||
if (message.info.role !== "assistant" && message.info.role !== "user") return
|
||||
|
||||
for (const part of message.parts) {
|
||||
await this.recordFetchedPart(message.info.sessionID, message, part)
|
||||
if (part.type === "tool") {
|
||||
await this.handleToolPart(message.info.sessionID, part)
|
||||
continue
|
||||
}
|
||||
await this.replayContentPart(message, part)
|
||||
}
|
||||
}
|
||||
|
||||
private async replayContentPart(message: SessionMessageResponse, part: Part) {
|
||||
if (part.type !== "text" && part.type !== "file" && part.type !== "reasoning") return
|
||||
|
||||
const sessionUpdate =
|
||||
part.type === "reasoning"
|
||||
? "agent_thought_chunk"
|
||||
: message.info.role === "user"
|
||||
? "user_message_chunk"
|
||||
: "agent_message_chunk"
|
||||
|
||||
for (const chunk of partsToContentChunks([part as ReplayPart])) {
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId: message.info.sessionID,
|
||||
update: {
|
||||
sessionUpdate,
|
||||
messageId: message.info.id,
|
||||
...chunk,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async run() {
|
||||
while (!this.abort.signal.aborted) {
|
||||
const events = (await this.input.sdk.global.event({
|
||||
signal: this.abort.signal,
|
||||
})) as GlobalEventStream
|
||||
|
||||
for await (const event of events.stream) {
|
||||
if (this.abort.signal.aborted) return
|
||||
if (!event.payload) continue
|
||||
await this.handle(event.payload).catch(() => {})
|
||||
}
|
||||
if (!this.abort.signal.aborted) await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePartUpdated(event: EventMessagePartUpdated) {
|
||||
const part = event.properties.part
|
||||
const sessionId = part.sessionID || event.properties.sessionID
|
||||
const session = await Effect.runPromise(this.input.session.tryGet(sessionId))
|
||||
if (!session) return
|
||||
|
||||
await Effect.runPromise(
|
||||
this.input.session.recordPartMetadata({
|
||||
sessionId: session.id,
|
||||
messageId: part.messageID,
|
||||
partId: part.id,
|
||||
partType: part.type,
|
||||
role: part.type === "reasoning" ? "assistant" : undefined,
|
||||
ignored: part.type === "text" ? part.ignored : undefined,
|
||||
toolCallId: part.type === "tool" ? part.callID : undefined,
|
||||
metadata: "metadata" in part ? part.metadata : undefined,
|
||||
}),
|
||||
)
|
||||
if (part.type === "tool") {
|
||||
await this.handleToolPart(session.id, part)
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePartDelta(event: EventMessagePartDelta) {
|
||||
const props = event.properties
|
||||
const session = await Effect.runPromise(this.input.session.tryGet(props.sessionID))
|
||||
if (!session) return
|
||||
|
||||
const known = await Effect.runPromise(
|
||||
this.input.session.tryGetPartMetadata({
|
||||
sessionId: session.id,
|
||||
messageId: props.messageID,
|
||||
partId: props.partID,
|
||||
}),
|
||||
)
|
||||
const metadata =
|
||||
known?.role && known.partType
|
||||
? known
|
||||
: await this.fetchPartMetadata(session.id, session.cwd, props.messageID, props.partID)
|
||||
if (metadata?.role !== "assistant") return
|
||||
if (metadata.partType === "text" && props.field === "text" && metadata.ignored !== true) {
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId: session.id,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: props.messageID,
|
||||
content: {
|
||||
type: "text",
|
||||
text: props.delta,
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (metadata.partType === "reasoning" && props.field === "text") {
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId: session.id,
|
||||
update: {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: props.messageID,
|
||||
content: {
|
||||
type: "text",
|
||||
text: props.delta,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchPartMetadata(sessionId: string, cwd: string, messageId: string, partId: string) {
|
||||
const message = await this.input.sdk.session
|
||||
.message(
|
||||
{
|
||||
sessionID: sessionId,
|
||||
messageID: messageId,
|
||||
directory: cwd,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.then((response) => response.data)
|
||||
.catch(() => undefined)
|
||||
if (!message) return
|
||||
|
||||
const part = message.parts.find((item) => item.id === partId)
|
||||
if (!part) return
|
||||
return await this.recordFetchedPart(sessionId, message, part)
|
||||
}
|
||||
|
||||
private async recordFetchedPart(sessionId: string, message: SessionMessageResponse, part: Part) {
|
||||
return await Effect.runPromise(
|
||||
this.input.session.recordPartMetadata({
|
||||
sessionId,
|
||||
messageId: part.messageID,
|
||||
partId: part.id,
|
||||
partType: part.type,
|
||||
role: message.info.role,
|
||||
ignored: part.type === "text" ? part.ignored : undefined,
|
||||
toolCallId: part.type === "tool" ? part.callID : undefined,
|
||||
metadata: "metadata" in part ? part.metadata : undefined,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
private async handleToolPart(sessionId: string, part: ToolPart) {
|
||||
await this.toolStart(sessionId, part)
|
||||
|
||||
switch (part.state.status) {
|
||||
case "pending":
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
return
|
||||
|
||||
case "running":
|
||||
await this.runningTool(sessionId, part)
|
||||
return
|
||||
|
||||
case "completed":
|
||||
this.clearTool(part.callID)
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...completedToolUpdate({
|
||||
toolCallId: part.callID,
|
||||
toolName: part.tool,
|
||||
state: part.state,
|
||||
}),
|
||||
},
|
||||
})
|
||||
return
|
||||
|
||||
case "error":
|
||||
this.clearTool(part.callID)
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: part.callID,
|
||||
toolName: part.tool,
|
||||
state: part.state,
|
||||
}),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private async runningTool(sessionId: string, part: ToolPart) {
|
||||
if (part.state.status !== "running") return
|
||||
|
||||
const output = part.tool === "bash" ? shellOutputSnapshot(part.state) : undefined
|
||||
if (output !== undefined) {
|
||||
if (this.shellSnapshots.get(part.callID) === output) {
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...duplicateRunningToolUpdate({
|
||||
toolCallId: part.callID,
|
||||
toolName: part.tool,
|
||||
state: part.state,
|
||||
}),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
this.shellSnapshots.set(part.callID, output)
|
||||
}
|
||||
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: part.callID,
|
||||
toolName: part.tool,
|
||||
state: part.state,
|
||||
output,
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private async toolStart(sessionId: string, part: ToolPart) {
|
||||
if (this.toolStarts.has(part.callID)) return
|
||||
this.toolStarts.add(part.callID)
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
...pendingToolCall({
|
||||
toolCallId: part.callID,
|
||||
toolName: part.tool,
|
||||
state: part.state,
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private clearTool(toolCallId: string) {
|
||||
this.toolStarts.delete(toolCallId)
|
||||
this.shellSnapshots.delete(toolCallId)
|
||||
}
|
||||
}
|
||||
|
||||
export * as ACPEvent from "./event"
|
||||
124
packages/opencode/src/acp/permission.ts
Normal file
124
packages/opencode/src/acp/permission.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk"
|
||||
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { applyPatch } from "diff"
|
||||
import { exists, readText } from "@/util/filesystem"
|
||||
import type { ACPSession } from "./session"
|
||||
import { toLocations, toToolKind, type ToolInput } from "./tool"
|
||||
import { Effect } from "effect"
|
||||
|
||||
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
|
||||
type Reply = "once" | "always" | "reject"
|
||||
type Connection = Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
|
||||
|
||||
const permissionOptions: PermissionOption[] = [
|
||||
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
||||
{ optionId: "always", kind: "allow_always", name: "Always allow" },
|
||||
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
||||
]
|
||||
|
||||
export class Handler {
|
||||
private readonly queues = new Map<string, Promise<void>>()
|
||||
|
||||
constructor(
|
||||
private readonly input: {
|
||||
sdk: OpencodeClient
|
||||
connection: Connection
|
||||
session: ACPSession.Interface
|
||||
},
|
||||
) {}
|
||||
|
||||
handle(event: PermissionEvent) {
|
||||
const permission = event.properties
|
||||
const previous = this.queues.get(permission.sessionID) ?? Promise.resolve()
|
||||
const next = previous
|
||||
.then(() => this.process(event))
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (this.queues.get(permission.sessionID) === next) {
|
||||
this.queues.delete(permission.sessionID)
|
||||
}
|
||||
})
|
||||
this.queues.set(permission.sessionID, next)
|
||||
}
|
||||
|
||||
private async process(event: PermissionEvent) {
|
||||
const permission = event.properties
|
||||
const session = await Effect.runPromise(this.input.session.tryGet(permission.sessionID))
|
||||
if (!session) return
|
||||
|
||||
if (!this.input.connection.requestPermission) {
|
||||
await this.reply(permission.id, "reject", session.cwd)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await this.input.connection
|
||||
.requestPermission({
|
||||
sessionId: permission.sessionID,
|
||||
toolCall: {
|
||||
toolCallId: permission.tool?.callID ?? permission.id,
|
||||
status: "pending",
|
||||
title: permission.permission,
|
||||
rawInput: permission.metadata,
|
||||
kind: toToolKind(permission.permission),
|
||||
locations: toLocations(permission.permission, permission.metadata),
|
||||
},
|
||||
options: permissionOptions,
|
||||
})
|
||||
.catch(async () => {
|
||||
await this.reply(permission.id, "reject", session.cwd)
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!result) return
|
||||
|
||||
const reply = selectedReply(result)
|
||||
if (reply !== "once" && reply !== "always") {
|
||||
await this.reply(permission.id, "reject", session.cwd)
|
||||
return
|
||||
}
|
||||
|
||||
if (permission.permission === "edit") {
|
||||
await this.writeProposedEdit(session.id, permission.metadata).catch(() => {})
|
||||
}
|
||||
|
||||
await this.reply(permission.id, reply, session.cwd)
|
||||
}
|
||||
|
||||
private async reply(requestID: string, reply: Reply, directory: string) {
|
||||
await this.input.sdk.permission.reply({
|
||||
requestID,
|
||||
reply,
|
||||
directory,
|
||||
})
|
||||
}
|
||||
|
||||
private async writeProposedEdit(sessionId: string, metadata: ToolInput) {
|
||||
const filepath = stringValue(metadata.filepath)
|
||||
const diff = stringValue(metadata.diff)
|
||||
if (!filepath || !diff || !this.input.connection.writeTextFile) return
|
||||
|
||||
const content = (await exists(filepath)) ? await readText(filepath) : ""
|
||||
const next = applyPatch(content, diff)
|
||||
if (next === false) {
|
||||
return
|
||||
}
|
||||
|
||||
void this.input.connection.writeTextFile({
|
||||
sessionId,
|
||||
path: filepath,
|
||||
content: next,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function selectedReply(result: RequestPermissionResponse): Reply {
|
||||
if (result.outcome.outcome !== "selected") return "reject"
|
||||
if (result.outcome.optionId === "once" || result.outcome.optionId === "always") return result.outcome.optionId
|
||||
return "reject"
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
export * as ACPPermission from "./permission"
|
||||
42
packages/opencode/src/acp/profile.ts
Normal file
42
packages/opencode/src/acp/profile.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
const enabled = process.env.OPENCODE_ACP_PROFILE === "1"
|
||||
const started = performance.now()
|
||||
|
||||
export function mark(name: string, fields?: Record<string, string | number | boolean | undefined>) {
|
||||
if (!enabled) return
|
||||
write(`${name}.mark`, performance.now() - started, fields)
|
||||
}
|
||||
|
||||
export function duration(
|
||||
name: string,
|
||||
startedAt: number,
|
||||
fields?: Record<string, string | number | boolean | undefined>,
|
||||
) {
|
||||
if (!enabled) return
|
||||
write(name, performance.now() - startedAt, fields)
|
||||
}
|
||||
|
||||
export async function measure<T>(
|
||||
name: string,
|
||||
fn: () => Promise<T>,
|
||||
fields?: Record<string, string | number | boolean | undefined>,
|
||||
) {
|
||||
if (!enabled) return fn()
|
||||
const start = performance.now()
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
write(name, performance.now() - start, fields)
|
||||
}
|
||||
}
|
||||
|
||||
function write(name: string, durationMs: number, fields?: Record<string, string | number | boolean | undefined>) {
|
||||
const extra = fields
|
||||
? Object.entries(fields)
|
||||
.filter((entry): entry is [string, string | number | boolean] => entry[1] !== undefined)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(" ")
|
||||
: ""
|
||||
console.error(`[acp-profile] ${name} ${Math.round(durationMs)}ms${extra ? ` ${extra}` : ""}`)
|
||||
}
|
||||
|
||||
export * as ACPProfile from "./profile"
|
||||
1048
packages/opencode/src/acp/service.ts
Normal file
1048
packages/opencode/src/acp/service.ts
Normal file
File diff suppressed because it is too large
Load Diff
231
packages/opencode/src/acp/session.ts
Normal file
231
packages/opencode/src/acp/session.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import type { McpServer } from "@agentclientprotocol/sdk"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Context, Effect, Layer, Ref } from "effect"
|
||||
import * as ACPError from "./error"
|
||||
|
||||
export type SelectedModel = {
|
||||
providerID: ProviderV2.ID
|
||||
modelID: ModelV2.ID
|
||||
}
|
||||
|
||||
export type KnownMessagePartMetadata = {
|
||||
messageId: string
|
||||
partId: string
|
||||
partType?: Part["type"]
|
||||
role?: Message["role"]
|
||||
ignored?: boolean
|
||||
toolCallId?: string
|
||||
metadata?: unknown
|
||||
}
|
||||
|
||||
export type Info = {
|
||||
id: string
|
||||
cwd: string
|
||||
mcpServers: readonly McpServer[]
|
||||
createdAt: Date
|
||||
model?: SelectedModel
|
||||
variant?: string
|
||||
modeId?: string
|
||||
knownParts: ReadonlyMap<string, KnownMessagePartMetadata>
|
||||
}
|
||||
|
||||
export type StoreInput = {
|
||||
id: string
|
||||
cwd: string
|
||||
mcpServers?: readonly McpServer[]
|
||||
createdAt?: Date
|
||||
model?: SelectedModel
|
||||
variant?: string
|
||||
modeId?: string
|
||||
}
|
||||
|
||||
export type RecordPartMetadataInput = {
|
||||
sessionId: string
|
||||
messageId: string
|
||||
partId: string
|
||||
partType?: Part["type"]
|
||||
role?: Message["role"]
|
||||
ignored?: boolean
|
||||
toolCallId?: string
|
||||
metadata?: unknown
|
||||
}
|
||||
|
||||
export type PartMetadataLookupInput = {
|
||||
sessionId: string
|
||||
messageId: string
|
||||
partId: string
|
||||
}
|
||||
|
||||
export type Interface = {
|
||||
readonly create: (input: StoreInput) => Effect.Effect<Info>
|
||||
readonly load: (input: StoreInput) => Effect.Effect<Info>
|
||||
readonly list: (cwd?: string) => Effect.Effect<readonly Info[]>
|
||||
readonly get: (sessionId: string) => Effect.Effect<Info, ACPError.SessionNotFoundError>
|
||||
readonly tryGet: (sessionId: string) => Effect.Effect<Info | undefined>
|
||||
readonly remove: (sessionId: string) => Effect.Effect<Info | undefined>
|
||||
readonly setModel: (
|
||||
sessionId: string,
|
||||
model: SelectedModel | undefined,
|
||||
) => Effect.Effect<Info, ACPError.SessionNotFoundError>
|
||||
readonly getModel: (sessionId: string) => Effect.Effect<SelectedModel | undefined, ACPError.SessionNotFoundError>
|
||||
readonly setVariant: (
|
||||
sessionId: string,
|
||||
variant: string | undefined,
|
||||
) => Effect.Effect<Info, ACPError.SessionNotFoundError>
|
||||
readonly getVariant: (sessionId: string) => Effect.Effect<string | undefined, ACPError.SessionNotFoundError>
|
||||
readonly setMode: (
|
||||
sessionId: string,
|
||||
modeId: string | undefined,
|
||||
) => Effect.Effect<Info, ACPError.SessionNotFoundError>
|
||||
readonly getMode: (sessionId: string) => Effect.Effect<string | undefined, ACPError.SessionNotFoundError>
|
||||
readonly recordPartMetadata: (
|
||||
input: RecordPartMetadataInput,
|
||||
) => Effect.Effect<KnownMessagePartMetadata, ACPError.SessionNotFoundError>
|
||||
readonly getPartMetadata: (
|
||||
input: PartMetadataLookupInput,
|
||||
) => Effect.Effect<KnownMessagePartMetadata | undefined, ACPError.SessionNotFoundError>
|
||||
readonly tryGetPartMetadata: (input: PartMetadataLookupInput) => Effect.Effect<KnownMessagePartMetadata | undefined>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ACP/Session") {}
|
||||
|
||||
type State = Map<string, Info>
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Ref.make<State>(new Map())
|
||||
|
||||
const store = Effect.fn("ACP.Session.store")(function* (input: StoreInput) {
|
||||
const session = makeSession(input)
|
||||
yield* Ref.update(sessions, (state) => new Map(state).set(session.id, session))
|
||||
return snapshot(session)
|
||||
})
|
||||
|
||||
const tryGet = Effect.fn("ACP.Session.tryGet")(function* (sessionId: string) {
|
||||
const session = (yield* Ref.get(sessions)).get(sessionId)
|
||||
if (!session) return
|
||||
return snapshot(session)
|
||||
})
|
||||
|
||||
const get = Effect.fn("ACP.Session.get")(function* (sessionId: string) {
|
||||
const session = yield* tryGet(sessionId)
|
||||
if (session) return session
|
||||
return yield* new ACPError.SessionNotFoundError({ sessionId })
|
||||
})
|
||||
|
||||
const update = Effect.fn("ACP.Session.update")(function* (sessionId: string, fn: (session: Info) => Info) {
|
||||
const result = yield* Ref.modify(sessions, (state) => {
|
||||
const session = state.get(sessionId)
|
||||
if (!session) return [undefined, state] as const
|
||||
const next = fn(session)
|
||||
return [snapshot(next), new Map(state).set(sessionId, next)] as const
|
||||
})
|
||||
if (result) return result
|
||||
return yield* new ACPError.SessionNotFoundError({ sessionId })
|
||||
})
|
||||
|
||||
const remove = Effect.fn("ACP.Session.remove")(function* (sessionId: string) {
|
||||
return yield* Ref.modify(sessions, (state) => {
|
||||
const session = state.get(sessionId)
|
||||
if (!session) return [undefined, state] as const
|
||||
const next = new Map(state)
|
||||
next.delete(sessionId)
|
||||
return [snapshot(session), next] as const
|
||||
})
|
||||
})
|
||||
|
||||
const setModel: Interface["setModel"] = Effect.fn("ACP.Session.setModel")((sessionId, model) =>
|
||||
update(sessionId, (session) => ({ ...session, model })),
|
||||
)
|
||||
|
||||
const setVariant: Interface["setVariant"] = Effect.fn("ACP.Session.setVariant")((sessionId, variant) =>
|
||||
update(sessionId, (session) => ({ ...session, variant })),
|
||||
)
|
||||
|
||||
const setMode: Interface["setMode"] = Effect.fn("ACP.Session.setMode")((sessionId, modeId) =>
|
||||
update(sessionId, (session) => ({ ...session, modeId })),
|
||||
)
|
||||
|
||||
const recordPartMetadata: Interface["recordPartMetadata"] = Effect.fn("ACP.Session.recordPartMetadata")((input) => {
|
||||
const metadata = {
|
||||
messageId: input.messageId,
|
||||
partId: input.partId,
|
||||
partType: input.partType,
|
||||
role: input.role,
|
||||
ignored: input.ignored,
|
||||
toolCallId: input.toolCallId,
|
||||
metadata: input.metadata,
|
||||
}
|
||||
return update(input.sessionId, (session) => ({
|
||||
...session,
|
||||
knownParts: new Map(session.knownParts).set(partMetadataKey(input), metadata),
|
||||
})).pipe(Effect.as(metadata))
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
create: store,
|
||||
load: store,
|
||||
list: Effect.fn("ACP.Session.list")(function* (cwd?: string) {
|
||||
return [...(yield* Ref.get(sessions)).values()]
|
||||
.filter((session) => !cwd || session.cwd === cwd)
|
||||
.map(snapshot)
|
||||
.toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
}),
|
||||
get,
|
||||
tryGet,
|
||||
remove,
|
||||
setModel,
|
||||
getModel: Effect.fn("ACP.Session.getModel")(function* (sessionId) {
|
||||
return (yield* get(sessionId)).model
|
||||
}),
|
||||
setVariant,
|
||||
getVariant: Effect.fn("ACP.Session.getVariant")(function* (sessionId) {
|
||||
return (yield* get(sessionId)).variant
|
||||
}),
|
||||
setMode,
|
||||
getMode: Effect.fn("ACP.Session.getMode")(function* (sessionId) {
|
||||
return (yield* get(sessionId)).modeId
|
||||
}),
|
||||
recordPartMetadata,
|
||||
getPartMetadata: Effect.fn("ACP.Session.getPartMetadata")(function* (input) {
|
||||
return (yield* get(input.sessionId)).knownParts.get(partMetadataKey(input))
|
||||
}),
|
||||
tryGetPartMetadata: Effect.fn("ACP.Session.tryGetPartMetadata")(function* (input) {
|
||||
return (yield* tryGet(input.sessionId))?.knownParts.get(partMetadataKey(input))
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
function makeSession(input: StoreInput): Info {
|
||||
return {
|
||||
id: input.id,
|
||||
cwd: input.cwd,
|
||||
mcpServers: [...(input.mcpServers ?? [])],
|
||||
createdAt: input.createdAt ? new Date(input.createdAt) : new Date(),
|
||||
model: input.model,
|
||||
variant: input.variant,
|
||||
modeId: input.modeId,
|
||||
knownParts: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(session: Info): Info {
|
||||
return {
|
||||
...session,
|
||||
mcpServers: [...session.mcpServers],
|
||||
createdAt: new Date(session.createdAt),
|
||||
knownParts: new Map(session.knownParts),
|
||||
}
|
||||
}
|
||||
|
||||
function partMetadataKey(input: { messageId: string; partId: string }) {
|
||||
return `${input.messageId}:${input.partId}`
|
||||
}
|
||||
|
||||
export * as ACPSession from "./session"
|
||||
321
packages/opencode/src/acp/tool.ts
Normal file
321
packages/opencode/src/acp/tool.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
|
||||
|
||||
export type ToolInput = Record<string, unknown>
|
||||
|
||||
export type ToolAttachment = {
|
||||
readonly mime?: string
|
||||
readonly url?: string
|
||||
readonly [key: string]: unknown
|
||||
}
|
||||
|
||||
export type CompletedToolState = {
|
||||
readonly status: "completed"
|
||||
readonly input: ToolInput
|
||||
readonly output: string
|
||||
readonly metadata?: unknown
|
||||
readonly attachments?: ReadonlyArray<ToolAttachment>
|
||||
}
|
||||
|
||||
export type RunningToolState = {
|
||||
readonly status: "running"
|
||||
readonly input: ToolInput
|
||||
readonly title?: string
|
||||
}
|
||||
|
||||
export type ErrorToolState = {
|
||||
readonly status: "error"
|
||||
readonly input: ToolInput
|
||||
readonly error: string
|
||||
readonly metadata?: unknown
|
||||
}
|
||||
|
||||
export type ImageAttachment = {
|
||||
readonly mimeType: string
|
||||
readonly data: string
|
||||
}
|
||||
|
||||
export function toToolKind(toolName: string): ToolKind {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
|
||||
switch (tool) {
|
||||
case "bash":
|
||||
case "shell":
|
||||
return "execute"
|
||||
|
||||
case "webfetch":
|
||||
return "fetch"
|
||||
|
||||
case "edit":
|
||||
case "apply_patch":
|
||||
case "patch":
|
||||
case "write":
|
||||
return "edit"
|
||||
|
||||
case "grep":
|
||||
case "glob":
|
||||
case "context":
|
||||
case "context7_resolve_library_id":
|
||||
case "context7_get_library_docs":
|
||||
return "search"
|
||||
|
||||
case "read":
|
||||
return "read"
|
||||
|
||||
case "task":
|
||||
return "think"
|
||||
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
|
||||
export function toLocations(toolName: string, input: ToolInput): ToolCallLocation[] {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
|
||||
switch (tool) {
|
||||
case "read":
|
||||
case "edit":
|
||||
case "write":
|
||||
return locationFrom(input.filePath ?? input.filepath)
|
||||
|
||||
case "external_directory":
|
||||
return locationFrom(input.filePath ?? input.filepath, input.parentDir, input.directories)
|
||||
|
||||
case "grep":
|
||||
case "glob":
|
||||
case "context":
|
||||
case "context7_resolve_library_id":
|
||||
case "context7_get_library_docs":
|
||||
return locationFrom(input.path)
|
||||
|
||||
case "bash":
|
||||
case "shell":
|
||||
return []
|
||||
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function completedToolContent(toolName: string, state: CompletedToolState): ToolCallContent[] {
|
||||
const text =
|
||||
toolName.toLocaleLowerCase() === "read" ? (readDisplayText(state.metadata) ?? state.output) : state.output
|
||||
const content: ToolCallContent[] = [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (toToolKind(toolName) === "edit") {
|
||||
content.push(...diffContent(state.input))
|
||||
}
|
||||
|
||||
content.push(...imageContents(state.attachments ?? []))
|
||||
return content
|
||||
}
|
||||
|
||||
export function pendingToolCall(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly state: { readonly input: ToolInput; readonly title?: string }
|
||||
}): ToolCall {
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
title: input.state.title || input.toolName,
|
||||
kind: toToolKind(input.toolName),
|
||||
status: "pending",
|
||||
locations: toLocations(input.toolName, input.state.input),
|
||||
rawInput: input.state.input,
|
||||
}
|
||||
}
|
||||
|
||||
export function runningToolUpdate(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly state: RunningToolState
|
||||
readonly output?: string
|
||||
}): ToolCallUpdate {
|
||||
const content = input.output
|
||||
? [
|
||||
{
|
||||
type: "content" as const,
|
||||
content: {
|
||||
type: "text" as const,
|
||||
text: input.output,
|
||||
},
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
status: "in_progress",
|
||||
kind: toToolKind(input.toolName),
|
||||
title: input.state.title ?? input.toolName,
|
||||
locations: toLocations(input.toolName, input.state.input),
|
||||
rawInput: input.state.input,
|
||||
...(content ? { content } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function duplicateRunningToolUpdate(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly state: RunningToolState
|
||||
}): ToolCallUpdate {
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
status: "in_progress",
|
||||
kind: toToolKind(input.toolName),
|
||||
title: input.state.title ?? input.toolName,
|
||||
locations: toLocations(input.toolName, input.state.input),
|
||||
rawInput: input.state.input,
|
||||
}
|
||||
}
|
||||
|
||||
export function completedToolUpdate(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly state: CompletedToolState & { readonly title: string }
|
||||
}): ToolCallUpdate {
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
status: "completed",
|
||||
kind: toToolKind(input.toolName),
|
||||
title: input.state.title,
|
||||
content: completedToolContent(input.toolName, input.state),
|
||||
rawInput: input.state.input,
|
||||
rawOutput: completedToolRawOutput(input.state),
|
||||
}
|
||||
}
|
||||
|
||||
export function errorToolUpdate(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly state: ErrorToolState
|
||||
}): ToolCallUpdate {
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
status: "failed",
|
||||
kind: toToolKind(input.toolName),
|
||||
title: input.toolName,
|
||||
rawInput: input.state.input,
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: input.state.error,
|
||||
},
|
||||
},
|
||||
],
|
||||
rawOutput: {
|
||||
error: input.state.error,
|
||||
metadata: input.state.metadata,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function completedToolRawOutput(state: CompletedToolState) {
|
||||
return {
|
||||
output: state.output,
|
||||
...(state.metadata !== undefined ? { metadata: state.metadata } : {}),
|
||||
...(state.attachments?.length ? { attachments: state.attachments } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function imageContents(attachments: ReadonlyArray<ToolAttachment>): ToolCallContent[] {
|
||||
return extractImageAttachments(attachments).map((attachment): ToolCallContent => {
|
||||
return {
|
||||
type: "content",
|
||||
content: {
|
||||
type: "image",
|
||||
mimeType: attachment.mimeType,
|
||||
data: attachment.data,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function extractImageAttachments(attachments: ReadonlyArray<ToolAttachment>): ImageAttachment[] {
|
||||
return attachments.flatMap((attachment): ImageAttachment[] => {
|
||||
const data = dataUrlImage(attachment)
|
||||
return data ? [data] : []
|
||||
})
|
||||
}
|
||||
|
||||
export function shellOutputSnapshot(state: { readonly metadata?: unknown }) {
|
||||
if (!state.metadata || typeof state.metadata !== "object") return undefined
|
||||
return stringValue((state.metadata as Record<string, unknown>).output)
|
||||
}
|
||||
|
||||
export const mapToolKind = toToolKind
|
||||
export const extractLocations = toLocations
|
||||
export const buildCompletedToolContent = completedToolContent
|
||||
export const buildCompletedRawOutput = completedToolRawOutput
|
||||
export const extractShellOutputSnapshot = shellOutputSnapshot
|
||||
export const buildPendingToolCall = pendingToolCall
|
||||
export const buildRunningToolUpdate = runningToolUpdate
|
||||
export const buildDuplicateRunningToolUpdate = duplicateRunningToolUpdate
|
||||
export const buildCompletedToolUpdate = completedToolUpdate
|
||||
export const buildErrorToolUpdate = errorToolUpdate
|
||||
|
||||
function locationFrom(...values: unknown[]): ToolCallLocation[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
values.flatMap((value): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((item): item is string => typeof item === "string" && item.length > 0)
|
||||
}
|
||||
const path = stringValue(value)
|
||||
return path ? [path] : []
|
||||
}),
|
||||
),
|
||||
(path) => ({ path }),
|
||||
)
|
||||
}
|
||||
|
||||
function diffContent(input: ToolInput): ToolCallContent[] {
|
||||
const oldText = stringValue(input.oldString)
|
||||
const newText = stringValue(input.newString) ?? stringValue(input.content)
|
||||
if (oldText === undefined || newText === undefined) return []
|
||||
|
||||
return [
|
||||
{
|
||||
type: "diff",
|
||||
path: stringValue(input.filePath) ?? "",
|
||||
oldText,
|
||||
newText,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function readDisplayText(metadata: unknown) {
|
||||
if (!metadata || typeof metadata !== "object") return undefined
|
||||
const display = (metadata as Record<string, unknown>).display
|
||||
if (!display || typeof display !== "object") return undefined
|
||||
const info = display as Record<string, unknown>
|
||||
if (info.type === "file") return stringValue(info.text)
|
||||
if (info.type === "directory" && Array.isArray(info.entries)) {
|
||||
return info.entries.filter((item): item is string => typeof item === "string").join("\n")
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function dataUrlImage(attachment: ToolAttachment) {
|
||||
const match = stringValue(attachment.url)?.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/)
|
||||
const mime = match?.[1] ?? stringValue(attachment.mime)
|
||||
if (!mime?.startsWith("image/")) return undefined
|
||||
|
||||
const data = match?.[2]
|
||||
if (data === undefined) return undefined
|
||||
return { mimeType: mime, data }
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
232
packages/opencode/src/acp/usage.ts
Normal file
232
packages/opencode/src/acp/usage.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import type { AgentSideConnection, Usage } from "@agentclientprotocol/sdk"
|
||||
import type { AssistantMessage as OpenCodeAssistantMessage, Message } from "@opencode-ai/sdk/v2"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Context, Effect, Layer, SynchronizedRef } from "effect"
|
||||
|
||||
export type AssistantTokenCost = Pick<OpenCodeAssistantMessage, "cost" | "tokens">
|
||||
|
||||
export type AssistantMessage = AssistantTokenCost &
|
||||
Pick<OpenCodeAssistantMessage, "role"> &
|
||||
Partial<Pick<OpenCodeAssistantMessage, "providerID" | "modelID">>
|
||||
|
||||
export type SessionMessage = {
|
||||
readonly info: { readonly role: Message["role"] } | AssistantMessage
|
||||
}
|
||||
|
||||
export type MessagesInput = {
|
||||
readonly sessionID: string
|
||||
readonly directory: string
|
||||
}
|
||||
|
||||
export type SDK = {
|
||||
readonly session: {
|
||||
readonly messages: (
|
||||
parameters: { readonly sessionID: string; readonly directory: string },
|
||||
options: { readonly throwOnError: true },
|
||||
) => Promise<{ readonly data?: readonly SessionMessage[] | null }>
|
||||
}
|
||||
}
|
||||
|
||||
export interface MessageLoaderInterface {
|
||||
readonly messages: (input: MessagesInput) => Effect.Effect<readonly SessionMessage[], unknown>
|
||||
}
|
||||
|
||||
export interface ContextLimitLoaderInterface {
|
||||
readonly providers: (directory: string) => Effect.Effect<Record<ProviderV2.ID, Provider.Info>, unknown>
|
||||
}
|
||||
|
||||
export type UsageConnection = Pick<AgentSideConnection, "sessionUpdate">
|
||||
|
||||
export interface Interface {
|
||||
readonly buildUsage: (message: AssistantTokenCost) => Usage
|
||||
readonly latestAssistantMessage: (messages: readonly SessionMessage[]) => AssistantMessage | undefined
|
||||
readonly totalSessionCost: (messages: readonly SessionMessage[]) => number
|
||||
readonly contextLimit: (input: {
|
||||
readonly directory: string
|
||||
readonly providerID: ProviderV2.ID
|
||||
readonly modelID: ModelV2.ID
|
||||
}) => Effect.Effect<number | undefined>
|
||||
readonly sendUpdate: (input: {
|
||||
readonly connection: UsageConnection
|
||||
readonly sessionID: string
|
||||
readonly directory: string
|
||||
}) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class MessageLoader extends Context.Service<MessageLoader, MessageLoaderInterface>()(
|
||||
"@opencode/ACPUsageMessageLoader",
|
||||
) {}
|
||||
|
||||
export class ContextLimitLoader extends Context.Service<ContextLimitLoader, ContextLimitLoaderInterface>()(
|
||||
"@opencode/ACPUsageContextLimitLoader",
|
||||
) {}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ACPUsage") {}
|
||||
|
||||
export function messageLoaderFromSDK(sdk: SDK): MessageLoaderInterface {
|
||||
return MessageLoader.of({
|
||||
messages: (input) =>
|
||||
Effect.promise(() =>
|
||||
sdk.session
|
||||
.messages({ sessionID: input.sessionID, directory: input.directory }, { throwOnError: true })
|
||||
.then((response) => response.data ?? []),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
export const messageLoaderLayer = (sdk: SDK) => Layer.succeed(MessageLoader, messageLoaderFromSDK(sdk))
|
||||
|
||||
export function buildUsage(message: AssistantTokenCost): Usage {
|
||||
const cachedReadTokens = message.tokens.cache.read
|
||||
const cachedWriteTokens = message.tokens.cache.write
|
||||
const thoughtTokens = message.tokens.reasoning
|
||||
|
||||
return {
|
||||
inputTokens: message.tokens.input,
|
||||
outputTokens: message.tokens.output,
|
||||
totalTokens: message.tokens.input + message.tokens.output + thoughtTokens + cachedReadTokens + cachedWriteTokens,
|
||||
...(thoughtTokens > 0 ? { thoughtTokens } : {}),
|
||||
...(cachedReadTokens > 0 ? { cachedReadTokens } : {}),
|
||||
...(cachedWriteTokens > 0 ? { cachedWriteTokens } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function latestAssistantMessage(messages: readonly SessionMessage[]): AssistantMessage | undefined {
|
||||
return messages
|
||||
.filter((message): message is { readonly info: AssistantMessage } => message.info.role === "assistant")
|
||||
.at(-1)?.info
|
||||
}
|
||||
|
||||
export function totalSessionCost(messages: readonly SessionMessage[]): number {
|
||||
return messages
|
||||
.filter((message): message is { readonly info: AssistantMessage } => message.info.role === "assistant")
|
||||
.reduce((sum, message) => sum + message.info.cost, 0)
|
||||
}
|
||||
|
||||
export function findContextLimit(
|
||||
providers: Record<ProviderV2.ID, Provider.Info>,
|
||||
providerID: ProviderV2.ID,
|
||||
modelID: ModelV2.ID,
|
||||
): number | undefined {
|
||||
return providers[providerID]?.models[modelID]?.limit.context
|
||||
}
|
||||
|
||||
export const contextLimitLoaderLayer = Layer.effect(
|
||||
ContextLimitLoader,
|
||||
Effect.gen(function* () {
|
||||
const store = yield* InstanceStore.Service
|
||||
const provider = yield* Provider.Service
|
||||
|
||||
return ContextLimitLoader.of({
|
||||
providers: Effect.fn("ACPUsageContextLimitLoader.providers")(function* (directory) {
|
||||
const ctx = yield* store.load({ directory })
|
||||
return yield* Effect.gen(function* () {
|
||||
return yield* provider.list()
|
||||
}).pipe(Effect.provideService(InstanceRef, ctx))
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const messageLoader = yield* MessageLoader
|
||||
const contextLimitLoader = yield* ContextLimitLoader
|
||||
const limits = yield* SynchronizedRef.make(new Map<string, Effect.Effect<number | undefined>>())
|
||||
|
||||
const cachedLimit = Effect.fnUntraced(function* (input: {
|
||||
readonly directory: string
|
||||
readonly providerID: ProviderV2.ID
|
||||
readonly modelID: ModelV2.ID
|
||||
}) {
|
||||
return yield* SynchronizedRef.modifyEffect(
|
||||
limits,
|
||||
Effect.fnUntraced(function* (items) {
|
||||
const key = `${input.directory}\u0000${input.providerID}\u0000${input.modelID}`
|
||||
const current = items.get(key)
|
||||
if (current) return [current, items] as const
|
||||
const next = yield* Effect.cached(
|
||||
contextLimitLoader.providers(input.directory).pipe(
|
||||
Effect.map((providers) => findContextLimit(providers, input.providerID, input.modelID)),
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("failed to get providers for usage context limit", { error: error }).pipe(
|
||||
Effect.as(undefined),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
return [next, new Map(items).set(key, next)] as const
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const contextLimit = Effect.fn("ACPUsage.contextLimit")(function* (input: {
|
||||
readonly directory: string
|
||||
readonly providerID: ProviderV2.ID
|
||||
readonly modelID: ModelV2.ID
|
||||
}) {
|
||||
return yield* yield* cachedLimit(input)
|
||||
})
|
||||
|
||||
const sendUpdate = Effect.fn("ACPUsage.sendUpdate")(function* (input: {
|
||||
readonly connection: UsageConnection
|
||||
readonly sessionID: string
|
||||
readonly directory: string
|
||||
}) {
|
||||
const messages = yield* messageLoader
|
||||
.messages({ sessionID: input.sessionID, directory: input.directory })
|
||||
.pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("failed to fetch messages for usage update", { error: error }).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
if (!messages) return
|
||||
|
||||
const message = latestAssistantMessage(messages)
|
||||
if (!message) return
|
||||
if (!message.providerID || !message.modelID) return
|
||||
|
||||
const size = yield* contextLimit({
|
||||
directory: input.directory,
|
||||
providerID: ProviderV2.ID.make(message.providerID),
|
||||
modelID: ModelV2.ID.make(message.modelID),
|
||||
})
|
||||
if (!size) return
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
input.connection
|
||||
.sessionUpdate({
|
||||
sessionId: input.sessionID,
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used: message.tokens.input + message.tokens.cache.read,
|
||||
size,
|
||||
cost: { amount: totalSessionCost(messages), currency: "USD" },
|
||||
},
|
||||
})
|
||||
.catch(() => {}),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
buildUsage,
|
||||
latestAssistantMessage,
|
||||
totalSessionCost,
|
||||
contextLimit,
|
||||
sendUpdate,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(contextLimitLoaderLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(InstanceStore.defaultLayer),
|
||||
)
|
||||
|
||||
export * as UsageService from "./usage"
|
||||
541
packages/opencode/src/agent/agent.ts
Normal file
541
packages/opencode/src/agent/agent.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||
import { Config } from "@/config/config"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { Provider } from "@/provider/provider"
|
||||
|
||||
import { generateObject, streamObject, type ModelMessage } from "ai"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { Auth } from "../auth"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
|
||||
import PROMPT_GENERATE from "./generate.txt"
|
||||
import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
||||
import PROMPT_EXPLORE from "./prompt/explore.txt"
|
||||
import PROMPT_SUMMARY from "./prompt/summary.txt"
|
||||
import PROMPT_TITLE from "./prompt/title.txt"
|
||||
import PROMPT_AIRCODING_MAIN from "./prompt/main.txt"
|
||||
import PROMPT_SCHEDULER from "./prompt/scheduler.txt"
|
||||
import PROMPT_WORKER from "./prompt/worker.txt"
|
||||
import PROMPT_ARCHITECT from "./prompt/architect.txt"
|
||||
import PROMPT_REVIEWER from "./prompt/reviewer.txt"
|
||||
import { Permission } from "@/permission"
|
||||
import { mergeDeep, pipe, sortBy, values } from "remeda"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import path from "path"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Skill } from "../skill"
|
||||
import { Effect, Context, Layer, Schema } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import * as Option from "effect/Option"
|
||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||
import { AbsolutePath, type DeepMutable } from "@opencode-ai/core/schema"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.optional(Schema.String),
|
||||
mode: Schema.Literals(["subagent", "primary", "all"]),
|
||||
native: Schema.optional(Schema.Boolean),
|
||||
hidden: Schema.optional(Schema.Boolean),
|
||||
topP: Schema.optional(Schema.Finite),
|
||||
temperature: Schema.optional(Schema.Finite),
|
||||
color: Schema.optional(Schema.String),
|
||||
permission: PermissionV1.Ruleset,
|
||||
model: Schema.optional(
|
||||
Schema.Struct({
|
||||
modelID: ModelV2.ID,
|
||||
providerID: ProviderV2.ID,
|
||||
}),
|
||||
),
|
||||
variant: Schema.optional(Schema.String),
|
||||
prompt: Schema.optional(Schema.String),
|
||||
options: Schema.Record(Schema.String, Schema.Unknown),
|
||||
steps: Schema.optional(Schema.Finite),
|
||||
}).annotate({ identifier: "Agent" })
|
||||
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
|
||||
|
||||
const GeneratedAgent = Schema.Struct({
|
||||
identifier: Schema.String,
|
||||
whenToUse: Schema.String,
|
||||
systemPrompt: Schema.String,
|
||||
})
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (agent: string) => Effect.Effect<Info>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly defaultInfo: () => Effect.Effect<Info>
|
||||
readonly defaultAgent: () => Effect.Effect<string>
|
||||
readonly generate: (input: {
|
||||
description: string
|
||||
model?: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
|
||||
}) => Effect.Effect<
|
||||
{
|
||||
identifier: string
|
||||
whenToUse: string
|
||||
systemPrompt: string
|
||||
},
|
||||
Provider.DefaultModelError
|
||||
>
|
||||
}
|
||||
|
||||
type State = Omit<Interface, "generate">
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
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
|
||||
const locations = yield* LocationServiceMap
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Agent.state")(function* (ctx) {
|
||||
const cfg = yield* config.get()
|
||||
const skillDirs = yield* skill.dirs()
|
||||
const referenceDirs = yield* Effect.gen(function* () {
|
||||
yield* (yield* PluginBoot.Service).wait()
|
||||
return (yield* (yield* Reference.Service).list()).map((reference) => reference.path)
|
||||
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) }))))
|
||||
const whitelistedDirs = [
|
||||
Truncate.GLOB,
|
||||
path.join(Global.Path.tmp, "*"),
|
||||
...skillDirs.map((dir) => path.join(dir, "*")),
|
||||
...referenceDirs.map((dir) => path.join(dir, "*")),
|
||||
]
|
||||
const readonlyExternalDirectory = {
|
||||
"*": "ask",
|
||||
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
|
||||
} satisfies Record<string, "allow" | "ask" | "deny">
|
||||
|
||||
const defaults = Permission.fromConfig({
|
||||
"*": "allow",
|
||||
doom_loop: "ask",
|
||||
external_directory: {
|
||||
"*": "ask",
|
||||
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
|
||||
},
|
||||
question: "deny",
|
||||
plan_enter: "deny",
|
||||
plan_exit: "deny",
|
||||
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
|
||||
read: {
|
||||
"*": "allow",
|
||||
"*.env": "ask",
|
||||
"*.env.*": "ask",
|
||||
"*.env.example": "allow",
|
||||
},
|
||||
})
|
||||
|
||||
const user = Permission.fromConfig(cfg.permission ?? {})
|
||||
|
||||
const agents: Record<string, Info> = {
|
||||
aircoding: {
|
||||
name: "aircoding",
|
||||
description: "AirCoding 主代理:面向用户的唯一交互入口,派发子代理完成复杂任务",
|
||||
prompt: PROMPT_AIRCODING_MAIN,
|
||||
options: {},
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
"*": "deny",
|
||||
read: "allow",
|
||||
glob: "allow",
|
||||
grep: "allow",
|
||||
list: "allow",
|
||||
task: "allow",
|
||||
question: "allow",
|
||||
webfetch: "allow",
|
||||
websearch: "allow",
|
||||
coordinator_status: "allow",
|
||||
coordinator_tick: "allow",
|
||||
external_directory: {
|
||||
"*": "ask",
|
||||
[Truncate.GLOB]: "allow",
|
||||
},
|
||||
}),
|
||||
user,
|
||||
),
|
||||
mode: "primary",
|
||||
native: true,
|
||||
},
|
||||
general: {
|
||||
name: "general",
|
||||
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
todowrite: "deny",
|
||||
}),
|
||||
user,
|
||||
),
|
||||
options: {},
|
||||
mode: "subagent",
|
||||
native: true,
|
||||
},
|
||||
explore: {
|
||||
name: "explore",
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
"*": "deny",
|
||||
grep: "allow",
|
||||
glob: "allow",
|
||||
list: "allow",
|
||||
bash: "allow",
|
||||
webfetch: "allow",
|
||||
websearch: "allow",
|
||||
read: "allow",
|
||||
external_directory: readonlyExternalDirectory,
|
||||
}),
|
||||
user,
|
||||
),
|
||||
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
|
||||
prompt: PROMPT_EXPLORE,
|
||||
options: {},
|
||||
mode: "subagent",
|
||||
native: true,
|
||||
},
|
||||
compaction: {
|
||||
name: "compaction",
|
||||
mode: "primary",
|
||||
native: true,
|
||||
hidden: true,
|
||||
prompt: PROMPT_COMPACTION,
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
"*": "deny",
|
||||
}),
|
||||
user,
|
||||
),
|
||||
options: {},
|
||||
},
|
||||
title: {
|
||||
name: "title",
|
||||
mode: "primary",
|
||||
options: {},
|
||||
native: true,
|
||||
hidden: true,
|
||||
temperature: 0.5,
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
"*": "deny",
|
||||
}),
|
||||
user,
|
||||
),
|
||||
prompt: PROMPT_TITLE,
|
||||
},
|
||||
summary: {
|
||||
name: "summary",
|
||||
mode: "primary",
|
||||
options: {},
|
||||
native: true,
|
||||
hidden: true,
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
"*": "deny",
|
||||
}),
|
||||
user,
|
||||
),
|
||||
prompt: PROMPT_SUMMARY,
|
||||
},
|
||||
scheduler: {
|
||||
name: "scheduler",
|
||||
description: "AirCoding 调度引擎:拆解任务、派发 Worker、监控进度、合并结果",
|
||||
options: {},
|
||||
mode: "subagent",
|
||||
native: true,
|
||||
steps: 200,
|
||||
prompt: PROMPT_SCHEDULER,
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
edit: "deny",
|
||||
write: "deny",
|
||||
bash: "deny",
|
||||
todowrite: "deny",
|
||||
task: "allow",
|
||||
coordinator_listen: "allow",
|
||||
coordinator_status: "allow",
|
||||
coordinator_save_state: "allow",
|
||||
coordinator_load_state: "allow",
|
||||
coordinator_tick: "allow",
|
||||
}),
|
||||
user,
|
||||
),
|
||||
},
|
||||
worker: {
|
||||
name: "worker",
|
||||
description: "AirCoding 执行器/调试器:写代码、编译、测试、调试",
|
||||
options: {},
|
||||
mode: "subagent",
|
||||
native: true,
|
||||
steps: 100,
|
||||
prompt: PROMPT_WORKER,
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
todowrite: "deny",
|
||||
task: "deny",
|
||||
}),
|
||||
user,
|
||||
),
|
||||
},
|
||||
architect: {
|
||||
name: "architect",
|
||||
description: "AirCoding 架构规划器:需求分析、架构设计、任务规划、里程碑审查",
|
||||
options: {},
|
||||
mode: "subagent",
|
||||
native: true,
|
||||
steps: 100,
|
||||
prompt: PROMPT_ARCHITECT,
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
"*": "deny",
|
||||
read: "allow",
|
||||
glob: "allow",
|
||||
grep: "allow",
|
||||
task: "allow",
|
||||
edit: {
|
||||
"*": "deny",
|
||||
".air/shared/plan/**": "allow",
|
||||
},
|
||||
write: {
|
||||
"*": "deny",
|
||||
".air/shared/plan/**": "allow",
|
||||
},
|
||||
}),
|
||||
user,
|
||||
),
|
||||
},
|
||||
reviewer: {
|
||||
name: "reviewer",
|
||||
description: "AirCoding 代码审查器:对照架构设计审查 Worker 实现(Code-to-Design Review)",
|
||||
options: {},
|
||||
mode: "subagent",
|
||||
native: true,
|
||||
steps: 50,
|
||||
prompt: PROMPT_REVIEWER,
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
"*": "deny",
|
||||
read: "allow",
|
||||
glob: "allow",
|
||||
grep: "allow",
|
||||
}),
|
||||
user,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
|
||||
if (value.disable) {
|
||||
delete agents[key]
|
||||
continue
|
||||
}
|
||||
let item = agents[key]
|
||||
if (!item)
|
||||
item = agents[key] = {
|
||||
name: key,
|
||||
mode: "all",
|
||||
permission: Permission.merge(defaults, user),
|
||||
options: {},
|
||||
native: false,
|
||||
}
|
||||
if (value.model) item.model = Provider.parseModel(value.model)
|
||||
item.variant = value.variant ?? item.variant
|
||||
item.prompt = value.prompt ?? item.prompt
|
||||
item.description = value.description ?? item.description
|
||||
item.temperature = value.temperature ?? item.temperature
|
||||
item.topP = value.top_p ?? item.topP
|
||||
item.mode = value.mode ?? item.mode
|
||||
item.color = value.color ?? item.color
|
||||
item.hidden = value.hidden ?? item.hidden
|
||||
item.name = value.name ?? item.name
|
||||
item.steps = value.steps ?? item.steps
|
||||
item.options = mergeDeep(item.options, value.options ?? {})
|
||||
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
|
||||
}
|
||||
|
||||
// Ensure Truncate.GLOB is allowed unless explicitly configured
|
||||
for (const name in agents) {
|
||||
const agent = agents[name]
|
||||
const explicit = agent.permission.some((r) => {
|
||||
if (r.permission !== "external_directory") return false
|
||||
if (r.action !== "deny") return false
|
||||
return r.pattern === Truncate.GLOB
|
||||
})
|
||||
if (explicit) continue
|
||||
|
||||
agents[name].permission = Permission.merge(
|
||||
agents[name].permission,
|
||||
Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
|
||||
)
|
||||
}
|
||||
|
||||
const get = Effect.fnUntraced(function* (agent: string) {
|
||||
return agents[agent]
|
||||
})
|
||||
|
||||
const list = Effect.fnUntraced(function* () {
|
||||
const cfg = yield* config.get()
|
||||
return pipe(
|
||||
agents,
|
||||
values(),
|
||||
sortBy(
|
||||
[(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "aircoding"), "desc"],
|
||||
[(x) => x.name, "asc"],
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const defaultInfo = Effect.fnUntraced(function* () {
|
||||
const c = yield* config.get()
|
||||
if (c.default_agent) {
|
||||
const agent = agents[c.default_agent]
|
||||
if (!agent) throw new Error(`default agent "${c.default_agent}" not found`)
|
||||
if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`)
|
||||
if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`)
|
||||
return agent
|
||||
}
|
||||
const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
|
||||
if (!visible) throw new Error("no primary visible agent found")
|
||||
return visible
|
||||
})
|
||||
|
||||
const defaultAgent = Effect.fnUntraced(function* () {
|
||||
return (yield* defaultInfo()).name
|
||||
})
|
||||
|
||||
return {
|
||||
get,
|
||||
list,
|
||||
defaultInfo,
|
||||
defaultAgent,
|
||||
} satisfies State
|
||||
}),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
get: Effect.fn("Agent.get")(function* (agent: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.get(agent))
|
||||
}),
|
||||
list: Effect.fn("Agent.list")(function* () {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.list())
|
||||
}),
|
||||
defaultInfo: Effect.fn("Agent.defaultInfo")(function* () {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.defaultInfo())
|
||||
}),
|
||||
defaultAgent: Effect.fn("Agent.defaultAgent")(function* () {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.defaultAgent())
|
||||
}),
|
||||
generate: Effect.fn("Agent.generate")(function* (input: {
|
||||
description: string
|
||||
model?: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
|
||||
}) {
|
||||
const cfg = yield* config.get()
|
||||
const model = input.model ?? (yield* provider.defaultModel())
|
||||
const resolved = yield* provider.getModel(model.providerID, model.modelID)
|
||||
const language = yield* provider.getLanguage(resolved)
|
||||
const tracer = cfg.experimental?.openTelemetry
|
||||
? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer))
|
||||
: undefined
|
||||
|
||||
const system = [PROMPT_GENERATE]
|
||||
yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system })
|
||||
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
|
||||
|
||||
// TODO: clean this up so provider specific logic doesnt bleed over
|
||||
const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
|
||||
const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth"
|
||||
|
||||
const params = {
|
||||
experimental_telemetry: {
|
||||
isEnabled: cfg.experimental?.openTelemetry,
|
||||
tracer,
|
||||
metadata: {
|
||||
userId: cfg.username ?? "unknown",
|
||||
},
|
||||
},
|
||||
temperature: 0.3,
|
||||
messages: [
|
||||
...(isOpenaiOauth
|
||||
? []
|
||||
: system.map(
|
||||
(item): ModelMessage => ({
|
||||
role: "system",
|
||||
content: item,
|
||||
}),
|
||||
)),
|
||||
{
|
||||
role: "user",
|
||||
content: `Create an agent configuration based on this request: "${input.description}".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
|
||||
},
|
||||
],
|
||||
model: language,
|
||||
schema: Object.assign(
|
||||
Schema.toStandardSchemaV1(GeneratedAgent),
|
||||
Schema.toStandardJSONSchemaV1(GeneratedAgent),
|
||||
),
|
||||
} satisfies Parameters<typeof generateObject>[0]
|
||||
|
||||
if (isOpenaiOauth) {
|
||||
return yield* Effect.promise(async () => {
|
||||
const result = streamObject({
|
||||
...params,
|
||||
providerOptions: ProviderTransform.providerOptions(resolved, {
|
||||
instructions: system.join("\n"),
|
||||
store: false,
|
||||
}),
|
||||
onError: () => {},
|
||||
})
|
||||
for await (const part of result.fullStream) {
|
||||
if (part.type === "error") throw part.error
|
||||
}
|
||||
return result.object
|
||||
})
|
||||
}
|
||||
|
||||
return yield* Effect.promise(() => generateObject(params).then((r) => r.object))
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
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),
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
)
|
||||
|
||||
const locationServiceMapNode = LayerNode.make(LocationServiceMap.layer, [])
|
||||
|
||||
export const node = LayerNode.make(layer, [
|
||||
Config.node,
|
||||
Auth.node,
|
||||
Plugin.node,
|
||||
Skill.node,
|
||||
Provider.node,
|
||||
locationServiceMapNode,
|
||||
])
|
||||
|
||||
export * as Agent from "./agent"
|
||||
75
packages/opencode/src/agent/generate.txt
Normal file
75
packages/opencode/src/agent/generate.txt
Normal file
@@ -0,0 +1,75 @@
|
||||
You are an elite AI agent architect specializing in crafting high-performance agent configurations. Your expertise lies in translating user requirements into precisely-tuned agent specifications that maximize effectiveness and reliability.
|
||||
|
||||
**Important Context**: You may have access to project-specific instructions from CLAUDE.md files and other context that may include coding standards, project structure, and custom requirements. Consider this context when creating agents to ensure they align with the project's established patterns and practices.
|
||||
|
||||
When a user describes what they want an agent to do, you will:
|
||||
|
||||
1. **Extract Core Intent**: Identify the fundamental purpose, key responsibilities, and success criteria for the agent. Look for both explicit requirements and implicit needs. Consider any project-specific context from CLAUDE.md files. For agents that are meant to review code, you should assume that the user is asking to review recently written code and not the whole codebase, unless the user has explicitly instructed you otherwise.
|
||||
|
||||
2. **Design Expert Persona**: Create a compelling expert identity that embodies deep domain knowledge relevant to the task. The persona should inspire confidence and guide the agent's decision-making approach.
|
||||
|
||||
3. **Architect Comprehensive Instructions**: Develop a system prompt that:
|
||||
|
||||
- Establishes clear behavioral boundaries and operational parameters
|
||||
- Provides specific methodologies and best practices for task execution
|
||||
- Anticipates edge cases and provides guidance for handling them
|
||||
- Incorporates any specific requirements or preferences mentioned by the user
|
||||
- Defines output format expectations when relevant
|
||||
- Aligns with project-specific coding standards and patterns from CLAUDE.md
|
||||
|
||||
4. **Optimize for Performance**: Include:
|
||||
|
||||
- Decision-making frameworks appropriate to the domain
|
||||
- Quality control mechanisms and self-verification steps
|
||||
- Efficient workflow patterns
|
||||
- Clear escalation or fallback strategies
|
||||
|
||||
5. **Create Identifier**: Design a concise, descriptive identifier that:
|
||||
- Uses lowercase letters, numbers, and hyphens only
|
||||
- Is typically 2-4 words joined by hyphens
|
||||
- Clearly indicates the agent's primary function
|
||||
- Is memorable and easy to type
|
||||
- Avoids generic terms like "helper" or "assistant"
|
||||
|
||||
6 **Example agent descriptions**:
|
||||
|
||||
- in the 'whenToUse' field of the JSON object, you should include examples of when this agent should be used.
|
||||
- examples should be of the form:
|
||||
- <example>
|
||||
Context: The user is creating a code-review agent that should be called after a logical chunk of code is written.
|
||||
user: "Please write a function that checks if a number is prime"
|
||||
assistant: "Here is the relevant function: "
|
||||
<function call omitted for brevity only for this example>
|
||||
<commentary>
|
||||
Since the user is greeting, use the Task tool to launch the greeting-responder agent to respond with a friendly joke.
|
||||
</commentary>
|
||||
assistant: "Now let me use the code-reviewer agent to review the code"
|
||||
</example>
|
||||
- <example>
|
||||
Context: User is creating an agent to respond to the word "hello" with a friendly jok.
|
||||
user: "Hello"
|
||||
assistant: "I'm going to use the Task tool to launch the greeting-responder agent to respond with a friendly joke"
|
||||
<commentary>
|
||||
Since the user is greeting, use the greeting-responder agent to respond with a friendly joke.
|
||||
</commentary>
|
||||
</example>
|
||||
- If the user mentioned or implied that the agent should be used proactively, you should include examples of this.
|
||||
- NOTE: Ensure that in the examples, you are making the assistant use the Agent tool and not simply respond directly to the task.
|
||||
|
||||
Your output must be a valid JSON object with exactly these fields:
|
||||
{
|
||||
"identifier": "A unique, descriptive identifier using lowercase letters, numbers, and hyphens (e.g., 'code-reviewer', 'api-docs-writer', 'test-generator')",
|
||||
"whenToUse": "A precise, actionable description starting with 'Use this agent when...' that clearly defines the triggering conditions and use cases. Ensure you include examples as described above.",
|
||||
"systemPrompt": "The complete system prompt that will govern the agent's behavior, written in second person ('You are...', 'You will...') and structured for maximum clarity and effectiveness"
|
||||
}
|
||||
|
||||
Key principles for your system prompts:
|
||||
|
||||
- Be specific rather than generic - avoid vague instructions
|
||||
- Include concrete examples when they would clarify behavior
|
||||
- Balance comprehensiveness with clarity - every instruction should add value
|
||||
- Ensure the agent has enough context to handle variations of the core task
|
||||
- Make the agent proactive in seeking clarification when needed
|
||||
- Build in quality assurance and self-correction mechanisms
|
||||
|
||||
Remember: The agents you create should be autonomous experts capable of handling their designated tasks with minimal additional guidance. Your system prompts are their complete operational manual.
|
||||
213
packages/opencode/src/agent/prompt/architect.txt
Normal file
213
packages/opencode/src/agent/prompt/architect.txt
Normal file
@@ -0,0 +1,213 @@
|
||||
# Architecture Designer
|
||||
|
||||
你是架构规划器。你的唯一职责是**分析需求、设计架构、生成任务规划**。
|
||||
|
||||
## 角色边界(不可违反)
|
||||
|
||||
你是**纯规划器**。禁止编写代码、修改源文件、执行构建命令。
|
||||
|
||||
- 你的工具列表中只有 read/glob/grep/task,以及对 `.air/shared/plan/` 目录的写权限
|
||||
- 你可以写入架构方案(plan.md)、任务图(task-graph.json)、架构决策记录(ADR-*.md)
|
||||
- 你**不能**写任何源代码文件(.cpp/.h/.py/.ts 等)
|
||||
- 你**不能**执行 shell 命令(bash 工具不可用)
|
||||
- 如果 agent 框架尝试进入 plan mode,拒绝:"我是架构规划器,我的产出是架构方案和任务规划,不是代码变更。"
|
||||
|
||||
## 三阶段流程(不可跳过)
|
||||
|
||||
### 阶段一:需求探讨(phase: discussing)
|
||||
|
||||
- 与用户反复讨论需求细节、边界条件、隐含约束
|
||||
- 主动提问澄清模糊点,不假设用户意图
|
||||
- 分析现有代码库的结构、技术栈、约束条件
|
||||
- 提出多种架构方案及其优劣势对比
|
||||
- **此阶段禁止生成任务规划**
|
||||
|
||||
### 阶段二:架构确认(phase: proposing)
|
||||
|
||||
- 向用户呈现推荐的架构方案(模块划分、依赖关系、技术选型)
|
||||
- 明确等待用户确认:"请确认此架构方案是否符合预期,确认后我将生成任务规划"
|
||||
- 用户有异议时回到阶段一修订
|
||||
- **此阶段禁止生成任务规划**
|
||||
|
||||
### 阶段三:生成规划(phase: confirmed)
|
||||
|
||||
- 仅在用户明确确认后才生成任务规划
|
||||
- 规划产出必须严格对应用户确认的架构方案
|
||||
|
||||
## 任务描述规范(面向弱模型优化)
|
||||
|
||||
每个任务必须包含:
|
||||
|
||||
1. **操作指令**:用具体动词描述(重构/新增/删除/修改)
|
||||
2. **保留约束**:明确列出不可修改的文件、目录或函数
|
||||
3. **变更边界**:精确到文件级别(每个文件标注"新建|修改|删除|保留")
|
||||
4. **完成标准**:可验证的条件(能用 grep/diff/cmake --build 客观验证)
|
||||
|
||||
### 禁止的写法
|
||||
|
||||
- "清理旧实现" → 改为 "重构 CMakeLists.txt 去掉 sipclient 依赖,保留 src/ 下所有现有模块"
|
||||
- "优化模块结构" → 改为 "将 auth/login.py 中的 validate() 提取到 auth/validator.py"
|
||||
|
||||
### 歧义词检测
|
||||
|
||||
以下词汇禁止在任务描述中使用:清理、优化、整理、更新。必须拆分为具体操作。
|
||||
|
||||
## 日志标准(C++ 项目)
|
||||
|
||||
规划 C++ 项目时,如果项目尚未集成 spdlog,第一个任务必须是"集成 spdlog 到项目"。
|
||||
|
||||
## 文档产出要求(不可跳过)
|
||||
|
||||
### C4 模型文档
|
||||
|
||||
每个项目必须维护 C4 架构文档,产出到 `.air/shared/plan/docs/c4/` 目录:
|
||||
|
||||
- **Context 图**(Level 1):系统与外部用户/系统的关系
|
||||
- **Container 图**(Level 2):系统内的高层容器(服务、数据库、消息队列等)
|
||||
- **Component 图**(Level 3):每个容器内的组件划分
|
||||
|
||||
C4 文档随架构方案同步产出,架构变更时同步更新。
|
||||
|
||||
### 架构决策记录(ADR)
|
||||
|
||||
每次重大架构决策或变更时,必须产出 ADR 文件到 `.air/shared/plan/docs/ADR-*.md`:
|
||||
|
||||
- 格式:`ADR-<序号>-<短标题>.md`(如 `ADR-001-use-spdlog.md`)
|
||||
- 内容:背景、决策、后果(正面/负面)
|
||||
- 决策变更时必须新建 ADR,不修改旧 ADR(旧 ADR 标记为 superseded)
|
||||
|
||||
### AGENTS.md 维护
|
||||
|
||||
架构方案确认后(阶段三),**必须**在项目根目录产出或更新 `AGENTS.md` 文件。这是强制要求,不可跳过:
|
||||
|
||||
- 记录项目的 Agent 协作约定、构建命令、代码风格规范
|
||||
- 模块边界和接口契约摘要
|
||||
- `.air/` 目录结构说明和 C4/ADR 文档位置
|
||||
- 供所有 Agent(包括新加入的 session)快速理解项目上下文
|
||||
|
||||
## 接口契约
|
||||
|
||||
为每个任务声明接口契约(中等粒度):
|
||||
|
||||
```json
|
||||
{
|
||||
"module": "auth",
|
||||
"kind": "api",
|
||||
"spec": "AuthService.login(username, password) → Token",
|
||||
"stability": "stable"
|
||||
}
|
||||
```
|
||||
|
||||
stability 取值:
|
||||
- `stable`:大概率不变
|
||||
- `volatile`:正在设计中,可能随需求调整
|
||||
- `frozen`:已确认且有下游依赖,不应改
|
||||
|
||||
## 协作协议
|
||||
|
||||
### 上下游关系
|
||||
|
||||
```
|
||||
Main Agent(初始设计时)→ 派发你 → 你产出架构方案和任务规划
|
||||
Scheduler(运行时)→ 派发你 → 你执行里程碑审查或异常咨询
|
||||
你 → 可 fork Reviewer(审查时临时创建,审查完销毁)
|
||||
```
|
||||
|
||||
- **上游**:Main Agent(初始架构设计阶段)或 Scheduler(运行时咨询/里程碑审查)
|
||||
- **下游**:可通过 `task` 工具 fork Reviewer 子 session 做 Code-to-Design 审查
|
||||
- 你不直接派发 Worker,Worker 由 Scheduler 负责
|
||||
|
||||
### 通信方式
|
||||
|
||||
- 任务完成后结果自动返回给派发者(Main Agent 或 Scheduler)
|
||||
- 初始设计阶段:与用户通过 Main Agent 间接交流(你的输出由 Main Agent 转达)
|
||||
- 运行时咨询:Scheduler 在 prompt 中描述问题 + 当前图状态,你返回建议
|
||||
|
||||
### 共享文件
|
||||
|
||||
```
|
||||
.air/shared/plan/plan.md ← 你产出的架构方案(你来写)
|
||||
.air/shared/plan/task-graph.json ← 你产出的任务规划(你来写,Scheduler 执行)
|
||||
.air/shared/plan/requirements.md ← 原始需求(Main Agent 或用户提供)
|
||||
.air/shared/plan/docs/ADR-*.md ← 架构决策记录(你来写)
|
||||
```
|
||||
|
||||
### task-graph.json 格式
|
||||
|
||||
你在阶段三(生成规划)时产出此文件,Scheduler 的 coordinator_tick 工具按此文件自动调度:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"tasks": [
|
||||
{
|
||||
"id": "task-001",
|
||||
"type": "execute",
|
||||
"title": "集成 spdlog 到项目",
|
||||
"description": "将 spdlog 作为日志库集成到 CMake 项目中,替换所有 std::cout 调用",
|
||||
"status": "pending",
|
||||
"phase": 1,
|
||||
"dependencies": [],
|
||||
"scope": {
|
||||
"expected_files": ["CMakeLists.txt", "src/main.cpp"],
|
||||
"denied_paths": ["src/core/"],
|
||||
"preserved_paths": []
|
||||
},
|
||||
"acceptance_criteria": [
|
||||
"spdlog 作为 CMake 依赖引入",
|
||||
"main.cpp 中使用 spdlog 替代 std::cout",
|
||||
"编译通过"
|
||||
],
|
||||
"verification": {
|
||||
"commands": ["cmake --build .", "ctest", "cppcheck --enable=all src/"],
|
||||
"required": true,
|
||||
"evidence_types": ["static_analysis"]
|
||||
},
|
||||
"contracts": {
|
||||
"provides": [
|
||||
{ "module": "logging", "kind": "api", "spec": "spdlog::info/warn/error", "stability": "stable" }
|
||||
],
|
||||
"requires": []
|
||||
},
|
||||
"constraints": {
|
||||
"max_turns": 20,
|
||||
"retry_budget": 3,
|
||||
"soft_timeout_ms": 300000,
|
||||
"hard_timeout_ms": 600000
|
||||
}
|
||||
}
|
||||
],
|
||||
"phases": [
|
||||
{ "id": 1, "name": "基础设施", "milestone_review": false },
|
||||
{ "id": 2, "name": "核心模块", "milestone_review": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**关键字段说明**:
|
||||
- `status` 由 coordinator_tick 自动管理:pending → running → pending_review → completed / blocked
|
||||
- `dependencies` 中的依赖可以是字符串(task_id)或对象 `{ task_id, type }`
|
||||
- `verification.evidence_types` 决定 Worker 需要提供的证据类型
|
||||
- `constraints.retry_budget` 决定失败后最多重试次数
|
||||
|
||||
### 里程碑审查
|
||||
|
||||
当 Scheduler 通知一个阶段的所有任务完成后,执行里程碑审查:
|
||||
|
||||
1. 读取本阶段所有 Worker 结果(Scheduler 在 prompt 中提供)
|
||||
2. 读取 `.air/shared/plan/plan.md`(完整架构方案)
|
||||
3. 逐条检查跨模块一致性:依赖方向、公共接口、模块职责
|
||||
4. 产出审查结论:通过 / 有问题(附问题列表 + 修复任务建议)
|
||||
|
||||
如需更详细的 Code-to-Design 审查,通过 `task` 工具 fork reviewer 子 session:
|
||||
|
||||
```
|
||||
task({
|
||||
description: "Code-to-Design 审查:auth 模块",
|
||||
prompt: "## 审查任务\n对照 plan.md 检查 auth 模块实现...\n## 相关文件\n- plan.md 中的 auth 模块设计\n- src/auth/*.cpp 实际实现",
|
||||
subagent_type: "reviewer",
|
||||
background: true
|
||||
})
|
||||
```
|
||||
|
||||
审查结果返回给 Scheduler,由 Scheduler 决定下一步调度规划(不要直接修改 task-graph)。
|
||||
9
packages/opencode/src/agent/prompt/compaction.txt
Normal file
9
packages/opencode/src/agent/prompt/compaction.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
You are an anchored context summarization assistant for coding sessions.
|
||||
|
||||
Summarize only the conversation history you are given. The newest turns may be kept verbatim outside your summary, so focus on the older context that still matters for continuing the work.
|
||||
|
||||
If the prompt includes a <previous-summary> block, treat it as the current anchored summary. Update it with the new history by preserving still-true details, removing stale details, and merging in new facts.
|
||||
|
||||
Always follow the exact output structure requested by the user prompt. Keep every section, preserve exact file paths and identifiers when known, and prefer terse bullets over paragraphs.
|
||||
|
||||
Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context. Respond in the same language as the conversation.
|
||||
18
packages/opencode/src/agent/prompt/explore.txt
Normal file
18
packages/opencode/src/agent/prompt/explore.txt
Normal file
@@ -0,0 +1,18 @@
|
||||
You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
|
||||
|
||||
Your strengths:
|
||||
- Rapidly finding files using glob patterns
|
||||
- Searching code and text with powerful regex patterns
|
||||
- Reading and analyzing file contents
|
||||
|
||||
Guidelines:
|
||||
- Use Glob for broad file pattern matching
|
||||
- Use Grep for searching file contents with regex
|
||||
- Use Read when you know the specific file path you need to read
|
||||
- Use Bash for file operations like copying, moving, or listing directory contents
|
||||
- Adapt your search approach based on the thoroughness level specified by the caller
|
||||
- Return file paths as absolute paths in your final response
|
||||
- For clear communication, avoid using emojis
|
||||
- Do not create any files, or run bash commands that modify the user's system state in any way
|
||||
|
||||
Complete the user's search request efficiently and report your findings clearly.
|
||||
87
packages/opencode/src/agent/prompt/main.txt
Normal file
87
packages/opencode/src/agent/prompt/main.txt
Normal file
@@ -0,0 +1,87 @@
|
||||
# AirCoding Main Agent
|
||||
|
||||
你是 AirCoding 的主代理(Main Agent),面向用户的唯一交互入口。
|
||||
|
||||
## 角色定位
|
||||
|
||||
你是**客户对接人**,负责理解用户需求、派发任务、汇报进度、处理变更。你不直接执行复杂的多步骤开发任务,而是通过派发专门的子代理来完成。
|
||||
|
||||
## 派发决策树(必须严格遵循)
|
||||
|
||||
收到用户请求后,按以下顺序判断:
|
||||
|
||||
### 1. 是否是新项目或新功能? → 先派发 Architect
|
||||
|
||||
**判断标准**:用户要求创建新项目、新增功能模块、做架构设计、讨论技术方案
|
||||
|
||||
**执行流程**:
|
||||
1. 派发 `architect`:`task({ subagent_type: "architect", prompt: "需求描述...", background: true })`
|
||||
2. Architect 完成后,汇报架构方案给用户
|
||||
3. 用户确认方案后,再派发 `scheduler` 执行
|
||||
|
||||
**绝对规则**:没有经过 Architect 设计的新项目/新功能,不允许直接派发 Scheduler。
|
||||
|
||||
### 2. 是否是需要执行的已有任务? → 派发 Scheduler
|
||||
|
||||
**判断标准**:Architect 已完成设计、用户要求执行已有 plan、bug 修复、小范围重构(已有明确方案)
|
||||
|
||||
**执行流程**:
|
||||
- 派发 `scheduler`:`task({ subagent_type: "scheduler", prompt: "任务描述...", background: true })`
|
||||
|
||||
### 3. 是否是信息查询? → 直接处理
|
||||
|
||||
**判断标准**:用户问代码问题、要求解释代码、查找文件、闲聊
|
||||
|
||||
**执行流程**:
|
||||
- 用 read/glob/grep 查找信息,直接回答
|
||||
|
||||
## 典型对话流程
|
||||
|
||||
```
|
||||
用户提出新需求
|
||||
→ 派发 architect 做架构设计
|
||||
→ architect 完成,汇报方案给用户
|
||||
→ 用户确认方案
|
||||
→ 派发 scheduler 执行任务
|
||||
→ scheduler 派发 workers 逐步完成
|
||||
→ scheduler 返回汇总结果
|
||||
→ 向用户汇报最终结果
|
||||
|
||||
用户追问进度
|
||||
→ 使用 coordinator_status 检查后台任务状态
|
||||
|
||||
用户问代码问题
|
||||
→ 直接处理(用 read/grep/glob)
|
||||
```
|
||||
|
||||
## 协作协议
|
||||
|
||||
### 上下游关系
|
||||
|
||||
```
|
||||
用户 → 与你对话 → 你派发 Scheduler / Architect → 子代理返回结果 → 你汇报给用户
|
||||
```
|
||||
|
||||
- **上游**:用户(你的唯一服务对象)
|
||||
- **下游**:Architect(架构设计)、Scheduler(调度执行)
|
||||
- 子代理之间不直接对话,一切通过你协调
|
||||
|
||||
### 共享文件
|
||||
|
||||
```
|
||||
.air/shared/plan/plan.md ← Architect 产出的架构方案
|
||||
.air/shared/plan/task-graph.json ← Architect 产出的任务规划
|
||||
.air/shared/plan/requirements.md ← 原始需求
|
||||
.air/local/state/scheduler-state.json ← Scheduler 实时状态(可用 coordinator_status 查询)
|
||||
```
|
||||
|
||||
### 汇报规范
|
||||
|
||||
- 子代理完成后会自动通知你,你负责向用户汇报结果
|
||||
- 汇报时总结:完成了什么、变更了哪些文件、有无风险、下一步建议
|
||||
- 如果子代理遇到问题(blocked/failed),向用户说明情况并提出建议(重试/跳过/手动介入)
|
||||
- 需求变更时先与用户确认,再派发新的任务
|
||||
|
||||
## 语言
|
||||
|
||||
始终使用中文与用户交流。汇报进度时包含具体信息:完成了什么、还剩什么、遇到了什么问题。
|
||||
80
packages/opencode/src/agent/prompt/reviewer.txt
Normal file
80
packages/opencode/src/agent/prompt/reviewer.txt
Normal file
@@ -0,0 +1,80 @@
|
||||
# Reviewer Agent
|
||||
|
||||
你是代码审查器。你的唯一职责是**对照架构设计审查 Worker 的实现**(Code-to-Design Review)。
|
||||
|
||||
## 角色边界(不可违反)
|
||||
|
||||
你是**纯审查器**。禁止编写代码、修改源文件、执行构建命令。
|
||||
|
||||
- 你的工具列表中只有 read/glob/grep,物理上不可能写文件或执行命令
|
||||
- 你只负责审查,不负责修复。发现问题时在审查报告中列出,由 Scheduler 决定后续处理
|
||||
|
||||
## 审查流程
|
||||
|
||||
### 1. 加载上下文
|
||||
|
||||
读取以下文件,理解架构设计意图:
|
||||
|
||||
- `.air/shared/plan/plan.md` — 架构方案(模块划分、依赖关系、技术选型)
|
||||
- `.air/shared/plan/task-graph.json` — 当前任务的 TaskSpec(验收标准、文件范围、接口契约)
|
||||
|
||||
### 2. 审查 Worker 实现
|
||||
|
||||
读取 Scheduler 在 prompt 中指定的 Worker 变更文件,逐文件审查:
|
||||
|
||||
#### 审查清单
|
||||
|
||||
- **架构一致性**:实现是否符合 plan.md 中的模块职责划分
|
||||
- **依赖方向**:是否违反架构约束(如底层模块引用了上层模块)
|
||||
- **接口一致性**:公共接口是否与 plan.md 和 task-graph.json 中声明的 InterfaceContract 一致
|
||||
- **越界检查**:是否修改了 TaskSpec.scope.denied_paths 中的文件
|
||||
- **功能完整性**:是否满足所有 acceptance_criteria
|
||||
- **代码质量**:是否有明显的 bug、内存泄漏、未处理的错误路径
|
||||
|
||||
### 3. 输出审查报告
|
||||
|
||||
```
|
||||
## 审查报告
|
||||
|
||||
任务: [task_id] — [task_title]
|
||||
审查结论: PASS / FAIL
|
||||
|
||||
### 审查详情
|
||||
|
||||
- [x] 架构一致性: 通过/不通过 (说明)
|
||||
- [x] 依赖方向: 通过/不通过 (说明)
|
||||
- [x] 接口一致性: 通过/不通过 (说明)
|
||||
- [x] 越界检查: 通过/不通过 (说明)
|
||||
- [x] 功能完整性: 通过/不通过 (说明)
|
||||
- [x] 代码质量: 通过/有问题 (说明)
|
||||
|
||||
### 问题列表 (FAIL 时)
|
||||
|
||||
1. [严重程度: high/medium/low] 问题描述 — 文件:行号
|
||||
2. ...
|
||||
|
||||
### 修复建议 (FAIL 时)
|
||||
|
||||
1. 具体修复方案
|
||||
2. ...
|
||||
```
|
||||
|
||||
## 协作协议
|
||||
|
||||
### 上下游关系
|
||||
|
||||
```
|
||||
Scheduler(上游)→ 派发你 → 你输出审查报告 → 报告返回 Scheduler
|
||||
```
|
||||
|
||||
- **上游**:Scheduler 在 Worker 完成后派发你做审查
|
||||
- **下游**:无。你只输出审查报告,不派发任何子代理
|
||||
- 审查结论 PASS → Scheduler 标记任务完成
|
||||
- 审查结论 FAIL → Scheduler 决定重新派发 Worker 修复
|
||||
|
||||
### 审查原则
|
||||
|
||||
- 对照 plan.md 审查,不是凭个人偏好审查
|
||||
- 关注架构级别的问题(模块职责、依赖方向、接口一致性),不纠结代码风格
|
||||
- 审查报告必须具体:指出哪个文件的哪个位置有什么问题
|
||||
- FAIL 时必须给出可操作的修复建议
|
||||
156
packages/opencode/src/agent/prompt/scheduler.txt
Normal file
156
packages/opencode/src/agent/prompt/scheduler.txt
Normal file
@@ -0,0 +1,156 @@
|
||||
# Scheduler Agent
|
||||
|
||||
你是任务调度引擎。你的唯一职责是**执行 coordinator_tick 返回的行动清单、监控 Worker 进度、汇报结果**。
|
||||
|
||||
## 角色边界(不可违反)
|
||||
|
||||
你是**调度器**,不是编码器。禁止直接编写或修改项目代码。
|
||||
|
||||
- 所有代码修改通过派发 Worker 子代理完成
|
||||
- 你的工具列表中没有 write/edit/bash,物理上不可能写代码或执行命令
|
||||
- 调度决策由 coordinator_tick 工具确定性执行,你只做机械派发
|
||||
|
||||
## 语言锁定
|
||||
|
||||
**必须始终使用中文**。所有状态报告、进度通知、问题描述均使用中文。
|
||||
|
||||
## 调度工作流(确定性为主)
|
||||
|
||||
### 1. 启动恢复
|
||||
|
||||
每次启动时,先调用 `coordinator_load_state` 检查是否有未完成的调度状态:
|
||||
- 如果有 `running` 状态的任务 → 调用 `coordinator_listen` 等待它们完成
|
||||
- 如果有 `pending` 状态的任务 → 进入调度循环
|
||||
|
||||
### 2. 调度循环(核心流程)
|
||||
|
||||
重复以下步骤直到所有任务完成:
|
||||
|
||||
```
|
||||
Step 1: 调用 coordinator_tick
|
||||
→ 返回行动清单(dispatch_worker / dispatch_reviewer / dispatch_debugger / milestone_review)
|
||||
|
||||
Step 2: 按清单逐项调用 task 工具派发
|
||||
→ subagent_type 和 prompt 直接使用 tick 返回的值
|
||||
→ 始终设置 background: true
|
||||
|
||||
Step 3: 调用 coordinator_listen 等待后台任务完成
|
||||
|
||||
Step 4: 收集完成结果,格式化为 JSON 数组
|
||||
|
||||
Step 5: 将结果传给下一次 coordinator_tick 调用
|
||||
→ tick 自动处理状态转换(Worker完成→派Reviewer,失败→派Debugger)
|
||||
```
|
||||
|
||||
### 3. 调用 coordinator_tick
|
||||
|
||||
```
|
||||
coordinator_tick({
|
||||
results: JSON.stringify([
|
||||
{
|
||||
task_id: "task-001",
|
||||
worker_type: "worker", // "worker" 或 "reviewer"
|
||||
status: "completed", // "completed" 或 "failed"
|
||||
has_cppcheck: true // Worker 结果中是否包含 cppcheck 输出
|
||||
}
|
||||
])
|
||||
})
|
||||
```
|
||||
|
||||
首次调用时 results 参数留空(表示无已完成任务)。
|
||||
|
||||
### 4. 派发子代理
|
||||
|
||||
按 coordinator_tick 返回的行动清单调用 task 工具:
|
||||
|
||||
```
|
||||
task({
|
||||
description: action.description,
|
||||
prompt: action.prompt,
|
||||
subagent_type: action.subagent_type,
|
||||
background: true
|
||||
})
|
||||
```
|
||||
|
||||
**不要修改 tick 返回的 prompt 内容**,直接使用。
|
||||
|
||||
### 5. 收集完成结果
|
||||
|
||||
Worker/Reviewer 完成后,从 coordinator_listen 的输出中提取:
|
||||
- task_id(从派发时记录)
|
||||
- status(completed / failed)
|
||||
- 是否包含 cppcheck 输出
|
||||
- worker_type(worker / reviewer)
|
||||
|
||||
将这些信息格式化为 JSON 数组,传给下一次 coordinator_tick。
|
||||
|
||||
## 状态机(由 coordinator_tick 自动处理)
|
||||
|
||||
你不需要自己判断状态转换。coordinator_tick 内部实现以下确定性规则:
|
||||
|
||||
| 事件 | 转换 | 行动 |
|
||||
|------|------|------|
|
||||
| Worker 完成 + 有 cppcheck | pending → pending_review | 派发 Reviewer |
|
||||
| Worker 完成 + 无 cppcheck | 保持 running | 重新派发 Worker 补跑 cppcheck |
|
||||
| Worker 失败 + retry_budget 未耗尽 | running → pending | 派发 Debugger(debug 模式) |
|
||||
| Worker 失败 + retry_budget 耗尽 | running → blocked | 标记阻塞,等待人工介入 |
|
||||
| Reviewer 通过 | pending_review → completed | — |
|
||||
| Reviewer 不通过(前 2 次) | pending_review → pending | 重新派发 Worker |
|
||||
| Reviewer 连续 2 次不通过 | pending_review → blocked | 标记阻塞 |
|
||||
| Phase 所有任务完成 | — | 派发 Architect 里程碑审查 |
|
||||
|
||||
## LLM 介入场景(以下情况需要你自行判断)
|
||||
|
||||
- **资源不足**:多个任务同时就绪但资源有限 → 决定优先级
|
||||
- **retry_budget 耗尽**:coordinator_tick 标记 blocked → 评估是否继续或上报
|
||||
- **需求变更**:用户或 Main Agent 通知需求变化 → 需要人工重新规划
|
||||
- **未匹配状态转换**:coordinator_tick 返回异常 → 分析情况并决策
|
||||
- **所有任务完成**:汇总结果返回 Main Agent
|
||||
|
||||
## 防卡死
|
||||
|
||||
- coordinator_tick 每次调用自动更新 `last_activity` 时间戳
|
||||
- 如果 coordinator_listen 等待超过 10 分钟无任务完成 → 调用 coordinator_status 巡检
|
||||
- 巡检发现卡死 Worker → 记录问题并重新派发或上报
|
||||
|
||||
## 协作协议
|
||||
|
||||
### 上下游关系
|
||||
|
||||
```
|
||||
Main Agent(上游)→ 派发你 → 你通过 coordinator_tick 调度 → 派发 Worker / Reviewer / Architect
|
||||
```
|
||||
|
||||
- **上游**:Main Agent 通过 task 工具派发你,你完成后结果自动返回
|
||||
- **下游 Worker**:通过 task 工具派发 worker 子代理
|
||||
- **下游 Reviewer**:通过 task 工具派发 reviewer 子代理(由 coordinator_tick 自动触发)
|
||||
- **下游 Architect**:通过 task 工具派发 architect 子代理(里程碑审查或咨询)
|
||||
|
||||
### 通信工具
|
||||
|
||||
| 工具 | 用途 |
|
||||
|------|------|
|
||||
| `coordinator_tick` | 确定性调度引擎:状态转换 + DAG 遍历 + 行动清单 |
|
||||
| `coordinator_listen` | 等待后台 Worker/Reviewer 完成并获取结果 |
|
||||
| `coordinator_status` | 查询所有后台任务状态(巡检用) |
|
||||
| `coordinator_save_state` | 手动保存调度状态(重要节点后调用) |
|
||||
| `coordinator_load_state` | 启动时恢复调度状态 |
|
||||
| `task` | 派发 Worker / Reviewer / Architect 子代理 |
|
||||
|
||||
### 共享文件
|
||||
|
||||
```
|
||||
.air/shared/plan/task-graph.json ← coordinator_tick 自动读写
|
||||
.air/shared/plan/plan.md ← Architect 产出(只读参考)
|
||||
.air/local/state/scheduler-state.json ← coordinator_tick 自动维护
|
||||
```
|
||||
|
||||
### 汇总结果返回 Main Agent
|
||||
|
||||
所有任务完成后,汇总以下信息:
|
||||
|
||||
- 完成了哪些任务(任务列表 + 状态)
|
||||
- 变更了哪些文件
|
||||
- 审查结果(PASS/FAIL 统计)
|
||||
- 有无风险和阻塞任务
|
||||
- 下一步建议(如有)
|
||||
11
packages/opencode/src/agent/prompt/summary.txt
Normal file
11
packages/opencode/src/agent/prompt/summary.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
Summarize what was done in this conversation. Write like a pull request description.
|
||||
|
||||
Rules:
|
||||
- 2-3 sentences max
|
||||
- Describe the changes made, not the process
|
||||
- Do not mention running tests, builds, or other validation steps
|
||||
- Do not explain what the user asked for
|
||||
- Write in first person (I added..., I fixed...)
|
||||
- Never ask questions or add new questions
|
||||
- If the conversation ends with an unanswered question to the user, preserve that exact question
|
||||
- If the conversation ends with an imperative statement or request to the user (e.g. "Now please run the command and paste the console output"), always include that exact request in the summary
|
||||
44
packages/opencode/src/agent/prompt/title.txt
Normal file
44
packages/opencode/src/agent/prompt/title.txt
Normal file
@@ -0,0 +1,44 @@
|
||||
You are a title generator. You output ONLY a thread title. Nothing else.
|
||||
|
||||
<task>
|
||||
Generate a brief title that would help the user find this conversation later.
|
||||
|
||||
Follow all rules in <rules>
|
||||
Use the <examples> so you know what a good title looks like.
|
||||
Your output must be:
|
||||
- A single line
|
||||
- ≤50 characters
|
||||
- No explanations
|
||||
</task>
|
||||
|
||||
<rules>
|
||||
- you MUST use the same language as the user message you are summarizing
|
||||
- Title must be grammatically correct and read naturally - no word salad
|
||||
- Never include tool names in the title (e.g. "read tool", "bash tool", "edit tool")
|
||||
- Focus on the main topic or question the user needs to retrieve
|
||||
- Vary your phrasing - avoid repetitive patterns like always starting with "Analyzing"
|
||||
- When a file is mentioned, focus on WHAT the user wants to do WITH the file, not just that they shared it
|
||||
- Keep exact: technical terms, numbers, filenames, HTTP codes
|
||||
- Remove: the, this, my, a, an
|
||||
- Never assume tech stack
|
||||
- Never use tools
|
||||
- NEVER respond to questions, just generate a title for the conversation
|
||||
- The title should NEVER include "summarizing" or "generating" when generating a title
|
||||
- DO NOT SAY YOU CANNOT GENERATE A TITLE OR COMPLAIN ABOUT THE INPUT
|
||||
- Always output something meaningful, even if the input is minimal.
|
||||
- If the user message is short or conversational (e.g. "hello", "lol", "what's up", "hey"):
|
||||
→ create a title that reflects the user's tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.)
|
||||
</rules>
|
||||
|
||||
<examples>
|
||||
"debug 500 errors in production" → Debugging production 500 errors
|
||||
"refactor user service" → Refactoring user service
|
||||
"why is app.js failing" → app.js failure investigation
|
||||
"implement rate limiting" → Rate limiting implementation
|
||||
"how do I connect postgres to my API" → Postgres API connection
|
||||
"best practices for React hooks" → React hooks best practices
|
||||
"@src/auth.ts can you add refresh token support" → Auth refresh token support
|
||||
"@utils/parser.ts this is broken" → Parser bug fix
|
||||
"look at @config.json" → Config review
|
||||
"@App.tsx add dark mode toggle" → Dark mode toggle in App
|
||||
</examples>
|
||||
126
packages/opencode/src/agent/prompt/worker.txt
Normal file
126
packages/opencode/src/agent/prompt/worker.txt
Normal file
@@ -0,0 +1,126 @@
|
||||
# Worker Agent
|
||||
|
||||
你是执行器 Worker。你根据任务类型在两种模式之间切换:**EXECUTE**(执行)和 **DEBUG**(调试)。
|
||||
|
||||
## EXECUTE 模式
|
||||
|
||||
当 task type 为 `execute` 时进入此模式。
|
||||
|
||||
### 工作流程
|
||||
|
||||
1. **理解任务**:仔细阅读任务描述、验收标准、文件范围约束
|
||||
2. **先读后改**:修改任何文件前,先读取该文件了解现有代码
|
||||
3. **小步编辑**:每次只修改一个明确的变更点,不做无关重构
|
||||
4. **编译验证**:通过 shell 执行 `cmake --build .` 确认编译通过
|
||||
5. **测试验证**:通过 shell 执行 `ctest` 确认测试通过
|
||||
6. **静态分析(强制)**:通过 shell 执行 `cppcheck --enable=all <changed_files>`(C++ 项目不可跳过)
|
||||
7. **输出结果**:汇总变更文件列表、编译/测试/cppcheck 结果
|
||||
|
||||
### 完成标准
|
||||
|
||||
- 编译通过 + 测试通过 + cppcheck 无严重问题 = 完成
|
||||
- 必须修改的文件在 `expected_files` 范围内
|
||||
- 禁止修改 `denied_paths` 中的文件
|
||||
- 结果中必须包含 cppcheck 输出(C++ 项目)
|
||||
|
||||
### 约束
|
||||
|
||||
- 不修改 `scope.denied_paths` 中的文件
|
||||
- 不修改 `scope.preserved_paths` 中标记为保留的文件
|
||||
- 不做超出验收标准的修改
|
||||
- 不做提前抽象或无关重构
|
||||
|
||||
## DEBUG 模式
|
||||
|
||||
当 task type 为 `debug` 时进入此模式。
|
||||
|
||||
### 核心硬规则:先取证后修改
|
||||
|
||||
**未取证不可改代码**。修改任何代码前,必须至少完成一种取证:
|
||||
|
||||
- GUI 问题 → 通过 shell 执行 `ffmpeg -f kmsgrab` 截图
|
||||
- 网络问题 → 通过 shell 执行 `tcpdump` 抓包
|
||||
- C++ 问题 → 通过 shell 执行 `cppcheck --enable=all` 静态分析
|
||||
- 通用 → 代码追踪(读取相关文件、分析调用链)+ 日志分析
|
||||
|
||||
取证结果必须记录后才能开始修改代码。
|
||||
|
||||
### 调试工作流(7 步,不可跳过)
|
||||
|
||||
1. **确认症状**:描述观察到的问题现象
|
||||
2. **加载上下文**:读取相关源文件、日志、配置
|
||||
3. **复现**(可跳过):尝试复现问题,或标记为不可复现
|
||||
4. **定位根因**:必须有 ≥1 种证据,分析证据定位根因
|
||||
5. **修复**:创建 git commit 作为回滚点,然后修改代码
|
||||
6. **验证**:重新编译 + 测试,确认修复有效
|
||||
7. **记录**:写 `.air/local/debug/debug-log.md`,记录根因、取证结果、修复方案(必须持久化,不可仅口头总结)
|
||||
|
||||
关键约束:
|
||||
- 步骤 4 必须有证据才能进入步骤 5
|
||||
- 步骤 5 必须先创建回滚点再改代码
|
||||
- 修复后必须重新编译测试验证
|
||||
- 修复失败不超过 retry_budget 次
|
||||
- `.air/local/debug/debug-log.md` 必须追加本次调试记录(时间戳 + 根因 + 修复方案)
|
||||
|
||||
## 输出格式
|
||||
|
||||
完成任务后,输出结构化结果:
|
||||
|
||||
```
|
||||
## 结果
|
||||
状态:completed / failed / blocked
|
||||
|
||||
## 变更文件
|
||||
- path/to/file1.cpp(修改)
|
||||
- path/to/file2.h(新增)
|
||||
|
||||
## 验证
|
||||
- 编译:通过 / 失败(附错误摘要)
|
||||
- 测试:通过 / 失败(附失败详情)
|
||||
- cppcheck:通过 / 有警告(附详情)
|
||||
|
||||
## 证据(DEBUG 模式)
|
||||
- 类型:static_analysis / screenshot / pcap / code_trace
|
||||
- 摘要:...
|
||||
|
||||
## 风险
|
||||
- (如有)
|
||||
```
|
||||
|
||||
## 协作协议
|
||||
|
||||
### 上下游关系
|
||||
|
||||
```
|
||||
Scheduler(上游)→ 派发你 → 你执行任务并返回结果
|
||||
```
|
||||
|
||||
- **上游**:Scheduler 通过 `task` 工具派发你,你完成后结果自动返回给 Scheduler
|
||||
- **下游**:无。你是叶子节点,不派发任何子代理
|
||||
- **你没有 `task` 工具**,无法派发其他 agent
|
||||
|
||||
### 通信方式
|
||||
|
||||
- 你不需要主动与 Scheduler 通信,任务完成后结果自动返回
|
||||
- 如果任务 blocked 且无法自行解决,在结果中标注 `状态: blocked` 并说明原因
|
||||
- 不要尝试直接联系 Main Agent 或用户,你只对 Scheduler 负责
|
||||
|
||||
### 共享文件
|
||||
|
||||
```
|
||||
.air/shared/plan/plan.md ← 架构方案(参考用,理解任务上下文)
|
||||
.air/shared/plan/task-graph.json ← 任务图(查看自己的任务详情和依赖关系)
|
||||
.air/shared/plan/requirements.md ← 原始需求(参考用)
|
||||
.air/local/debug/debug-log.md ← 调试记录(DEBUG 模式:你来追加记录)
|
||||
```
|
||||
|
||||
### 任务接收
|
||||
|
||||
Scheduler 派发时会在 prompt 中提供:
|
||||
|
||||
- 任务描述和验收标准
|
||||
- 文件范围(可修改文件 + 禁止触碰路径)
|
||||
- 任务类型(`execute` 或 `debug`)
|
||||
- 依赖关系(前置任务结果,如有)
|
||||
|
||||
根据任务类型进入对应模式(EXECUTE / DEBUG),严格按流程执行。
|
||||
27
packages/opencode/src/agent/subagent-permissions.ts
Normal file
27
packages/opencode/src/agent/subagent-permissions.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||
import type { Agent } from "./agent"
|
||||
|
||||
/**
|
||||
* Build the `permission` ruleset for a subagent's session when it's spawned
|
||||
* via the task tool. Combines:
|
||||
*
|
||||
* 1. The parent session's deny rules and external_directory rules.
|
||||
* Parent agent restrictions only govern that agent; the subagent's own
|
||||
* permissions determine its capabilities.
|
||||
* 2. Default `todowrite` and `task` denies if the subagent's own ruleset
|
||||
* doesn't already permit them.
|
||||
*/
|
||||
export function deriveSubagentSessionPermission(input: {
|
||||
parentSessionPermission: PermissionV1.Ruleset
|
||||
subagent: Agent.Info
|
||||
}): PermissionV1.Ruleset {
|
||||
const canTask = input.subagent.permission.some((rule) => rule.permission === "task")
|
||||
const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite")
|
||||
return [
|
||||
...input.parentSessionPermission.filter(
|
||||
(rule) => rule.permission === "external_directory" || rule.action === "deny",
|
||||
),
|
||||
...(canTodo ? [] : [{ permission: "todowrite" as const, pattern: "*" as const, action: "deny" as const }]),
|
||||
...(canTask ? [] : [{ permission: "task" as const, pattern: "*" as const, action: "deny" as const }]),
|
||||
]
|
||||
}
|
||||
14
packages/opencode/src/audio.d.ts
vendored
Normal file
14
packages/opencode/src/audio.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
declare module "*.wav" {
|
||||
const file: string
|
||||
export default file
|
||||
}
|
||||
|
||||
declare module "*.mp3" {
|
||||
const file: string
|
||||
export default file
|
||||
}
|
||||
|
||||
declare module "*.wasm" {
|
||||
const file: string
|
||||
export default file
|
||||
}
|
||||
99
packages/opencode/src/auth/index.ts
Normal file
99
packages/opencode/src/auth/index.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import path from "path"
|
||||
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
|
||||
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
||||
|
||||
const file = path.join(Global.Path.data, "auth.json")
|
||||
|
||||
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
|
||||
|
||||
export class Oauth extends Schema.Class<Oauth>("OAuth")({
|
||||
type: Schema.Literal("oauth"),
|
||||
refresh: Schema.String,
|
||||
access: Schema.String,
|
||||
expires: NonNegativeInt,
|
||||
accountId: Schema.optional(Schema.String),
|
||||
enterpriseUrl: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export class Api extends Schema.Class<Api>("ApiAuth")({
|
||||
type: Schema.Literal("api"),
|
||||
key: Schema.String,
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
}) {}
|
||||
|
||||
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
|
||||
type: Schema.Literal("wellknown"),
|
||||
key: Schema.String,
|
||||
token: Schema.String,
|
||||
}) {}
|
||||
|
||||
export const Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" })
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
|
||||
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
|
||||
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
|
||||
readonly remove: (key: string) => Effect.Effect<void, AuthError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Auth") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* FSUtil.Service
|
||||
const decode = Schema.decodeUnknownOption(Info)
|
||||
|
||||
const all = Effect.fn("Auth.all")(function* () {
|
||||
if (process.env.OPENCODE_AUTH_CONTENT) {
|
||||
try {
|
||||
return JSON.parse(process.env.OPENCODE_AUTH_CONTENT)
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>
|
||||
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
|
||||
})
|
||||
|
||||
const get = Effect.fn("Auth.get")(function* (providerID: string) {
|
||||
return (yield* all())[providerID]
|
||||
})
|
||||
|
||||
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
|
||||
const norm = key.replace(/\/+$/, "")
|
||||
const data = yield* all()
|
||||
if (norm !== key) delete data[key]
|
||||
delete data[norm + "/"]
|
||||
yield* fsys
|
||||
.writeJson(file, { ...data, [norm]: info }, 0o600)
|
||||
.pipe(Effect.mapError(fail("Failed to write auth data")))
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Auth.remove")(function* (key: string) {
|
||||
const norm = key.replace(/\/+$/, "")
|
||||
const data = yield* all()
|
||||
delete data[key]
|
||||
delete data[norm]
|
||||
yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data")))
|
||||
})
|
||||
|
||||
return Service.of({ get, all, set, remove })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer))
|
||||
|
||||
export const node = LayerNode.make(layer, [FSUtil.node])
|
||||
|
||||
export * as Auth from "."
|
||||
39
packages/opencode/src/background/job.ts
Normal file
39
packages/opencode/src/background/job.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { BackgroundJob as CoreBackgroundJob } from "@opencode-ai/core/background-job"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Effect, Layer } from "effect"
|
||||
|
||||
export {
|
||||
Service,
|
||||
type ExtendInput,
|
||||
type Info,
|
||||
type Interface,
|
||||
type StartInput,
|
||||
type Status,
|
||||
type WaitInput,
|
||||
type WaitResult,
|
||||
} from "@opencode-ai/core/background-job"
|
||||
|
||||
/** Keeps the legacy service instance-scoped while sharing the core registry engine. */
|
||||
export const layer = Layer.effect(
|
||||
CoreBackgroundJob.Service,
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make(() => CoreBackgroundJob.make)
|
||||
return CoreBackgroundJob.Service.of({
|
||||
list: () => InstanceState.useEffect(state, (jobs) => jobs.list()),
|
||||
get: (id) => InstanceState.useEffect(state, (jobs) => jobs.get(id)),
|
||||
start: (input) => InstanceState.useEffect(state, (jobs) => jobs.start(input)),
|
||||
extend: (input) => InstanceState.useEffect(state, (jobs) => jobs.extend(input)),
|
||||
wait: (input) => InstanceState.useEffect(state, (jobs) => jobs.wait(input)),
|
||||
waitForPromotion: (id) => InstanceState.useEffect(state, (jobs) => jobs.waitForPromotion(id)),
|
||||
promote: (id) => InstanceState.useEffect(state, (jobs) => jobs.promote(id)),
|
||||
cancel: (id) => InstanceState.useEffect(state, (jobs) => jobs.cancel(id)),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
export const node = LayerNode.make(layer, [])
|
||||
|
||||
export * as BackgroundJob from "./job"
|
||||
22
packages/opencode/src/bus/global.ts
Normal file
22
packages/opencode/src/bus/global.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { EventEmitter } from "events"
|
||||
import { Identifier } from "@/id/id"
|
||||
|
||||
export type GlobalEvent = {
|
||||
directory?: string
|
||||
project?: string
|
||||
workspace?: string
|
||||
payload: any
|
||||
}
|
||||
|
||||
class GlobalBusEmitter extends EventEmitter<{
|
||||
event: [GlobalEvent]
|
||||
}> {
|
||||
override emit(eventName: "event", event: GlobalEvent): boolean {
|
||||
if (event.payload && typeof event.payload === "object" && !("id" in event.payload)) {
|
||||
event.payload.id = event.payload.syncEvent?.id ?? Identifier.create("evt", "ascending")
|
||||
}
|
||||
return super.emit(eventName, event)
|
||||
}
|
||||
}
|
||||
|
||||
export const GlobalBus = new GlobalBusEmitter()
|
||||
11
packages/opencode/src/cli/bootstrap.ts
Normal file
11
packages/opencode/src/cli/bootstrap.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { InstanceRuntime } from "../project/instance-runtime"
|
||||
import { context } from "../project/instance-context"
|
||||
|
||||
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
|
||||
const ctx = await InstanceRuntime.load({ directory })
|
||||
try {
|
||||
return await context.provide(ctx, cb)
|
||||
} finally {
|
||||
await InstanceRuntime.disposeInstance(ctx)
|
||||
}
|
||||
}
|
||||
264
packages/opencode/src/cli/cmd/account.ts
Normal file
264
packages/opencode/src/cli/cmd/account.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { cmd } from "./cmd"
|
||||
import { Duration, Effect, Match, Option } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { Account } from "@/account/account"
|
||||
import { AccountID, OrgID, PollExpired, type PollResult, type AccountError } from "@/account/schema"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import * as Prompt from "../effect/prompt"
|
||||
import open from "open"
|
||||
|
||||
const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() => undefined))
|
||||
|
||||
const println = (msg: string) => Effect.sync(() => UI.println(msg))
|
||||
|
||||
const dim = (value: string) => UI.Style.TEXT_DIM + value + UI.Style.TEXT_NORMAL
|
||||
|
||||
const activeSuffix = (isActive: boolean) => (isActive ? dim(" (active)") : "")
|
||||
|
||||
export const defaultConsoleUrl = "https://console.opencode.ai"
|
||||
|
||||
export const formatAccountLabel = (account: { email: string; url: string }, isActive: boolean) =>
|
||||
`${account.email} ${dim(account.url)}${activeSuffix(isActive)}`
|
||||
|
||||
const formatOrgChoiceLabel = (account: { email: string }, org: { name: string }, isActive: boolean) =>
|
||||
`${org.name} (${account.email})${activeSuffix(isActive)}`
|
||||
|
||||
export const formatOrgLine = (
|
||||
account: { email: string; url: string },
|
||||
org: { id: string; name: string },
|
||||
isActive: boolean,
|
||||
) => {
|
||||
const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " "
|
||||
const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name
|
||||
return ` ${dot} ${name} ${dim(account.email)} ${dim(account.url)} ${dim(org.id)}`
|
||||
}
|
||||
|
||||
const isActiveOrgChoice = (
|
||||
active: Option.Option<{ id: AccountID; active_org_id: OrgID | null }>,
|
||||
choice: { accountID: AccountID; orgID: OrgID },
|
||||
) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
|
||||
|
||||
const loginEffect = Effect.fn("login")(function* (url: string) {
|
||||
const service = yield* Account.Service
|
||||
|
||||
yield* Prompt.intro("Log in")
|
||||
const login = yield* service.login(url)
|
||||
|
||||
yield* Prompt.log.info("Go to: " + login.url)
|
||||
yield* Prompt.log.info("Enter code: " + login.user)
|
||||
yield* openBrowser(login.url)
|
||||
|
||||
const s = Prompt.spinner()
|
||||
yield* s.start("Waiting for authorization...")
|
||||
|
||||
const poll = (wait: Duration.Duration): Effect.Effect<PollResult, AccountError> =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.sleep(wait)
|
||||
const result = yield* service.poll(login)
|
||||
if (result._tag === "PollPending") return yield* poll(wait)
|
||||
if (result._tag === "PollSlow") return yield* poll(Duration.sum(wait, Duration.seconds(5)))
|
||||
return result
|
||||
})
|
||||
|
||||
const result = yield* poll(login.interval).pipe(
|
||||
Effect.timeout(login.expiry),
|
||||
Effect.catchTag("TimeoutError", () => Effect.succeed(new PollExpired())),
|
||||
)
|
||||
|
||||
yield* Match.valueTags(result, {
|
||||
PollSuccess: (r) =>
|
||||
Effect.gen(function* () {
|
||||
yield* s.stop("Logged in as " + r.email)
|
||||
yield* Prompt.outro("Done")
|
||||
}),
|
||||
PollExpired: () => s.stop("Device code expired", 1),
|
||||
PollDenied: () => s.stop("Authorization denied", 1),
|
||||
PollError: (r) => s.stop("Error: " + String(r.cause), 1),
|
||||
PollPending: () => s.stop("Unexpected state", 1),
|
||||
PollSlow: () => s.stop("Unexpected state", 1),
|
||||
})
|
||||
})
|
||||
|
||||
const logoutEffect = Effect.fn("logout")(function* (email?: string) {
|
||||
const service = yield* Account.Service
|
||||
const accounts = yield* service.list()
|
||||
if (accounts.length === 0) return yield* println("Not logged in")
|
||||
|
||||
if (email) {
|
||||
const match = accounts.find((a) => a.email === email)
|
||||
if (!match) return yield* println("Account not found: " + email)
|
||||
yield* service.remove(match.id)
|
||||
yield* Prompt.outro("Logged out from " + email)
|
||||
return
|
||||
}
|
||||
|
||||
const active = yield* service.active()
|
||||
const activeID = Option.map(active, (a) => a.id)
|
||||
|
||||
yield* Prompt.intro("Log out")
|
||||
|
||||
const opts = accounts.map((a) => {
|
||||
const isActive = Option.isSome(activeID) && activeID.value === a.id
|
||||
return {
|
||||
value: a,
|
||||
label: formatAccountLabel(a, isActive),
|
||||
}
|
||||
})
|
||||
|
||||
const selected = yield* Prompt.select({ message: "Select account to log out", options: opts })
|
||||
if (Option.isNone(selected)) return
|
||||
|
||||
yield* service.remove(selected.value.id)
|
||||
yield* Prompt.outro("Logged out from " + selected.value.email)
|
||||
})
|
||||
|
||||
interface OrgChoice {
|
||||
orgID: OrgID
|
||||
accountID: AccountID
|
||||
label: string
|
||||
}
|
||||
|
||||
const switchEffect = Effect.fn("switch")(function* () {
|
||||
const service = yield* Account.Service
|
||||
|
||||
const groups = yield* service.orgsByAccount()
|
||||
if (groups.length === 0) return yield* println("Not logged in")
|
||||
|
||||
const active = yield* service.active()
|
||||
|
||||
const opts = groups.flatMap((group) =>
|
||||
group.orgs.map((org) => {
|
||||
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id })
|
||||
return {
|
||||
value: { orgID: org.id, accountID: group.account.id, label: org.name },
|
||||
label: formatOrgChoiceLabel(group.account, org, isActive),
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (opts.length === 0) return yield* println("No orgs found")
|
||||
|
||||
yield* Prompt.intro("Switch org")
|
||||
|
||||
const selected = yield* Prompt.select<OrgChoice>({ message: "Select org", options: opts })
|
||||
if (Option.isNone(selected)) return
|
||||
|
||||
const choice = selected.value
|
||||
yield* service.use(choice.accountID, Option.some(choice.orgID))
|
||||
yield* Prompt.outro("Switched to " + choice.label)
|
||||
})
|
||||
|
||||
const orgsEffect = Effect.fn("orgs")(function* () {
|
||||
const service = yield* Account.Service
|
||||
|
||||
const groups = yield* service.orgsByAccount()
|
||||
if (groups.length === 0) return yield* println("No accounts found")
|
||||
if (!groups.some((group) => group.orgs.length > 0)) return yield* println("No orgs found")
|
||||
|
||||
const active = yield* service.active()
|
||||
|
||||
for (const group of groups) {
|
||||
for (const org of group.orgs) {
|
||||
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id })
|
||||
yield* println(formatOrgLine(group.account, org, isActive))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const openEffect = Effect.fn("open")(function* () {
|
||||
const service = yield* Account.Service
|
||||
const active = yield* service.active()
|
||||
if (Option.isNone(active)) return yield* println("No active account")
|
||||
|
||||
const url = active.value.url
|
||||
yield* openBrowser(url)
|
||||
yield* Prompt.outro("Opened " + url)
|
||||
})
|
||||
|
||||
export const LoginCommand = effectCmd({
|
||||
command: "login [url]",
|
||||
describe: false,
|
||||
instance: false,
|
||||
builder: (yargs) =>
|
||||
yargs.positional("url", {
|
||||
describe: "server URL",
|
||||
type: "string",
|
||||
}),
|
||||
handler: Effect.fn("Cli.account.login")(function* (args) {
|
||||
UI.empty()
|
||||
yield* Effect.orDie(loginEffect(args.url ?? defaultConsoleUrl))
|
||||
}),
|
||||
})
|
||||
|
||||
export const LogoutCommand = effectCmd({
|
||||
command: "logout [email]",
|
||||
describe: false,
|
||||
instance: false,
|
||||
builder: (yargs) =>
|
||||
yargs.positional("email", {
|
||||
describe: "account email to log out from",
|
||||
type: "string",
|
||||
}),
|
||||
handler: Effect.fn("Cli.account.logout")(function* (args) {
|
||||
UI.empty()
|
||||
yield* Effect.orDie(logoutEffect(args.email))
|
||||
}),
|
||||
})
|
||||
|
||||
export const SwitchCommand = effectCmd({
|
||||
command: "switch",
|
||||
describe: false,
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.account.switch")(function* () {
|
||||
UI.empty()
|
||||
yield* Effect.orDie(switchEffect())
|
||||
}),
|
||||
})
|
||||
|
||||
export const OrgsCommand = effectCmd({
|
||||
command: "orgs",
|
||||
describe: false,
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.account.orgs")(function* () {
|
||||
UI.empty()
|
||||
yield* Effect.orDie(orgsEffect())
|
||||
}),
|
||||
})
|
||||
|
||||
export const OpenCommand = effectCmd({
|
||||
command: "open",
|
||||
describe: false,
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.account.open")(function* () {
|
||||
UI.empty()
|
||||
yield* Effect.orDie(openEffect())
|
||||
}),
|
||||
})
|
||||
|
||||
export const ConsoleCommand = cmd({
|
||||
command: "console",
|
||||
describe: false,
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command({
|
||||
...LoginCommand,
|
||||
describe: "log in to console",
|
||||
})
|
||||
.command({
|
||||
...LogoutCommand,
|
||||
describe: "log out from console",
|
||||
})
|
||||
.command({
|
||||
...SwitchCommand,
|
||||
describe: "switch active org",
|
||||
})
|
||||
.command({
|
||||
...OrgsCommand,
|
||||
describe: "list orgs",
|
||||
})
|
||||
.command({
|
||||
...OpenCommand,
|
||||
describe: "open active console account",
|
||||
})
|
||||
.demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
73
packages/opencode/src/cli/cmd/acp.ts
Normal file
73
packages/opencode/src/cli/cmd/acp.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Effect } from "effect"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { ACPProfile } from "@/acp/profile"
|
||||
|
||||
export const AcpCommand = effectCmd({
|
||||
command: "acp",
|
||||
describe: "start ACP (Agent Client Protocol) server",
|
||||
builder: (yargs) => {
|
||||
return withNetworkOptions(yargs).option("cwd", {
|
||||
describe: "working directory",
|
||||
type: "string",
|
||||
default: process.cwd(),
|
||||
})
|
||||
},
|
||||
handler: Effect.fn("Cli.acp")(function* (args) {
|
||||
const { Server } = yield* Effect.promise(() => import("@/server/server"))
|
||||
const { ACP } = yield* Effect.promise(() => import("@/acp/agent"))
|
||||
ACPProfile.mark("cli.acp.handler")
|
||||
process.env.OPENCODE_CLIENT = "acp"
|
||||
const opts = yield* resolveNetworkOptions(args)
|
||||
const server = yield* Effect.promise(() => ACPProfile.measure("cli.acp.server.listen", () => Server.listen(opts)))
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: `http://${server.hostname}:${server.port}`,
|
||||
headers: ServerAuth.headers(),
|
||||
})
|
||||
|
||||
const input = new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
process.stdout.write(chunk, (err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
const output = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
process.stdin.on("data", (chunk: Buffer) => {
|
||||
controller.enqueue(new Uint8Array(chunk))
|
||||
})
|
||||
process.stdin.on("end", () => controller.close())
|
||||
process.stdin.on("error", (err) => controller.error(err))
|
||||
},
|
||||
})
|
||||
|
||||
const stream = ndJsonStream(input, output)
|
||||
const agent = ACP.init({ sdk })
|
||||
|
||||
new AgentSideConnection((conn) => {
|
||||
ACPProfile.mark("cli.acp.connection.create")
|
||||
return agent.create(conn)
|
||||
}, stream)
|
||||
|
||||
yield* Effect.logInfo("setup connection")
|
||||
process.stdin.resume()
|
||||
yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
process.stdin.on("end", () => resolve())
|
||||
process.stdin.on("error", reject)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
259
packages/opencode/src/cli/cmd/agent.ts
Normal file
259
packages/opencode/src/cli/cmd/agent.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { cmd } from "./cmd"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { UI } from "../ui"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import matter from "gray-matter"
|
||||
import { EOL } from "os"
|
||||
import type { Argv } from "yargs"
|
||||
import { Effect } from "effect"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
|
||||
type AgentMode = "all" | "primary" | "subagent"
|
||||
|
||||
// Permission keys (not raw tool names). Multiple tools can map to a single
|
||||
// permission — e.g. write/edit/apply_patch all gate on `edit` — so we configure
|
||||
// agents at the permission level to match how the runtime actually enforces it.
|
||||
const AVAILABLE_PERMISSIONS = [
|
||||
"bash",
|
||||
"read",
|
||||
"edit",
|
||||
"glob",
|
||||
"grep",
|
||||
"webfetch",
|
||||
"task",
|
||||
"todowrite",
|
||||
"websearch",
|
||||
"lsp",
|
||||
"skill",
|
||||
]
|
||||
|
||||
const AgentCreateCommand = effectCmd({
|
||||
command: "create",
|
||||
describe: "create a new agent",
|
||||
builder: (yargs: Argv) =>
|
||||
yargs
|
||||
.option("path", {
|
||||
type: "string",
|
||||
describe: "directory path to generate the agent file",
|
||||
})
|
||||
.option("description", {
|
||||
type: "string",
|
||||
describe: "what the agent should do",
|
||||
})
|
||||
.option("mode", {
|
||||
type: "string",
|
||||
describe: "agent mode",
|
||||
choices: ["all", "primary", "subagent"] as const,
|
||||
})
|
||||
.option("permissions", {
|
||||
type: "string",
|
||||
alias: ["tools"],
|
||||
describe: `comma-separated list of permissions to allow (default: all). Available: "${AVAILABLE_PERMISSIONS.join(", ")}"`,
|
||||
})
|
||||
.option("model", {
|
||||
type: "string",
|
||||
alias: ["m"],
|
||||
describe: "model to use in the format of provider/model",
|
||||
}),
|
||||
handler: Effect.fn("Cli.agent.create")(function* (args) {
|
||||
const { InstanceRef } = yield* Effect.promise(() => import("@/effect/instance-ref"))
|
||||
const { Agent } = yield* Effect.promise(() => import("../../agent/agent"))
|
||||
const { Provider } = yield* Effect.promise(() => import("@/provider/provider"))
|
||||
const maybeCtx = yield* InstanceRef
|
||||
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
|
||||
const ctx = maybeCtx
|
||||
const agentSvc = yield* Agent.Service
|
||||
const runLocalEffect = <A, E>(effect: Effect.Effect<A, E>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provideService(InstanceRef, ctx)))
|
||||
yield* Effect.promise(async () => {
|
||||
const cliPath = args.path
|
||||
const cliDescription = args.description
|
||||
const cliMode = args.mode as AgentMode | undefined
|
||||
const perms = args.permissions
|
||||
|
||||
const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined
|
||||
|
||||
if (!isFullyNonInteractive) {
|
||||
UI.empty()
|
||||
prompts.intro("Create agent")
|
||||
}
|
||||
|
||||
const project = ctx.project
|
||||
|
||||
// Determine scope/path
|
||||
let targetPath: string
|
||||
if (cliPath) {
|
||||
targetPath = path.join(cliPath, "agents")
|
||||
} else {
|
||||
let scope: "global" | "project" = "global"
|
||||
if (project.vcs === "git") {
|
||||
const scopeResult = await prompts.select({
|
||||
message: "Location",
|
||||
options: [
|
||||
{
|
||||
label: "Current project",
|
||||
value: "project" as const,
|
||||
hint: ctx.worktree,
|
||||
},
|
||||
{
|
||||
label: "Global",
|
||||
value: "global" as const,
|
||||
hint: Global.Path.config,
|
||||
},
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
|
||||
scope = scopeResult
|
||||
}
|
||||
targetPath = path.join(scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"), "agents")
|
||||
}
|
||||
|
||||
// Get description
|
||||
let description: string
|
||||
if (cliDescription) {
|
||||
description = cliDescription
|
||||
} else {
|
||||
const query = await prompts.text({
|
||||
message: "Description",
|
||||
placeholder: "What should this agent do?",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(query)) throw new UI.CancelledError()
|
||||
description = query
|
||||
}
|
||||
|
||||
// Generate agent
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Generating agent configuration...")
|
||||
const model = args.model ? Provider.parseModel(args.model) : undefined
|
||||
const generated = await runLocalEffect(agentSvc.generate({ description, model })).catch((error) => {
|
||||
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
|
||||
if (isFullyNonInteractive) process.exit(1)
|
||||
throw new UI.CancelledError()
|
||||
})
|
||||
spinner.stop(`Agent ${generated.identifier} generated`)
|
||||
|
||||
// Select permissions to allow
|
||||
let selected: string[]
|
||||
if (perms !== undefined) {
|
||||
selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS
|
||||
} else {
|
||||
const result = await prompts.multiselect({
|
||||
message: "Select permissions to allow (Space to toggle)",
|
||||
options: AVAILABLE_PERMISSIONS.map((permission) => ({
|
||||
label: permission,
|
||||
value: permission,
|
||||
})),
|
||||
initialValues: AVAILABLE_PERMISSIONS,
|
||||
})
|
||||
if (prompts.isCancel(result)) throw new UI.CancelledError()
|
||||
selected = result
|
||||
}
|
||||
|
||||
// Get mode
|
||||
let mode: AgentMode
|
||||
if (cliMode) {
|
||||
mode = cliMode
|
||||
} else {
|
||||
const modeResult = await prompts.select({
|
||||
message: "Agent mode",
|
||||
options: [
|
||||
{
|
||||
label: "All",
|
||||
value: "all" as const,
|
||||
hint: "Can function in both primary and subagent roles",
|
||||
},
|
||||
{
|
||||
label: "Primary",
|
||||
value: "primary" as const,
|
||||
hint: "Acts as a primary/main agent",
|
||||
},
|
||||
{
|
||||
label: "Subagent",
|
||||
value: "subagent" as const,
|
||||
hint: "Can be used as a subagent by other agents",
|
||||
},
|
||||
],
|
||||
initialValue: "all" as const,
|
||||
})
|
||||
if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
|
||||
mode = modeResult
|
||||
}
|
||||
|
||||
// Build permissions config — deny anything not explicitly selected.
|
||||
const permissions: Record<string, "deny"> = {}
|
||||
for (const permission of AVAILABLE_PERMISSIONS) {
|
||||
if (!selected.includes(permission)) {
|
||||
permissions[permission] = "deny"
|
||||
}
|
||||
}
|
||||
|
||||
// Build frontmatter
|
||||
const frontmatter: {
|
||||
description: string
|
||||
mode: AgentMode
|
||||
permission?: Record<string, "deny">
|
||||
} = {
|
||||
description: generated.whenToUse,
|
||||
mode,
|
||||
}
|
||||
if (Object.keys(permissions).length > 0) {
|
||||
frontmatter.permission = permissions
|
||||
}
|
||||
|
||||
// Write file
|
||||
const content = matter.stringify(generated.systemPrompt, frontmatter)
|
||||
const filePath = path.join(targetPath, `${generated.identifier}.md`)
|
||||
|
||||
await fs.mkdir(targetPath, { recursive: true })
|
||||
|
||||
if (await Filesystem.exists(filePath)) {
|
||||
if (isFullyNonInteractive) {
|
||||
console.error(`Error: Agent file already exists: ${filePath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
prompts.log.error(`Agent file already exists: ${filePath}`)
|
||||
throw new UI.CancelledError()
|
||||
}
|
||||
|
||||
await Filesystem.write(filePath, content)
|
||||
|
||||
if (isFullyNonInteractive) {
|
||||
console.log(filePath)
|
||||
} else {
|
||||
prompts.log.success(`Agent created: ${filePath}`)
|
||||
prompts.outro("Done")
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
const AgentListCommand = effectCmd({
|
||||
command: "list",
|
||||
describe: "list all available agents",
|
||||
handler: Effect.fn("Cli.agent.list")(function* () {
|
||||
const { Agent } = yield* Effect.promise(() => import("../../agent/agent"))
|
||||
const agents = yield* Agent.Service.use((svc) => svc.list())
|
||||
const sortedAgents = agents.sort((a, b) => {
|
||||
if (a.native !== b.native) {
|
||||
return a.native ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
for (const agent of sortedAgents) {
|
||||
process.stdout.write(`${agent.name} (${agent.mode})` + EOL)
|
||||
process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL)
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export const AgentCommand = cmd({
|
||||
command: "agent",
|
||||
describe: "manage agents",
|
||||
builder: (yargs) => yargs.command(AgentCreateCommand).command(AgentListCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
97
packages/opencode/src/cli/cmd/attach.ts
Normal file
97
packages/opencode/src/cli/cmd/attach.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { cmd } from "./cmd"
|
||||
import { UI } from "@/cli/ui"
|
||||
import { errorMessage } from "@opencode-ai/tui/util/error"
|
||||
import { validateSession } from "../tui/validate-session"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
|
||||
export const AttachCommand = cmd({
|
||||
command: "attach <url>",
|
||||
describe: "attach to a running aircoding server",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional("url", {
|
||||
type: "string",
|
||||
describe: "http://localhost:4096",
|
||||
demandOption: true,
|
||||
})
|
||||
.option("dir", {
|
||||
type: "string",
|
||||
description: "directory to run in",
|
||||
})
|
||||
.option("continue", {
|
||||
alias: ["c"],
|
||||
describe: "continue the last session",
|
||||
type: "boolean",
|
||||
})
|
||||
.option("session", {
|
||||
alias: ["s"],
|
||||
type: "string",
|
||||
describe: "session id to continue",
|
||||
})
|
||||
.option("fork", {
|
||||
type: "boolean",
|
||||
describe: "fork the session when continuing (use with --continue or --session)",
|
||||
})
|
||||
.option("password", {
|
||||
alias: ["p"],
|
||||
type: "string",
|
||||
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
|
||||
})
|
||||
.option("username", {
|
||||
alias: ["u"],
|
||||
type: "string",
|
||||
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'aircoding')",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
const { TuiConfig } = await import("@/config/tui")
|
||||
if (args.fork && !args.continue && !args.session) {
|
||||
UI.error("--fork requires --continue or --session")
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const directory = (() => {
|
||||
if (!args.dir) return undefined
|
||||
try {
|
||||
process.chdir(args.dir)
|
||||
return process.cwd()
|
||||
} catch {
|
||||
// If the directory doesn't exist locally (remote attach), pass it through.
|
||||
return args.dir
|
||||
}
|
||||
})()
|
||||
const headers = ServerAuth.headers({ password: args.password, username: args.username })
|
||||
const config = await TuiConfig.get()
|
||||
|
||||
try {
|
||||
await validateSession({
|
||||
url: args.url,
|
||||
sessionID: args.session,
|
||||
directory,
|
||||
headers,
|
||||
})
|
||||
} catch (error) {
|
||||
UI.error(errorMessage(error))
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const { Effect } = await import("effect")
|
||||
const { run } = await import("../tui/layer")
|
||||
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
|
||||
await Effect.runPromise(
|
||||
run({
|
||||
url: args.url,
|
||||
config,
|
||||
pluginHost: createLegacyTuiPluginHost(),
|
||||
args: {
|
||||
continue: args.continue,
|
||||
sessionID: args.session,
|
||||
fork: args.fork,
|
||||
},
|
||||
directory,
|
||||
headers,
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
7
packages/opencode/src/cli/cmd/cmd.ts
Normal file
7
packages/opencode/src/cli/cmd/cmd.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { CommandModule } from "yargs"
|
||||
|
||||
export type WithDoubleDash<T> = T & { "--"?: string[] }
|
||||
|
||||
export function cmd<T, U>(input: CommandModule<T, WithDoubleDash<U>>) {
|
||||
return input
|
||||
}
|
||||
62
packages/opencode/src/cli/cmd/db.ts
Normal file
62
packages/opencode/src/cli/cmd/db.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { spawn } from "child_process"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Effect } from "effect"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
|
||||
const QueryCommand = effectCmd({
|
||||
command: "$0 [query]",
|
||||
describe: "open an interactive sqlite3 shell or run a query",
|
||||
instance: false,
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs
|
||||
.positional("query", {
|
||||
type: "string",
|
||||
describe: "SQL query to execute",
|
||||
})
|
||||
.option("format", {
|
||||
type: "string",
|
||||
choices: ["json", "tsv"],
|
||||
default: "tsv",
|
||||
describe: "Output format",
|
||||
})
|
||||
},
|
||||
handler: Effect.fn("Cli.db.query")(function* (args: { query?: string; format: string }) {
|
||||
const query = args.query as string | undefined
|
||||
if (query) {
|
||||
const { db } = yield* Database.Service
|
||||
const result = yield* db.all<Record<string, unknown>>(sql.raw(query)).pipe(Effect.orDie)
|
||||
if (args.format === "json") console.log(JSON.stringify(result, null, 2))
|
||||
else if (result.length > 0) {
|
||||
const keys = Object.keys(result[0])
|
||||
console.log(keys.join("\t"))
|
||||
for (const row of result) console.log(keys.map((key) => row[key]).join("\t"))
|
||||
}
|
||||
return
|
||||
}
|
||||
const child = spawn("sqlite3", [Database.path()], {
|
||||
stdio: "inherit",
|
||||
})
|
||||
yield* Effect.promise(() => new Promise((resolve) => child.on("close", resolve)))
|
||||
}),
|
||||
})
|
||||
|
||||
const PathCommand = effectCmd({
|
||||
command: "path",
|
||||
describe: "print the database path",
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.db.path")(function* () {
|
||||
console.log(Database.path())
|
||||
}),
|
||||
})
|
||||
|
||||
export const DbCommand = effectCmd({
|
||||
command: "db",
|
||||
describe: "database tools",
|
||||
instance: false,
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs.command(QueryCommand).command(PathCommand).demandCommand()
|
||||
},
|
||||
handler: Effect.fn("Cli.db")(function* () {}),
|
||||
})
|
||||
193
packages/opencode/src/cli/cmd/debug/agent.handler.ts
Normal file
193
packages/opencode/src/cli/cmd/debug/agent.handler.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||
import { EOL } from "os"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { basename } from "path"
|
||||
import { Cause, Effect } from "effect"
|
||||
import { Agent } from "../../../agent/agent"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Session } from "@/session/session"
|
||||
import type { MessageV2 } from "../../../session/message-v2"
|
||||
import { MessageID, PartID } from "../../../session/schema"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Permission } from "../../../permission"
|
||||
import { iife } from "../../../util/iife"
|
||||
import { fail } from "../../effect-cmd"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
|
||||
export const debugAgent = Effect.fn("Cli.debug.agent")(function* (args: {
|
||||
name: string
|
||||
tool?: string
|
||||
params?: string
|
||||
}) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
return yield* run(args, ctx)
|
||||
})
|
||||
|
||||
const run = Effect.fn("Cli.debug.agent.body")(function* (
|
||||
args: { name: string; tool?: string; params?: string },
|
||||
ctx: InstanceContext,
|
||||
) {
|
||||
const agentName = args.name
|
||||
const agent = yield* Agent.Service.use((svc) => svc.get(agentName))
|
||||
if (!agent) {
|
||||
process.stderr.write(
|
||||
`Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL,
|
||||
)
|
||||
return yield* fail("", 1)
|
||||
}
|
||||
const availableTools = yield* getAvailableTools(agent)
|
||||
const resolvedTools = resolveTools(agent, availableTools)
|
||||
const toolID = args.tool
|
||||
if (toolID) {
|
||||
const tool = availableTools.find((item) => item.id === toolID)
|
||||
if (!tool) {
|
||||
process.stderr.write(`Tool ${toolID} not found for agent ${agentName}` + EOL)
|
||||
return yield* fail("", 1)
|
||||
}
|
||||
if (resolvedTools[toolID] === false) {
|
||||
process.stderr.write(`Tool ${toolID} is disabled for agent ${agentName}` + EOL)
|
||||
return yield* fail("", 1)
|
||||
}
|
||||
const params = parseToolParams(args.params)
|
||||
const toolCtx = yield* createToolContext(agent, ctx)
|
||||
const result = yield* tool.execute(params, toolCtx)
|
||||
process.stdout.write(JSON.stringify({ tool: toolID, input: params, result }, null, 2) + EOL)
|
||||
return
|
||||
}
|
||||
|
||||
const output = {
|
||||
...agent,
|
||||
tools: resolvedTools,
|
||||
}
|
||||
process.stdout.write(JSON.stringify(output, null, 2) + EOL)
|
||||
})
|
||||
|
||||
const getAvailableTools = Effect.fn("Cli.debug.agent.getAvailableTools")(function* (agent: Agent.Info) {
|
||||
const provider = yield* Provider.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const model =
|
||||
agent.model ??
|
||||
(yield* provider.defaultModel().pipe(
|
||||
Effect.matchCauseEffect({
|
||||
onSuccess: Effect.succeed,
|
||||
onFailure: (cause) => {
|
||||
const error = Cause.squash(cause) as Provider.DefaultModelError
|
||||
if (error instanceof Provider.ModelNotFoundError) {
|
||||
return fail(`Model not found: ${error.providerID}/${error.modelID}`)
|
||||
}
|
||||
if (error instanceof Provider.NoModelsError) return fail(`No models found for provider ${error.providerID}`)
|
||||
return fail("No providers found")
|
||||
},
|
||||
}),
|
||||
))
|
||||
return yield* registry.tools({ ...model, agent })
|
||||
})
|
||||
|
||||
function resolveTools(agent: Agent.Info, availableTools: { id: string }[]) {
|
||||
const disabled = Permission.disabled(
|
||||
availableTools.map((tool) => tool.id),
|
||||
agent.permission,
|
||||
)
|
||||
const resolved: Record<string, boolean> = {}
|
||||
for (const tool of availableTools) {
|
||||
resolved[tool.id] = !disabled.has(tool.id)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
function parseToolParams(input?: string) {
|
||||
if (!input) return {}
|
||||
const trimmed = input.trim()
|
||||
if (trimmed.length === 0) return {}
|
||||
|
||||
const parsed = iife(() => {
|
||||
try {
|
||||
return JSON.parse(trimmed)
|
||||
} catch (jsonError) {
|
||||
try {
|
||||
return new Function(`return (${trimmed})`)()
|
||||
} catch (evalError) {
|
||||
throw new Error(
|
||||
`Failed to parse --params. Use JSON or a JS object literal. JSON error: ${jsonError}. Eval error: ${evalError}.`,
|
||||
{ cause: evalError },
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Tool params must be an object.")
|
||||
}
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
|
||||
const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(function* (
|
||||
agent: Agent.Info,
|
||||
ctx: InstanceContext,
|
||||
) {
|
||||
const sessionSvc = yield* Session.Service
|
||||
const session = yield* sessionSvc.create({ title: `Debug tool run (${agent.name})` })
|
||||
const messageID = MessageID.ascending()
|
||||
const model = agent.model
|
||||
? agent.model
|
||||
: yield* Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
return yield* provider.defaultModel().pipe(
|
||||
Effect.matchCauseEffect({
|
||||
onSuccess: Effect.succeed,
|
||||
onFailure: (cause) => {
|
||||
const error = Cause.squash(cause) as Provider.DefaultModelError
|
||||
if (error instanceof Provider.ModelNotFoundError) {
|
||||
return fail(`Model not found: ${error.providerID}/${error.modelID}`)
|
||||
}
|
||||
if (error instanceof Provider.NoModelsError)
|
||||
return fail(`No models found for provider ${error.providerID}`)
|
||||
return fail("No providers found")
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
const now = Date.now()
|
||||
const message: SessionV1.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: ctx.directory,
|
||||
root: ctx.worktree,
|
||||
},
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
}
|
||||
yield* sessionSvc.updateMessage(message)
|
||||
|
||||
const ruleset = Permission.merge(agent.permission, session.permission ?? [])
|
||||
|
||||
return {
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
callID: PartID.ascending(),
|
||||
agent: agent.name,
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask(req: Omit<PermissionV1.Request, "id" | "sessionID" | "tool">) {
|
||||
return Effect.sync(() => {
|
||||
for (const pattern of req.patterns) {
|
||||
const rule = Permission.evaluate(req.permission, pattern, ruleset)
|
||||
if (rule.action === "deny") {
|
||||
throw new PermissionV1.DeniedError({ ruleset })
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
})
|
||||
27
packages/opencode/src/cli/cmd/debug/agent.ts
Normal file
27
packages/opencode/src/cli/cmd/debug/agent.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Effect } from "effect"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
|
||||
export const AgentCommand = effectCmd({
|
||||
command: "agent <name>",
|
||||
describe: "show agent configuration details",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional("name", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
description: "Agent name",
|
||||
})
|
||||
.option("tool", {
|
||||
type: "string",
|
||||
description: "Tool id to execute",
|
||||
})
|
||||
.option("params", {
|
||||
type: "string",
|
||||
description: "Tool params as JSON or a JS object literal",
|
||||
}),
|
||||
handler: (args) =>
|
||||
Effect.gen(function* () {
|
||||
const { debugAgent } = yield* Effect.promise(() => import("./agent.handler"))
|
||||
return yield* debugAgent(args)
|
||||
}),
|
||||
})
|
||||
14
packages/opencode/src/cli/cmd/debug/config.ts
Normal file
14
packages/opencode/src/cli/cmd/debug/config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
|
||||
export const ConfigCommand = effectCmd({
|
||||
command: "config",
|
||||
describe: "show resolved configuration",
|
||||
builder: (yargs) => yargs,
|
||||
handler: Effect.fn("Cli.debug.config")(function* () {
|
||||
const { Config } = yield* Effect.promise(() => import("@/config/config"))
|
||||
const config = yield* Config.Service.use((cfg) => cfg.get())
|
||||
process.stdout.write(JSON.stringify(config, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
73
packages/opencode/src/cli/cmd/debug/file.ts
Normal file
73
packages/opencode/src/cli/cmd/debug/file.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
const filesystem = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(
|
||||
Effect.provide(LocationServiceMap.get(Location.Ref.make({ directory: AbsolutePath.make(process.cwd()) }))),
|
||||
Effect.provide(LocationServiceMap.layer),
|
||||
)
|
||||
|
||||
const FileSearchCommand = effectCmd({
|
||||
command: "search <query>",
|
||||
describe: "search files by query",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("query", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
description: "Search query",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.file.search")(function* (args) {
|
||||
const results = yield* Effect.orDie(filesystem(FileSystem.Service.use((svc) => svc.find({ query: args.query }))))
|
||||
process.stdout.write(results.map((item) => item.path).join(EOL) + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
const FileReadCommand = effectCmd({
|
||||
command: "read <path>",
|
||||
describe: "read file contents as JSON",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("path", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
description: "File path to read",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.file.read")(function* (args) {
|
||||
const file = yield* filesystem(FileSystem.Service.use((svc) => svc.read({ path: RelativePath.make(args.path) })))
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
{ content: Buffer.from(file.content).toString("base64"), encoding: "base64", mime: file.mime },
|
||||
null,
|
||||
2,
|
||||
) + EOL,
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
const FileListCommand = effectCmd({
|
||||
command: "list <path>",
|
||||
describe: "list files in a directory",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("path", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
description: "File path to list",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.file.list")(function* (args) {
|
||||
const files = yield* filesystem(FileSystem.Service.use((svc) => svc.list({ path: RelativePath.make(args.path) })))
|
||||
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
export const FileCommand = cmd({
|
||||
command: "file",
|
||||
describe: "file system debugging utilities",
|
||||
builder: (yargs) =>
|
||||
yargs.command(FileReadCommand).command(FileListCommand).command(FileSearchCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
87
packages/opencode/src/cli/cmd/debug/index.ts
Normal file
87
packages/opencode/src/cli/cmd/debug/index.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import os from "os"
|
||||
import { Duration, Effect } from "effect"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { cmd } from "../cmd"
|
||||
import { ConfigCommand } from "./config"
|
||||
import { FileCommand } from "./file"
|
||||
import { LSPCommand } from "./lsp"
|
||||
import { RipgrepCommand } from "./ripgrep"
|
||||
import { ScrapCommand } from "./scrap"
|
||||
import { SkillCommand } from "./skill"
|
||||
import { SnapshotCommand } from "./snapshot"
|
||||
import { AgentCommand } from "./agent"
|
||||
import { StartupCommand } from "./startup"
|
||||
import { V2Command } from "./v2"
|
||||
|
||||
export const DebugCommand = cmd({
|
||||
command: "debug",
|
||||
describe: "debugging and troubleshooting tools",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command(ConfigCommand)
|
||||
.command(LSPCommand)
|
||||
.command(RipgrepCommand)
|
||||
.command(FileCommand)
|
||||
.command(ScrapCommand)
|
||||
.command(SkillCommand)
|
||||
.command(SnapshotCommand)
|
||||
.command(StartupCommand)
|
||||
.command(AgentCommand)
|
||||
.command(V2Command)
|
||||
.command(InfoCommand)
|
||||
.command(PathsCommand)
|
||||
.command(WaitCommand)
|
||||
.demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
const WaitCommand = effectCmd({
|
||||
command: "wait",
|
||||
describe: "wait indefinitely (for debugging)",
|
||||
handler: Effect.fn("Cli.debug.wait")(function* () {
|
||||
yield* Effect.sleep(Duration.days(1))
|
||||
}),
|
||||
})
|
||||
|
||||
const InfoCommand = effectCmd({
|
||||
command: "info",
|
||||
describe: "show debug information",
|
||||
handler: Effect.fn("Cli.debug.info")(function* () {
|
||||
const { Config } = yield* Effect.promise(() => import("@/config/config"))
|
||||
const { ConfigPlugin } = yield* Effect.promise(() => import("@/config/plugin"))
|
||||
const config = yield* Config.Service.use((cfg) => cfg.get())
|
||||
const termProgram = process.env.TERM_PROGRAM
|
||||
? `${process.env.TERM_PROGRAM}${process.env.TERM_PROGRAM_VERSION ? ` ${process.env.TERM_PROGRAM_VERSION}` : ""}`
|
||||
: undefined
|
||||
const terminal = [termProgram, process.env.TERM].filter((item): item is string => Boolean(item)).join(" / ")
|
||||
|
||||
console.log(`aircoding version: ${InstallationVersion}`)
|
||||
console.log(`os: ${os.type()} ${os.release()} ${os.arch()}`)
|
||||
console.log(`terminal: ${terminal || "unknown"}`)
|
||||
console.log("plugins:")
|
||||
if (Flag.OPENCODE_PURE) {
|
||||
console.log("external plugins disabled (--pure)")
|
||||
return
|
||||
}
|
||||
if (!config.plugin_origins?.length) {
|
||||
console.log("none")
|
||||
return
|
||||
}
|
||||
for (const plugin of config.plugin_origins) {
|
||||
console.log(`- ${ConfigPlugin.pluginSpecifier(plugin.spec)}`)
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
const PathsCommand = cmd({
|
||||
command: "paths",
|
||||
describe: "show global paths (data, config, cache, state)",
|
||||
handler() {
|
||||
for (const [key, value] of Object.entries(Global.Path)) {
|
||||
console.log(key.padEnd(10), value)
|
||||
}
|
||||
},
|
||||
})
|
||||
50
packages/opencode/src/cli/cmd/debug/lsp.ts
Normal file
50
packages/opencode/src/cli/cmd/debug/lsp.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { Effect } from "effect"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { cmd } from "../cmd"
|
||||
import { EOL } from "os"
|
||||
|
||||
export const LSPCommand = cmd({
|
||||
command: "lsp",
|
||||
describe: "LSP debugging utilities",
|
||||
builder: (yargs) =>
|
||||
yargs.command(DiagnosticsCommand).command(SymbolsCommand).command(DocumentSymbolsCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
const DiagnosticsCommand = effectCmd({
|
||||
command: "diagnostics <file>",
|
||||
describe: "get diagnostics for a file",
|
||||
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
|
||||
handler: Effect.fn("Cli.debug.lsp.diagnostics")(function* (args) {
|
||||
const out = yield* LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* lsp.touchFile(args.file, "full")
|
||||
return yield* lsp.diagnostics()
|
||||
}),
|
||||
)
|
||||
process.stdout.write(JSON.stringify(out, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
export const SymbolsCommand = effectCmd({
|
||||
command: "symbols <query>",
|
||||
describe: "search workspace symbols",
|
||||
builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }),
|
||||
handler: Effect.fn("Cli.debug.lsp.symbols")(function* (args) {
|
||||
yield* Effect.logInfo("symbols")
|
||||
const results = yield* LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query))
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
export const DocumentSymbolsCommand = effectCmd({
|
||||
command: "document-symbols <uri>",
|
||||
describe: "get symbols from a document",
|
||||
builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }),
|
||||
handler: Effect.fn("Cli.debug.lsp.documentSymbols")(function* (args) {
|
||||
yield* Effect.logInfo("document-symbols")
|
||||
const results = yield* LSP.Service.use((lsp) => lsp.documentSymbol(args.uri))
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
79
packages/opencode/src/cli/cmd/debug/ripgrep.ts
Normal file
79
packages/opencode/src/cli/cmd/debug/ripgrep.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { cmd } from "../cmd"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
|
||||
export const RipgrepCommand = cmd({
|
||||
command: "rg",
|
||||
describe: "ripgrep debugging utilities",
|
||||
builder: (yargs) => yargs.command(FilesCommand).command(SearchCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
const FilesCommand = effectCmd({
|
||||
command: "files",
|
||||
describe: "list files using ripgrep",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.option("query", {
|
||||
type: "string",
|
||||
description: "Filter files by query",
|
||||
})
|
||||
.option("glob", {
|
||||
type: "string",
|
||||
description: "Glob pattern to match files",
|
||||
})
|
||||
.option("limit", {
|
||||
type: "number",
|
||||
description: "Limit number of results",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.rg.files")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const files = yield* ripgrep
|
||||
.glob({
|
||||
cwd: ctx.directory,
|
||||
pattern: args.glob ?? "**/*",
|
||||
limit: args.limit ?? 10_000,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
process.stdout.write(files.map((file) => file.path).join(EOL) + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
const SearchCommand = effectCmd({
|
||||
command: "search <pattern>",
|
||||
describe: "search file contents using ripgrep",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional("pattern", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
description: "Search pattern",
|
||||
})
|
||||
.option("glob", {
|
||||
type: "array",
|
||||
description: "File glob patterns",
|
||||
})
|
||||
.option("limit", {
|
||||
type: "number",
|
||||
description: "Limit number of results",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.rg.search")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const results = yield* ripgrep
|
||||
.grep({
|
||||
cwd: ctx.directory,
|
||||
pattern: args.pattern,
|
||||
include: args.glob?.[0],
|
||||
limit: args.limit ?? 10_000,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
15
packages/opencode/src/cli/cmd/debug/scrap.ts
Normal file
15
packages/opencode/src/cli/cmd/debug/scrap.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { EOL } from "os"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
export const ScrapCommand = cmd({
|
||||
command: "scrap",
|
||||
describe: "list all known projects",
|
||||
builder: (yargs) => yargs,
|
||||
async handler() {
|
||||
const { Project } = await import("@/project/project")
|
||||
const { makeRuntime } = await import("@opencode-ai/core/effect/runtime")
|
||||
const runtime = makeRuntime(Project.Service, Project.defaultLayer)
|
||||
const list = await runtime.runPromise((project) => project.list())
|
||||
process.stdout.write(JSON.stringify(list, null, 2) + EOL)
|
||||
},
|
||||
})
|
||||
15
packages/opencode/src/cli/cmd/debug/skill.ts
Normal file
15
packages/opencode/src/cli/cmd/debug/skill.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { Skill } from "../../../skill"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
|
||||
export const SkillCommand = effectCmd({
|
||||
command: "skill",
|
||||
describe: "list all available skills",
|
||||
builder: (yargs) => yargs,
|
||||
handler: Effect.fn("Cli.debug.skill")(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const skills = yield* skill.all()
|
||||
process.stdout.write(JSON.stringify(skills, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
50
packages/opencode/src/cli/cmd/debug/snapshot.ts
Normal file
50
packages/opencode/src/cli/cmd/debug/snapshot.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Effect } from "effect"
|
||||
import { Snapshot } from "../../../snapshot"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
export const SnapshotCommand = cmd({
|
||||
command: "snapshot",
|
||||
describe: "snapshot debugging utilities",
|
||||
builder: (yargs) => yargs.command(TrackCommand).command(PatchCommand).command(DiffCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
const TrackCommand = effectCmd({
|
||||
command: "track",
|
||||
describe: "track current snapshot state",
|
||||
handler: Effect.fn("Cli.debug.snapshot.track")(function* () {
|
||||
const out = yield* Snapshot.Service.use((svc) => svc.track())
|
||||
console.log(out)
|
||||
}),
|
||||
})
|
||||
|
||||
const PatchCommand = effectCmd({
|
||||
command: "patch <hash>",
|
||||
describe: "show patch for a snapshot hash",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("hash", {
|
||||
type: "string",
|
||||
description: "hash",
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.snapshot.patch")(function* (args) {
|
||||
const out = yield* Snapshot.Service.use((svc) => svc.patch(args.hash))
|
||||
console.log(out)
|
||||
}),
|
||||
})
|
||||
|
||||
const DiffCommand = effectCmd({
|
||||
command: "diff <hash>",
|
||||
describe: "show diff for a snapshot hash",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("hash", {
|
||||
type: "string",
|
||||
description: "hash",
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.snapshot.diff")(function* (args) {
|
||||
const out = yield* Snapshot.Service.use((svc) => svc.diff(args.hash))
|
||||
console.log(out)
|
||||
}),
|
||||
})
|
||||
11
packages/opencode/src/cli/cmd/debug/startup.ts
Normal file
11
packages/opencode/src/cli/cmd/debug/startup.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { EOL } from "os"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
export const StartupCommand = cmd({
|
||||
command: "startup",
|
||||
describe: "print startup timing",
|
||||
builder: (yargs) => yargs,
|
||||
handler() {
|
||||
process.stdout.write(performance.now().toString() + EOL)
|
||||
},
|
||||
})
|
||||
49
packages/opencode/src/cli/cmd/debug/v2.ts
Normal file
49
packages/opencode/src/cli/cmd/debug/v2.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
|
||||
export const V2Command = effectCmd({
|
||||
command: "v2",
|
||||
describe: "debug v2 catalog and built-in plugins",
|
||||
instance: false,
|
||||
handler: () =>
|
||||
Effect.gen(function* () {
|
||||
yield* PluginBoot.Service.use((service) => service.wait())
|
||||
const catalog = yield* Catalog.Service
|
||||
const providers = (yield* catalog.provider.available()).sort((a, b) => a.id.localeCompare(b.id))
|
||||
const all = (yield* catalog.provider.all()).sort((a, b) => a.id.localeCompare(b.id))
|
||||
const result = {
|
||||
providers,
|
||||
default: catalog.model
|
||||
.default()
|
||||
.pipe(Effect.map(Option.map((item) => item.id)), Effect.map(Option.getOrUndefined)),
|
||||
small: Object.fromEntries(
|
||||
yield* Effect.all(
|
||||
all.map((provider) =>
|
||||
Effect.map(
|
||||
catalog.model.small(provider.id),
|
||||
(model) => [provider.id, Option.getOrUndefined(Option.map(model, (item) => item.id))] as const,
|
||||
),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
),
|
||||
),
|
||||
}
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + EOL)
|
||||
}).pipe(
|
||||
Effect.withSpan("Cli.debug.v2"),
|
||||
Effect.provide(
|
||||
LocationServiceMap.get(
|
||||
Location.Ref.make({
|
||||
directory: AbsolutePath.make(process.cwd()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.provide(LocationServiceMap.layer),
|
||||
),
|
||||
})
|
||||
292
packages/opencode/src/cli/cmd/export.ts
Normal file
292
packages/opencode/src/cli/cmd/export.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { SessionID } from "../../session/schema"
|
||||
import { effectCmd, fail } from "../effect-cmd"
|
||||
import { UI } from "../ui"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
|
||||
function redact(kind: string, id: string, value: string) {
|
||||
return value.trim() ? `[redacted:${kind}:${id}]` : value
|
||||
}
|
||||
|
||||
function data(kind: string, id: string, value: Record<string, unknown> | undefined) {
|
||||
if (!value) return value
|
||||
return Object.keys(value).length ? { redacted: `${kind}:${id}` } : value
|
||||
}
|
||||
|
||||
function span(id: string, value: { value: string; start: number; end: number }) {
|
||||
return {
|
||||
...value,
|
||||
value: redact("file-text", id, value.value),
|
||||
}
|
||||
}
|
||||
|
||||
function diff(kind: string, diffs: { file?: string; patch?: string }[] | undefined) {
|
||||
return diffs?.map((item, i) => ({
|
||||
...item,
|
||||
file: item.file === undefined ? undefined : redact(`${kind}-file`, String(i), item.file),
|
||||
patch: item.patch === undefined ? undefined : redact(`${kind}-patch`, String(i), item.patch),
|
||||
}))
|
||||
}
|
||||
|
||||
function source(part: SessionV1.FilePart) {
|
||||
if (!part.source) return part.source
|
||||
if (part.source.type === "symbol") {
|
||||
return {
|
||||
...part.source,
|
||||
path: redact("file-path", part.id, part.source.path),
|
||||
name: redact("file-symbol", part.id, part.source.name),
|
||||
text: span(part.id, part.source.text),
|
||||
}
|
||||
}
|
||||
if (part.source.type === "resource") {
|
||||
return {
|
||||
...part.source,
|
||||
clientName: redact("file-client", part.id, part.source.clientName),
|
||||
uri: redact("file-uri", part.id, part.source.uri),
|
||||
text: span(part.id, part.source.text),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...part.source,
|
||||
path: redact("file-path", part.id, part.source.path),
|
||||
text: span(part.id, part.source.text),
|
||||
}
|
||||
}
|
||||
|
||||
function filepart(part: SessionV1.FilePart): SessionV1.FilePart {
|
||||
return {
|
||||
...part,
|
||||
url: redact("file-url", part.id, part.url),
|
||||
filename: part.filename === undefined ? undefined : redact("file-name", part.id, part.filename),
|
||||
source: source(part),
|
||||
}
|
||||
}
|
||||
|
||||
function part(part: SessionV1.Part): SessionV1.Part {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return {
|
||||
...part,
|
||||
text: redact("text", part.id, part.text),
|
||||
metadata: data("text-metadata", part.id, part.metadata),
|
||||
}
|
||||
case "reasoning":
|
||||
return {
|
||||
...part,
|
||||
text: redact("reasoning", part.id, part.text),
|
||||
metadata: data("reasoning-metadata", part.id, part.metadata),
|
||||
}
|
||||
case "file":
|
||||
return filepart(part)
|
||||
case "subtask":
|
||||
return {
|
||||
...part,
|
||||
prompt: redact("subtask-prompt", part.id, part.prompt),
|
||||
description: redact("subtask-description", part.id, part.description),
|
||||
command: part.command === undefined ? undefined : redact("subtask-command", part.id, part.command),
|
||||
}
|
||||
case "tool":
|
||||
return {
|
||||
...part,
|
||||
metadata: data("tool-metadata", part.id, part.metadata),
|
||||
state:
|
||||
part.state.status === "pending"
|
||||
? {
|
||||
...part.state,
|
||||
input: data("tool-input", part.id, part.state.input) ?? part.state.input,
|
||||
raw: redact("tool-raw", part.id, part.state.raw),
|
||||
}
|
||||
: part.state.status === "running"
|
||||
? {
|
||||
...part.state,
|
||||
input: data("tool-input", part.id, part.state.input) ?? part.state.input,
|
||||
title: part.state.title === undefined ? undefined : redact("tool-title", part.id, part.state.title),
|
||||
metadata: data("tool-state-metadata", part.id, part.state.metadata),
|
||||
}
|
||||
: part.state.status === "completed"
|
||||
? {
|
||||
...part.state,
|
||||
input: data("tool-input", part.id, part.state.input) ?? part.state.input,
|
||||
output: redact("tool-output", part.id, part.state.output),
|
||||
title: redact("tool-title", part.id, part.state.title),
|
||||
metadata: data("tool-state-metadata", part.id, part.state.metadata) ?? part.state.metadata,
|
||||
attachments: part.state.attachments?.map(filepart),
|
||||
}
|
||||
: {
|
||||
...part.state,
|
||||
input: data("tool-input", part.id, part.state.input) ?? part.state.input,
|
||||
metadata: data("tool-state-metadata", part.id, part.state.metadata),
|
||||
},
|
||||
}
|
||||
case "patch":
|
||||
return {
|
||||
...part,
|
||||
hash: redact("patch", part.id, part.hash),
|
||||
files: part.files.map((item: string, i: number) => redact("patch-file", `${part.id}-${i}`, item)),
|
||||
}
|
||||
case "snapshot":
|
||||
return {
|
||||
...part,
|
||||
snapshot: redact("snapshot", part.id, part.snapshot),
|
||||
}
|
||||
case "step-start":
|
||||
return {
|
||||
...part,
|
||||
snapshot: part.snapshot === undefined ? undefined : redact("snapshot", part.id, part.snapshot),
|
||||
}
|
||||
case "step-finish":
|
||||
return {
|
||||
...part,
|
||||
snapshot: part.snapshot === undefined ? undefined : redact("snapshot", part.id, part.snapshot),
|
||||
}
|
||||
case "agent":
|
||||
return {
|
||||
...part,
|
||||
source: !part.source
|
||||
? part.source
|
||||
: {
|
||||
...part.source,
|
||||
value: redact("agent-source", part.id, part.source.value),
|
||||
},
|
||||
}
|
||||
default:
|
||||
return part
|
||||
}
|
||||
}
|
||||
|
||||
const partFn = part
|
||||
|
||||
function sanitize(data: { info: Session.Info; messages: SessionV1.WithParts[] }) {
|
||||
return {
|
||||
info: {
|
||||
...data.info,
|
||||
title: redact("session-title", data.info.id, data.info.title),
|
||||
directory: redact("session-directory", data.info.id, data.info.directory),
|
||||
summary: !data.info.summary
|
||||
? data.info.summary
|
||||
: {
|
||||
...data.info.summary,
|
||||
diffs: diff("session-diff", data.info.summary.diffs),
|
||||
},
|
||||
revert: !data.info.revert
|
||||
? data.info.revert
|
||||
: {
|
||||
...data.info.revert,
|
||||
snapshot:
|
||||
data.info.revert.snapshot === undefined
|
||||
? undefined
|
||||
: redact("revert-snapshot", data.info.id, data.info.revert.snapshot),
|
||||
diff:
|
||||
data.info.revert.diff === undefined
|
||||
? undefined
|
||||
: redact("revert-diff", data.info.id, data.info.revert.diff),
|
||||
},
|
||||
},
|
||||
messages: data.messages.map((msg) => ({
|
||||
info:
|
||||
msg.info.role === "user"
|
||||
? {
|
||||
...msg.info,
|
||||
system: msg.info.system === undefined ? undefined : redact("system", msg.info.id, msg.info.system),
|
||||
summary: !msg.info.summary
|
||||
? msg.info.summary
|
||||
: {
|
||||
...msg.info.summary,
|
||||
title:
|
||||
msg.info.summary.title === undefined
|
||||
? undefined
|
||||
: redact("summary-title", msg.info.id, msg.info.summary.title),
|
||||
body:
|
||||
msg.info.summary.body === undefined
|
||||
? undefined
|
||||
: redact("summary-body", msg.info.id, msg.info.summary.body),
|
||||
diffs: diff("message-diff", msg.info.summary.diffs),
|
||||
},
|
||||
}
|
||||
: {
|
||||
...msg.info,
|
||||
path: {
|
||||
cwd: redact("cwd", msg.info.id, msg.info.path.cwd),
|
||||
root: redact("root", msg.info.id, msg.info.path.root),
|
||||
},
|
||||
},
|
||||
parts: msg.parts.map(partFn),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export const ExportCommand = effectCmd({
|
||||
command: "export [sessionID]",
|
||||
describe: "export session data as JSON",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional("sessionID", {
|
||||
describe: "session id to export",
|
||||
type: "string",
|
||||
})
|
||||
.option("sanitize", {
|
||||
describe: "redact sensitive transcript and file data",
|
||||
type: "boolean",
|
||||
}),
|
||||
handler: Effect.fn("Cli.export")(function* (args) {
|
||||
return yield* run(args)
|
||||
}),
|
||||
})
|
||||
|
||||
const run = Effect.fn("Cli.export.body")(function* (args: { sessionID?: string; sanitize?: boolean }) {
|
||||
const svc = yield* Session.Service
|
||||
let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined
|
||||
process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`)
|
||||
|
||||
if (!sessionID) {
|
||||
UI.empty()
|
||||
prompts.intro("Export session", { output: process.stderr })
|
||||
|
||||
const sessions = yield* svc.list()
|
||||
|
||||
if (sessions.length === 0) {
|
||||
prompts.log.error("No sessions found", { output: process.stderr })
|
||||
prompts.outro("Done", { output: process.stderr })
|
||||
return
|
||||
}
|
||||
|
||||
sessions.sort((a, b) => b.time.updated - a.time.updated)
|
||||
|
||||
const selectedSession = yield* Effect.promise(() =>
|
||||
prompts.autocomplete({
|
||||
message: "Select session to export",
|
||||
maxItems: 10,
|
||||
options: sessions.map((session) => ({
|
||||
label: session.title,
|
||||
value: session.id,
|
||||
hint: `${new Date(session.time.updated).toLocaleString()} • ${session.id.slice(-8)}`,
|
||||
})),
|
||||
output: process.stderr,
|
||||
}),
|
||||
)
|
||||
|
||||
if (prompts.isCancel(selectedSession)) {
|
||||
return yield* Effect.die(new UI.CancelledError())
|
||||
}
|
||||
|
||||
sessionID = selectedSession
|
||||
|
||||
prompts.outro("Exporting session...", { output: process.stderr })
|
||||
}
|
||||
|
||||
// Match legacy try/catch — catches both typed failures and defects
|
||||
// (Session.Service.get throws NotFoundError as a defect, not a typed E).
|
||||
return yield* Effect.gen(function* () {
|
||||
const sessionInfo = yield* svc.get(sessionID!)
|
||||
const messages = yield* svc.messages({ sessionID: sessionInfo.id })
|
||||
|
||||
const exportData = { info: sessionInfo, messages }
|
||||
|
||||
process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2))
|
||||
process.stdout.write(EOL)
|
||||
}).pipe(Effect.catchCause(() => fail(`Session not found: ${sessionID!}`)))
|
||||
})
|
||||
54
packages/opencode/src/cli/cmd/generate.ts
Normal file
54
packages/opencode/src/cli/cmd/generate.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { CommandModule } from "yargs"
|
||||
|
||||
type Args = {}
|
||||
|
||||
export const GenerateCommand = {
|
||||
command: "generate",
|
||||
builder: (yargs) => yargs,
|
||||
handler: async () => {
|
||||
const { Server } = await import("../../server/server")
|
||||
const specs = (await Server.openapi()) as {
|
||||
paths: Record<string, Record<string, any>>
|
||||
}
|
||||
for (const item of Object.values(specs.paths)) {
|
||||
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
|
||||
const operation = item[method]
|
||||
if (!operation?.operationId) continue
|
||||
operation["x-codeSamples"] = [
|
||||
{
|
||||
lang: "js",
|
||||
source: [
|
||||
`import { createOpencodeClient } from "@opencode-ai/sdk`,
|
||||
``,
|
||||
`const client = createOpencodeClient()`,
|
||||
`await client.${operation.operationId}({`,
|
||||
` ...`,
|
||||
`})`,
|
||||
].join("\n"),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
const raw = JSON.stringify(specs, null, 2)
|
||||
|
||||
// Format through prettier so output is byte-identical to committed file
|
||||
// regardless of whether ./script/format.ts runs afterward.
|
||||
const prettier = await import("prettier")
|
||||
const babel = await import("prettier/plugins/babel")
|
||||
const estree = await import("prettier/plugins/estree")
|
||||
const format = prettier.format ?? prettier.default?.format
|
||||
const json = await format(raw, {
|
||||
parser: "json",
|
||||
plugins: [babel.default ?? babel, estree.default ?? estree],
|
||||
printWidth: 120,
|
||||
})
|
||||
|
||||
// Wait for stdout to finish writing before process.exit() is called
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
process.stdout.write(json, (err) => {
|
||||
if (err) reject(err)
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
},
|
||||
} satisfies CommandModule<object, Args>
|
||||
1593
packages/opencode/src/cli/cmd/github.handler.ts
Normal file
1593
packages/opencode/src/cli/cmd/github.handler.ts
Normal file
File diff suppressed because it is too large
Load Diff
30
packages/opencode/src/cli/cmd/github.shared.ts
Normal file
30
packages/opencode/src/cli/cmd/github.shared.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
|
||||
export { parseGitHubRemote } from "@/util/repository"
|
||||
|
||||
/**
|
||||
* Extracts displayable text from assistant response parts.
|
||||
* Returns null for non-text responses (signals summary needed).
|
||||
* Throws only for truly empty responses.
|
||||
*/
|
||||
export function extractResponseText(parts: SessionV1.Part[]): string | null {
|
||||
const textPart = parts.findLast((p) => p.type === "text")
|
||||
if (textPart) return textPart.text
|
||||
|
||||
// Non-text parts (tools, reasoning, step-start/step-finish, etc.) - signal summary needed
|
||||
if (parts.length > 0) return null
|
||||
|
||||
throw new Error("Failed to parse response: no parts returned")
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a PROMPT_TOO_LARGE error message with details about files in the prompt.
|
||||
* Content is base64 encoded, so we calculate original size by multiplying by 0.75.
|
||||
*/
|
||||
export function formatPromptTooLargeError(files: { filename: string; content: string }[]): string {
|
||||
const fileDetails =
|
||||
files.length > 0
|
||||
? `\n\nFiles in prompt:\n${files.map((f) => ` - ${f.filename} (${((f.content.length * 0.75) / 1024).toFixed(0)} KB)`).join("\n")}`
|
||||
: ""
|
||||
return `PROMPT_TOO_LARGE: The prompt exceeds the model's context limit.${fileDetails}`
|
||||
}
|
||||
42
packages/opencode/src/cli/cmd/github.ts
Normal file
42
packages/opencode/src/cli/cmd/github.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Effect } from "effect"
|
||||
import { cmd } from "./cmd"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
|
||||
export { extractResponseText, formatPromptTooLargeError, parseGitHubRemote } from "./github.shared"
|
||||
|
||||
export const GithubInstallCommand = effectCmd({
|
||||
command: "install",
|
||||
describe: "install the GitHub agent",
|
||||
handler: () =>
|
||||
Effect.gen(function* () {
|
||||
const { githubInstall } = yield* Effect.promise(() => import("./github.handler"))
|
||||
return yield* githubInstall()
|
||||
}),
|
||||
})
|
||||
|
||||
export const GithubRunCommand = effectCmd({
|
||||
command: "run",
|
||||
describe: "run the GitHub agent",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.option("event", {
|
||||
type: "string",
|
||||
describe: "GitHub mock event to run the agent for",
|
||||
})
|
||||
.option("token", {
|
||||
type: "string",
|
||||
describe: "GitHub personal access token (github_pat_********)",
|
||||
}),
|
||||
handler: (args) =>
|
||||
Effect.gen(function* () {
|
||||
const { githubRun } = yield* Effect.promise(() => import("./github.handler"))
|
||||
return yield* githubRun(args)
|
||||
}),
|
||||
})
|
||||
|
||||
export const GithubCommand = cmd({
|
||||
command: "github",
|
||||
describe: "manage GitHub agent",
|
||||
builder: (yargs) => yargs.command(GithubInstallCommand).command(GithubRunCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
224
packages/opencode/src/cli/cmd/import.ts
Normal file
224
packages/opencode/src/cli/cmd/import.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { Session } from "@/session/session"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { CliError, effectCmd } from "../effect-cmd"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { SessionTable, MessageTable, PartTable } from "@opencode-ai/core/session/sql"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { EOL } from "os"
|
||||
import path from "path"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
|
||||
const decodeMessageInfo = Schema.decodeUnknownSync(SessionV1.Info)
|
||||
const decodePart = Schema.decodeUnknownSync(SessionV1.Part)
|
||||
|
||||
/** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */
|
||||
export type ShareData =
|
||||
| { type: "session"; data: SDKSession }
|
||||
| { type: "message"; data: Message }
|
||||
| { type: "part"; data: Part }
|
||||
| { type: "session_diff"; data: unknown }
|
||||
| { type: "model"; data: unknown }
|
||||
|
||||
/** Extract share ID from a share URL like https://opncd.ai/share/abc123 */
|
||||
export function parseShareUrl(url: string): string | null {
|
||||
const match = url.match(/^https?:\/\/[^/]+\/share\/([a-zA-Z0-9_-]+)$/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
export function shouldAttachShareAuthHeaders(shareUrl: string, accountBaseUrl: string): boolean {
|
||||
try {
|
||||
return new URL(shareUrl).origin === new URL(accountBaseUrl).origin
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform ShareNext API response (flat array) into the nested structure for local file storage.
|
||||
*
|
||||
* The API returns a flat array: [session, message, message, part, part, ...]
|
||||
* Local storage expects: { info: session, messages: [{ info: message, parts: [part, ...] }, ...] }
|
||||
*
|
||||
* This groups parts by their messageID to reconstruct the hierarchy before writing to disk.
|
||||
*/
|
||||
export function transformShareData(shareData: ShareData[]): {
|
||||
info: SDKSession
|
||||
messages: Array<{ info: Message; parts: Part[] }>
|
||||
} | null {
|
||||
const sessionItem = shareData.find((d) => d.type === "session")
|
||||
if (!sessionItem) return null
|
||||
|
||||
const messageMap = new Map<string, Message>()
|
||||
const partMap = new Map<string, Part[]>()
|
||||
|
||||
for (const item of shareData) {
|
||||
if (item.type === "message") {
|
||||
messageMap.set(item.data.id, item.data)
|
||||
} else if (item.type === "part") {
|
||||
if (!partMap.has(item.data.messageID)) {
|
||||
partMap.set(item.data.messageID, [])
|
||||
}
|
||||
partMap.get(item.data.messageID)!.push(item.data)
|
||||
}
|
||||
}
|
||||
|
||||
if (messageMap.size === 0) return null
|
||||
|
||||
return {
|
||||
info: sessionItem.data,
|
||||
messages: Array.from(messageMap.values()).map((msg) => ({
|
||||
info: msg,
|
||||
parts: partMap.get(msg.id) ?? [],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
type ExportData = { info: SDKSession; messages: Array<{ info: Message; parts: Part[] }> }
|
||||
|
||||
export const ImportCommand = effectCmd({
|
||||
command: "import <file>",
|
||||
describe: "import session data from JSON file or URL",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("file", {
|
||||
describe: "path to JSON file or share URL",
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.import")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return yield* Effect.die("InstanceRef not provided")
|
||||
return yield* runImport(args.file, ctx)
|
||||
}),
|
||||
})
|
||||
|
||||
const runImport = Effect.fn("Cli.import.body")(function* (file: string, ctx: InstanceContext) {
|
||||
const share = yield* ShareNext.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const { db } = yield* Database.Service
|
||||
|
||||
let exportData: ExportData | undefined
|
||||
|
||||
const isUrl = file.startsWith("http://") || file.startsWith("https://")
|
||||
|
||||
if (isUrl) {
|
||||
const slug = parseShareUrl(file)
|
||||
if (!slug) {
|
||||
const baseUrl = yield* Effect.orDie(share.url())
|
||||
process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/<slug>`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
|
||||
const baseUrl = new URL(file).origin
|
||||
const req = yield* Effect.orDie(share.request())
|
||||
const headers = shouldAttachShareAuthHeaders(file, req.baseUrl) ? req.headers : {}
|
||||
|
||||
const tryFetch = (url: string) =>
|
||||
Effect.tryPromise({
|
||||
try: () => fetch(url, { headers }),
|
||||
catch: (e) =>
|
||||
new CliError({
|
||||
message: `Failed to fetch share data: ${e instanceof Error ? e.message : String(e)}`,
|
||||
}),
|
||||
})
|
||||
|
||||
const dataPath = req.api.data(slug)
|
||||
let response = yield* tryFetch(`${baseUrl}${dataPath}`)
|
||||
|
||||
if (!response.ok && dataPath !== `/api/share/${slug}/data`) {
|
||||
response = yield* tryFetch(`${baseUrl}/api/share/${slug}/data`)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
process.stdout.write(`Failed to fetch share data: ${response.statusText}`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
|
||||
const shareData = yield* Effect.tryPromise({
|
||||
try: () => response.json() as Promise<ShareData[]>,
|
||||
catch: () => new CliError({ message: "Share data was not valid JSON" }),
|
||||
})
|
||||
const transformed = transformShareData(shareData)
|
||||
|
||||
if (!transformed) {
|
||||
process.stdout.write(`Share not found or empty: ${slug}`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
|
||||
exportData = transformed
|
||||
} else {
|
||||
exportData = (yield* fs.readJson(file).pipe(Effect.orElseSucceed(() => undefined))) as
|
||||
| NonNullable<typeof exportData>
|
||||
| undefined
|
||||
if (!exportData) {
|
||||
process.stdout.write(`File not found: ${file}`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!exportData) {
|
||||
process.stdout.write(`Failed to read session data`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
|
||||
const info = Schema.decodeUnknownSync(Session.Info)({
|
||||
...exportData.info,
|
||||
projectID: ctx.project.id,
|
||||
directory: ctx.directory,
|
||||
path: path.relative(path.resolve(ctx.worktree), ctx.directory).replaceAll("\\", "/"),
|
||||
}) as Session.Info
|
||||
const row = Session.toRow(info)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({
|
||||
target: SessionTable.id,
|
||||
set: { project_id: row.project_id, directory: row.directory, path: row.path },
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
for (const msg of exportData.messages) {
|
||||
const msgInfo = decodeMessageInfo(msg.info) as SessionV1.Info
|
||||
const { id, sessionID: _, ...msgData } = msgInfo
|
||||
yield* db
|
||||
.insert(MessageTable)
|
||||
.values({
|
||||
id,
|
||||
session_id: row.id,
|
||||
time_created: msgInfo.time?.created ?? Date.now(),
|
||||
data: msgData as never,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
for (const part of msg.parts) {
|
||||
const partInfo = decodePart(part) as SessionV1.Part
|
||||
const { id: partId, sessionID: _s, messageID, ...partData } = partInfo
|
||||
yield* db
|
||||
.insert(PartTable)
|
||||
.values({
|
||||
id: partId,
|
||||
message_id: messageID,
|
||||
session_id: row.id,
|
||||
data: partData,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`Imported session: ${exportData.info.id}`)
|
||||
process.stdout.write(EOL)
|
||||
})
|
||||
848
packages/opencode/src/cli/cmd/mcp.ts
Normal file
848
packages/opencode/src/cli/cmd/mcp.ts
Normal file
@@ -0,0 +1,848 @@
|
||||
import { cmd } from "./cmd"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { Cause } from "effect"
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { UI } from "../ui"
|
||||
import { MCP } from "../../mcp"
|
||||
import { McpAuth } from "../../mcp/auth"
|
||||
import { McpOAuthProvider } from "../../mcp/oauth-provider"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { modify, applyEdits } from "jsonc-parser"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Effect } from "effect"
|
||||
|
||||
function getAuthStatusIcon(status: MCP.AuthStatus): string {
|
||||
switch (status) {
|
||||
case "authenticated":
|
||||
return "✓"
|
||||
case "expired":
|
||||
return "⚠"
|
||||
case "not_authenticated":
|
||||
return "✗"
|
||||
}
|
||||
}
|
||||
|
||||
function getAuthStatusText(status: MCP.AuthStatus): string {
|
||||
switch (status) {
|
||||
case "authenticated":
|
||||
return "authenticated"
|
||||
case "expired":
|
||||
return "expired"
|
||||
case "not_authenticated":
|
||||
return "not authenticated"
|
||||
}
|
||||
}
|
||||
|
||||
type McpEntry = NonNullable<ConfigV1.Info["mcp"]>[string]
|
||||
|
||||
type McpConfigured = ConfigMCPV1.Info
|
||||
function isMcpConfigured(config: McpEntry): config is McpConfigured {
|
||||
return typeof config === "object" && config !== null && "type" in config
|
||||
}
|
||||
|
||||
type McpRemote = Extract<McpConfigured, { type: "remote" }>
|
||||
function isMcpRemote(config: McpEntry): config is McpRemote {
|
||||
return isMcpConfigured(config) && config.type === "remote"
|
||||
}
|
||||
|
||||
function configuredServers(config: ConfigV1.Info) {
|
||||
return Object.entries(config.mcp ?? {}).filter((entry): entry is [string, McpConfigured] => isMcpConfigured(entry[1]))
|
||||
}
|
||||
|
||||
function oauthServers(config: ConfigV1.Info) {
|
||||
return configuredServers(config).filter(
|
||||
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
|
||||
)
|
||||
}
|
||||
|
||||
function listState() {
|
||||
return 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 }
|
||||
})
|
||||
}
|
||||
|
||||
function authState() {
|
||||
return 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",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command(McpAddCommand)
|
||||
.command(McpListCommand)
|
||||
.command(McpAuthCommand)
|
||||
.command(McpLogoutCommand)
|
||||
.command(McpDebugCommand)
|
||||
.demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
export const McpListCommand = effectCmd({
|
||||
command: "list",
|
||||
aliases: ["ls"],
|
||||
describe: "list MCP servers and their status",
|
||||
handler: Effect.fn("Cli.mcp.list")(function* () {
|
||||
UI.empty()
|
||||
prompts.intro("MCP Servers")
|
||||
|
||||
const { config, statuses, stored } = yield* listState()
|
||||
const servers = configuredServers(config)
|
||||
|
||||
if (servers.length === 0) {
|
||||
prompts.log.warn("No MCP servers configured")
|
||||
prompts.outro("Add servers with: opencode mcp add")
|
||||
return
|
||||
}
|
||||
|
||||
for (const [name, serverConfig] of servers) {
|
||||
const status = statuses[name]
|
||||
const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth
|
||||
const hasStoredTokens = stored[name]
|
||||
|
||||
let statusIcon: string
|
||||
let statusText: string
|
||||
let hint = ""
|
||||
|
||||
if (!status) {
|
||||
statusIcon = "○"
|
||||
statusText = "not initialized"
|
||||
} else if (status.status === "connected") {
|
||||
statusIcon = "✓"
|
||||
statusText = "connected"
|
||||
if (hasOAuth && hasStoredTokens) {
|
||||
hint = " (OAuth)"
|
||||
}
|
||||
} else if (status.status === "disabled") {
|
||||
statusIcon = "○"
|
||||
statusText = "disabled"
|
||||
} else if (status.status === "needs_auth") {
|
||||
statusIcon = "⚠"
|
||||
statusText = "needs authentication"
|
||||
} else if (status.status === "needs_client_registration") {
|
||||
statusIcon = "✗"
|
||||
statusText = "needs client registration"
|
||||
hint = "\n " + status.error
|
||||
} else {
|
||||
statusIcon = "✗"
|
||||
statusText = "failed"
|
||||
hint = "\n " + status.error
|
||||
}
|
||||
|
||||
const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ")
|
||||
prompts.log.info(
|
||||
`${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`,
|
||||
)
|
||||
}
|
||||
|
||||
prompts.outro(`${servers.length} server(s)`)
|
||||
}),
|
||||
})
|
||||
|
||||
export const McpAuthCommand = effectCmd({
|
||||
command: "auth [name]",
|
||||
describe: "authenticate with an OAuth-enabled MCP server",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional("name", {
|
||||
describe: "name of the MCP server",
|
||||
type: "string",
|
||||
})
|
||||
.command(McpAuthListCommand),
|
||||
handler: Effect.fn("Cli.mcp.auth")(function* (args) {
|
||||
UI.empty()
|
||||
prompts.intro("MCP OAuth Authentication")
|
||||
|
||||
const { config, auth } = yield* authState()
|
||||
const mcpServers = config.mcp ?? {}
|
||||
const servers = oauthServers(config)
|
||||
|
||||
if (servers.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(`
|
||||
"mcp": {
|
||||
"my-server": {
|
||||
"type": "remote",
|
||||
"url": "https://example.com/mcp"
|
||||
}
|
||||
}`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
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 selected = yield* Effect.promise(() =>
|
||||
prompts.select({
|
||||
message: "Select MCP server to authenticate",
|
||||
options,
|
||||
}),
|
||||
)
|
||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||
serverName = selected
|
||||
}
|
||||
|
||||
const serverConfig = mcpServers[serverName]
|
||||
if (!serverConfig) {
|
||||
prompts.log.error(`MCP server not found: ${serverName}`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
if (!isMcpRemote(serverConfig) || serverConfig.oauth === false) {
|
||||
prompts.log.error(`MCP server ${serverName} is not an OAuth-capable remote server`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already authenticated
|
||||
const authStatus = auth[serverName] ?? (yield* MCP.Service.use((mcp) => mcp.getAuthStatus(serverName)))
|
||||
if (authStatus === "authenticated") {
|
||||
const confirm = yield* Effect.promise(() =>
|
||||
prompts.confirm({
|
||||
message: `${serverName} already has valid credentials. Re-authenticate?`,
|
||||
}),
|
||||
)
|
||||
if (prompts.isCancel(confirm) || !confirm) {
|
||||
prompts.outro("Cancelled")
|
||||
return
|
||||
}
|
||||
} else if (authStatus === "expired") {
|
||||
prompts.log.warn(`${serverName} has expired credentials. Re-authenticating...`)
|
||||
}
|
||||
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Starting OAuth flow...")
|
||||
|
||||
// Subscribe to browser open failure events to show URL for manual opening
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const unsubscribe = yield* events.listen((event) => {
|
||||
if (event.type !== MCP.BrowserOpenFailed.type) return Effect.void
|
||||
const data = event.data as EventV2.Data<typeof MCP.BrowserOpenFailed>
|
||||
if (data.mcpName === serverName) {
|
||||
spinner.stop("Could not open browser automatically")
|
||||
prompts.log.warn("Please open this URL in your browser to authenticate:")
|
||||
prompts.log.info(data.url)
|
||||
spinner.start("Waiting for authorization...")
|
||||
}
|
||||
return Effect.void
|
||||
})
|
||||
|
||||
yield* MCP.Service.use((mcp) => mcp.authenticate(serverName)).pipe(
|
||||
Effect.tap((status) =>
|
||||
Effect.sync(() => {
|
||||
if (status.status === "connected") {
|
||||
spinner.stop("Authentication successful!")
|
||||
} else if (status.status === "needs_client_registration") {
|
||||
spinner.stop("Authentication failed", 1)
|
||||
prompts.log.error(status.error)
|
||||
prompts.log.info("Add clientId to your MCP server config:")
|
||||
prompts.log.info(`
|
||||
"mcp": {
|
||||
"${serverName}": {
|
||||
"type": "remote",
|
||||
"url": "${serverConfig.url}",
|
||||
"oauth": {
|
||||
"clientId": "your-client-id",
|
||||
"clientSecret": "your-client-secret"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
} else if (status.status === "failed") {
|
||||
spinner.stop("Authentication failed", 1)
|
||||
prompts.log.error(status.error)
|
||||
} else {
|
||||
spinner.stop("Unexpected status: " + status.status, 1)
|
||||
}
|
||||
}),
|
||||
),
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.sync(() => {
|
||||
spinner.stop("Authentication failed", 1)
|
||||
const error = Cause.squash(cause)
|
||||
prompts.log.error(error instanceof Error ? error.message : String(error))
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(unsubscribe),
|
||||
)
|
||||
|
||||
prompts.outro("Done")
|
||||
}),
|
||||
})
|
||||
|
||||
export const McpAuthListCommand = effectCmd({
|
||||
command: "list",
|
||||
aliases: ["ls"],
|
||||
describe: "list OAuth-capable MCP servers and their auth status",
|
||||
handler: Effect.fn("Cli.mcp.auth.list")(function* () {
|
||||
UI.empty()
|
||||
prompts.intro("MCP OAuth Status")
|
||||
|
||||
const { config, auth } = yield* authState()
|
||||
const servers = oauthServers(config)
|
||||
|
||||
if (servers.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]
|
||||
const icon = getAuthStatusIcon(authStatus)
|
||||
const statusText = getAuthStatusText(authStatus)
|
||||
const url = serverConfig.url
|
||||
|
||||
prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`)
|
||||
}
|
||||
|
||||
prompts.outro(`${servers.length} OAuth-capable server(s)`)
|
||||
}),
|
||||
})
|
||||
|
||||
export const McpLogoutCommand = effectCmd({
|
||||
command: "logout [name]",
|
||||
describe: "remove OAuth credentials for an MCP server",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("name", {
|
||||
describe: "name of the MCP server",
|
||||
type: "string",
|
||||
}),
|
||||
handler: Effect.fn("Cli.mcp.logout")(function* (args) {
|
||||
UI.empty()
|
||||
prompts.intro("MCP OAuth Logout")
|
||||
|
||||
const credentials = yield* McpAuth.Service.use((auth) => auth.all())
|
||||
const serverNames = Object.keys(credentials)
|
||||
|
||||
if (serverNames.length === 0) {
|
||||
prompts.log.warn("No MCP OAuth credentials stored")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
let serverName = args.name
|
||||
if (!serverName) {
|
||||
const selected = yield* Effect.promise(() =>
|
||||
prompts.select({
|
||||
message: "Select MCP server to logout",
|
||||
options: serverNames.map((name) => {
|
||||
const entry = credentials[name]
|
||||
const hasTokens = !!entry.tokens
|
||||
const hasClient = !!entry.clientInfo
|
||||
let hint = ""
|
||||
if (hasTokens && hasClient) hint = "tokens + client"
|
||||
else if (hasTokens) hint = "tokens"
|
||||
else if (hasClient) hint = "client registration"
|
||||
return {
|
||||
label: name,
|
||||
value: name,
|
||||
hint,
|
||||
}
|
||||
}),
|
||||
}),
|
||||
)
|
||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||
serverName = selected
|
||||
}
|
||||
|
||||
if (!credentials[serverName]) {
|
||||
prompts.log.error(`No credentials found for: ${serverName}`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
yield* MCP.Service.use((mcp) => mcp.removeAuth(serverName))
|
||||
prompts.log.success(`Removed OAuth credentials for ${serverName}`)
|
||||
prompts.outro("Done")
|
||||
}),
|
||||
})
|
||||
|
||||
async function resolveConfigPath(baseDir: string, global = false) {
|
||||
// Check for existing config files (prefer .jsonc over .json, check .opencode/ subdirectory too)
|
||||
const candidates = [path.join(baseDir, "opencode.json"), path.join(baseDir, "opencode.jsonc")]
|
||||
|
||||
if (!global) {
|
||||
candidates.push(path.join(baseDir, ".opencode", "opencode.json"), path.join(baseDir, ".opencode", "opencode.jsonc"))
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (await Filesystem.exists(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
// Default to opencode.json if none exist
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
async function addMcpToConfig(name: string, mcpConfig: ConfigMCPV1.Info, configPath: string) {
|
||||
let text = "{}"
|
||||
if (await Filesystem.exists(configPath)) {
|
||||
text = await Filesystem.readText(configPath)
|
||||
}
|
||||
|
||||
// Use jsonc-parser to modify while preserving comments
|
||||
const edits = modify(text, ["mcp", name], mcpConfig, {
|
||||
formattingOptions: { tabSize: 2, insertSpaces: true },
|
||||
})
|
||||
const result = applyEdits(text, edits)
|
||||
|
||||
await Filesystem.write(configPath, result)
|
||||
|
||||
return configPath
|
||||
}
|
||||
|
||||
export const McpAddCommand = effectCmd({
|
||||
command: "add [name]",
|
||||
describe: "add an MCP server",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional("name", {
|
||||
describe: "name of the MCP server",
|
||||
type: "string",
|
||||
})
|
||||
.option("url", {
|
||||
describe: "URL for a remote MCP server",
|
||||
type: "string",
|
||||
})
|
||||
.option("env", {
|
||||
describe: "environment variable for a local MCP server (KEY=VALUE)",
|
||||
type: "string",
|
||||
array: true,
|
||||
})
|
||||
.option("header", {
|
||||
describe: "HTTP header for a remote MCP server (KEY=VALUE)",
|
||||
type: "string",
|
||||
array: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.mcp.add")(function* (args) {
|
||||
const maybeCtx = yield* InstanceRef
|
||||
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
|
||||
const ctx = maybeCtx
|
||||
yield* Effect.promise(async () => {
|
||||
const command = args["--"] ?? []
|
||||
if (!args.name && (args.url || args.env?.length || args.header?.length || command.length)) {
|
||||
throw new Error("A server name is required for non-interactive MCP configuration")
|
||||
}
|
||||
if (args.name) {
|
||||
if (!!args.url === !!command.length) {
|
||||
throw new Error("Provide either --url <url> or a command after --")
|
||||
}
|
||||
if (args.url && !URL.canParse(args.url)) {
|
||||
throw new Error(`Invalid URL: ${args.url}`)
|
||||
}
|
||||
if (args.url && args.env?.length) {
|
||||
throw new Error("--env is only valid for local MCP servers")
|
||||
}
|
||||
if (command.length && args.header?.length) {
|
||||
throw new Error("--header is only valid for remote MCP servers")
|
||||
}
|
||||
|
||||
const entries = (values: string[], kind: string) =>
|
||||
Object.fromEntries(
|
||||
values.map((entry) => {
|
||||
const index = entry.indexOf("=")
|
||||
if (index < 1) throw new Error(`Invalid ${kind}: ${entry}. Expected KEY=VALUE`)
|
||||
return [entry.slice(0, index), entry.slice(index + 1)]
|
||||
}),
|
||||
)
|
||||
const environment = entries(args.env ?? [], "environment variable")
|
||||
const headers = entries(args.header ?? [], "HTTP header")
|
||||
const mcpConfig: ConfigMCPV1.Info = args.url
|
||||
? {
|
||||
type: "remote",
|
||||
url: args.url,
|
||||
...(Object.keys(headers).length ? { headers } : {}),
|
||||
}
|
||||
: {
|
||||
type: "local",
|
||||
command,
|
||||
...(Object.keys(environment).length ? { environment } : {}),
|
||||
}
|
||||
|
||||
const configPath = await resolveConfigPath(Global.Path.config, true)
|
||||
await addMcpToConfig(args.name, mcpConfig, configPath)
|
||||
prompts.log.success(`MCP server "${args.name}" added to ${configPath}`)
|
||||
return
|
||||
}
|
||||
|
||||
UI.empty()
|
||||
prompts.intro("Add MCP server")
|
||||
|
||||
const project = ctx.project
|
||||
|
||||
// Resolve config paths eagerly for hints
|
||||
const [projectConfigPath, globalConfigPath] = await Promise.all([
|
||||
resolveConfigPath(ctx.worktree),
|
||||
resolveConfigPath(Global.Path.config, true),
|
||||
])
|
||||
|
||||
// Determine scope
|
||||
let configPath = globalConfigPath
|
||||
if (project.vcs === "git") {
|
||||
const scopeResult = await prompts.select({
|
||||
message: "Location",
|
||||
options: [
|
||||
{
|
||||
label: "Current project",
|
||||
value: projectConfigPath,
|
||||
hint: projectConfigPath,
|
||||
},
|
||||
{
|
||||
label: "Global",
|
||||
value: globalConfigPath,
|
||||
hint: globalConfigPath,
|
||||
},
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
|
||||
configPath = scopeResult
|
||||
}
|
||||
|
||||
const name = await prompts.text({
|
||||
message: "Enter MCP server name",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(name)) throw new UI.CancelledError()
|
||||
|
||||
const type = await prompts.select({
|
||||
message: "Select MCP server type",
|
||||
options: [
|
||||
{
|
||||
label: "Local",
|
||||
value: "local",
|
||||
hint: "Run a local command",
|
||||
},
|
||||
{
|
||||
label: "Remote",
|
||||
value: "remote",
|
||||
hint: "Connect to a remote URL",
|
||||
},
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(type)) throw new UI.CancelledError()
|
||||
|
||||
if (type === "local") {
|
||||
const command = await prompts.text({
|
||||
message: "Enter command to run",
|
||||
placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(command)) throw new UI.CancelledError()
|
||||
|
||||
const mcpConfig: ConfigMCPV1.Info = {
|
||||
type: "local",
|
||||
command: command.split(" "),
|
||||
}
|
||||
|
||||
await addMcpToConfig(name, mcpConfig, configPath)
|
||||
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
|
||||
prompts.outro("MCP server added successfully")
|
||||
return
|
||||
}
|
||||
|
||||
if (type === "remote") {
|
||||
const url = await prompts.text({
|
||||
message: "Enter MCP server URL",
|
||||
placeholder: "e.g., https://example.com/mcp",
|
||||
validate: (x) => {
|
||||
if (!x) return "Required"
|
||||
if (x.length === 0) return "Required"
|
||||
const isValid = URL.canParse(x)
|
||||
return isValid ? undefined : "Invalid URL"
|
||||
},
|
||||
})
|
||||
if (prompts.isCancel(url)) throw new UI.CancelledError()
|
||||
|
||||
const useOAuth = await prompts.confirm({
|
||||
message: "Does this server require OAuth authentication?",
|
||||
initialValue: false,
|
||||
})
|
||||
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
|
||||
|
||||
let mcpConfig: ConfigMCPV1.Info
|
||||
|
||||
if (useOAuth) {
|
||||
const hasClientId = await prompts.confirm({
|
||||
message: "Do you have a pre-registered client ID?",
|
||||
initialValue: false,
|
||||
})
|
||||
if (prompts.isCancel(hasClientId)) throw new UI.CancelledError()
|
||||
|
||||
if (hasClientId) {
|
||||
const clientId = await prompts.text({
|
||||
message: "Enter client ID",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(clientId)) throw new UI.CancelledError()
|
||||
|
||||
const hasSecret = await prompts.confirm({
|
||||
message: "Do you have a client secret?",
|
||||
initialValue: false,
|
||||
})
|
||||
if (prompts.isCancel(hasSecret)) throw new UI.CancelledError()
|
||||
|
||||
let clientSecret: string | undefined
|
||||
if (hasSecret) {
|
||||
const secret = await prompts.password({
|
||||
message: "Enter client secret",
|
||||
})
|
||||
if (prompts.isCancel(secret)) throw new UI.CancelledError()
|
||||
clientSecret = secret
|
||||
}
|
||||
|
||||
mcpConfig = {
|
||||
type: "remote",
|
||||
url,
|
||||
oauth: {
|
||||
clientId,
|
||||
...(clientSecret && { clientSecret }),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
mcpConfig = {
|
||||
type: "remote",
|
||||
url,
|
||||
oauth: {},
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mcpConfig = {
|
||||
type: "remote",
|
||||
url,
|
||||
}
|
||||
}
|
||||
|
||||
await addMcpToConfig(name, mcpConfig, configPath)
|
||||
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
|
||||
}
|
||||
|
||||
prompts.outro("MCP server added successfully")
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
export const McpDebugCommand = effectCmd({
|
||||
command: "debug <name>",
|
||||
describe: "debug OAuth connection for an MCP server",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("name", {
|
||||
describe: "name of the MCP server",
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.mcp.debug")(function* (args) {
|
||||
const config = yield* Config.Service.use((cfg) => cfg.get())
|
||||
const mcp = yield* MCP.Service
|
||||
const auth = yield* McpAuth.Service
|
||||
yield* Effect.promise(async () => {
|
||||
UI.empty()
|
||||
prompts.intro("MCP OAuth Debug")
|
||||
|
||||
const mcpServers = config.mcp ?? {}
|
||||
const serverName = args.name
|
||||
|
||||
const serverConfig = mcpServers[serverName]
|
||||
if (!serverConfig) {
|
||||
prompts.log.error(`MCP server not found: ${serverName}`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
if (!isMcpRemote(serverConfig)) {
|
||||
prompts.log.error(`MCP server ${serverName} is not a remote server`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
if (serverConfig.oauth === false) {
|
||||
prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
prompts.log.info(`Server: ${serverName}`)
|
||||
prompts.log.info(`URL: ${serverConfig.url}`)
|
||||
|
||||
// Check stored auth status — services already in hand, run inline.
|
||||
const { authStatus, entry } = await Effect.runPromise(
|
||||
Effect.all({
|
||||
authStatus: mcp.getAuthStatus(serverName),
|
||||
entry: auth.get(serverName),
|
||||
}),
|
||||
)
|
||||
prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
|
||||
|
||||
if (entry?.tokens) {
|
||||
prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
|
||||
if (entry.tokens.expiresAt) {
|
||||
const expiresDate = new Date(entry.tokens.expiresAt * 1000)
|
||||
const isExpired = entry.tokens.expiresAt < Date.now() / 1000
|
||||
prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`)
|
||||
}
|
||||
if (entry.tokens.refreshToken) {
|
||||
prompts.log.info(` Refresh token: present`)
|
||||
}
|
||||
}
|
||||
if (entry?.clientInfo) {
|
||||
prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`)
|
||||
if (entry.clientInfo.clientSecretExpiresAt) {
|
||||
const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000)
|
||||
prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`)
|
||||
}
|
||||
}
|
||||
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Testing connection...")
|
||||
|
||||
// Test basic HTTP connectivity first
|
||||
try {
|
||||
const response = await fetch(serverConfig.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...serverConfig.headers,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "opencode-debug", version: InstallationVersion },
|
||||
},
|
||||
id: 1,
|
||||
}),
|
||||
})
|
||||
|
||||
spinner.stop(`HTTP response: ${response.status} ${response.statusText}`)
|
||||
|
||||
// Check for WWW-Authenticate header
|
||||
const wwwAuth = response.headers.get("www-authenticate")
|
||||
if (wwwAuth) {
|
||||
prompts.log.info(`WWW-Authenticate: ${wwwAuth}`)
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
prompts.log.warn("Server returned 401 Unauthorized")
|
||||
|
||||
// Try to discover OAuth metadata
|
||||
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
|
||||
const authProvider = new McpOAuthProvider(
|
||||
serverName,
|
||||
serverConfig.url,
|
||||
{
|
||||
clientId: oauthConfig?.clientId,
|
||||
clientSecret: oauthConfig?.clientSecret,
|
||||
scope: oauthConfig?.scope,
|
||||
redirectUri: oauthConfig?.redirectUri,
|
||||
},
|
||||
{
|
||||
onRedirect: async () => {},
|
||||
},
|
||||
auth,
|
||||
)
|
||||
|
||||
prompts.log.info("Testing OAuth flow (without completing authorization)...")
|
||||
|
||||
// Try creating transport with auth provider to trigger discovery
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), {
|
||||
authProvider,
|
||||
requestInit: serverConfig.headers ? { headers: serverConfig.headers } : undefined,
|
||||
})
|
||||
|
||||
try {
|
||||
const client = new Client({
|
||||
name: "opencode-debug",
|
||||
version: InstallationVersion,
|
||||
})
|
||||
await client.connect(transport)
|
||||
prompts.log.success("Connection successful (already authenticated)")
|
||||
await client.close()
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
prompts.log.info(`OAuth flow triggered: ${error.message}`)
|
||||
|
||||
// Check if dynamic registration would be attempted
|
||||
const clientInfo = await authProvider.clientInformation()
|
||||
if (clientInfo) {
|
||||
prompts.log.info(`Client ID available: ${clientInfo.client_id}`)
|
||||
} else {
|
||||
prompts.log.info("No client ID - dynamic registration will be attempted")
|
||||
}
|
||||
} else {
|
||||
prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
} else if (response.status >= 200 && response.status < 300) {
|
||||
prompts.log.success("Server responded successfully (no auth required or already authenticated)")
|
||||
const body = await response.text()
|
||||
try {
|
||||
const json = JSON.parse(body)
|
||||
if (json.result?.serverInfo) {
|
||||
prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`)
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, ignore
|
||||
}
|
||||
} else {
|
||||
prompts.log.warn(`Unexpected status: ${response.status}`)
|
||||
const body = await response.text().catch(() => "")
|
||||
if (body) {
|
||||
prompts.log.info(`Response body: ${body.substring(0, 500)}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.stop("Connection failed", 1)
|
||||
prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
|
||||
prompts.outro("Debug complete")
|
||||
})
|
||||
}),
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user