Compare commits

..
Author SHA1 Message Date
Kit Langton 9306488cea refactor(tui): inline final Go shimmer settings 2026-04-16 23:19:07 -04:00
Kit Langton fa17f15a56 feat(tui): polish Go upsell animation and copy 2026-04-16 23:09:13 -04:00
Kit Langton 84fa5f0645 feat(tui): redesign free-limit upsell dialog with animated GO logo
- Replace generic dialog content with custom panel layout
- Add animated GO block logo reusing the existing opencode logo
  shimmer/click-burst system via a new shape prop on Logo
- Add idle shimmer mode: continuous tight white specular highlight
  traveling across lit letter pixels only (shadow cells excluded)
- Add BgPulse component: radial primary-tinted rings emanating from
  the GO center, phase-synced with the logo shimmer so rings emit
  exactly when the shine reaches the center of GO
- Pixel-accurate centering via refs on the logo box
- Buttons reordered: 'don't show again' bottom-left, 'subscribe'
  bottom-right and default-selected
2026-04-16 20:49:49 -04:00
130 changed files with 12576 additions and 12721 deletions
-1
View File
@@ -14,7 +14,6 @@
- Use Bun APIs when possible, like `Bun.file()`
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
- 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.
Reduce total variable count by inlining when a value is only used once.
+16 -16
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.4.7",
"version": "1.4.6",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -83,7 +83,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.4.7",
"version": "1.4.6",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -117,7 +117,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.4.7",
"version": "1.4.6",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -144,7 +144,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.4.7",
"version": "1.4.6",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.4.7",
"version": "1.4.6",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.4.7",
"version": "1.4.6",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -225,7 +225,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.4.7",
"version": "1.4.6",
"dependencies": {
"effect": "catalog:",
"electron-context-menu": "4.1.2",
@@ -268,7 +268,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.4.7",
"version": "1.4.6",
"dependencies": {
"@opencode-ai/shared": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -297,7 +297,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.4.7",
"version": "1.4.6",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -313,7 +313,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.4.7",
"version": "1.4.6",
"bin": {
"opencode": "./bin/opencode",
},
@@ -458,7 +458,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.4.7",
"version": "1.4.6",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -493,7 +493,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.4.7",
"version": "1.4.6",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -508,7 +508,7 @@
},
"packages/shared": {
"name": "@opencode-ai/shared",
"version": "1.4.7",
"version": "1.4.6",
"bin": {
"opencode": "./bin/opencode",
},
@@ -532,7 +532,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.4.7",
"version": "1.4.6",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -567,7 +567,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.4.7",
"version": "1.4.6",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -616,7 +616,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.4.7",
"version": "1.4.6",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.4.7",
"version": "1.4.6",
"description": "",
"type": "module",
"exports": {
+133 -45
View File
@@ -1,6 +1,6 @@
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
import { useMutation } from "@tanstack/solid-query"
import {
batch,
onCleanup,
@@ -324,7 +324,6 @@ export default function Page() {
const local = useLocal()
const file = useFile()
const sync = useSync()
const queryClient = useQueryClient()
const dialog = useDialog()
const language = useLanguage()
const sdk = useSDK()
@@ -519,6 +518,26 @@ export default function Page() {
deferRender: false,
})
const [vcs, setVcs] = createStore<{
diff: {
git: VcsFileDiff[]
branch: VcsFileDiff[]
}
ready: {
git: boolean
branch: boolean
}
}>({
diff: {
git: [] as VcsFileDiff[],
branch: [] as VcsFileDiff[],
},
ready: {
git: false,
branch: false,
},
})
const [followup, setFollowup] = persisted(
Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
createStore<{
@@ -552,6 +571,68 @@ export default function Page() {
let todoTimer: number | undefined
let diffFrame: number | undefined
let diffTimer: number | undefined
const vcsTask = new Map<VcsMode, Promise<void>>()
const vcsRun = new Map<VcsMode, number>()
const bumpVcs = (mode: VcsMode) => {
const next = (vcsRun.get(mode) ?? 0) + 1
vcsRun.set(mode, next)
return next
}
const resetVcs = (mode?: VcsMode) => {
const list = mode ? [mode] : (["git", "branch"] as const)
list.forEach((item) => {
bumpVcs(item)
vcsTask.delete(item)
setVcs("diff", item, [])
setVcs("ready", item, false)
})
}
const loadVcs = (mode: VcsMode, force = false) => {
if (sync.project?.vcs !== "git") return Promise.resolve()
if (!force && vcs.ready[mode]) return Promise.resolve()
if (force) {
if (vcsTask.has(mode)) bumpVcs(mode)
vcsTask.delete(mode)
setVcs("ready", mode, false)
}
const current = vcsTask.get(mode)
if (current) return current
const run = bumpVcs(mode)
const task = sdk.client.vcs
.diff({ mode })
.then((result) => {
if (vcsRun.get(mode) !== run) return
setVcs("diff", mode, list(result.data))
setVcs("ready", mode, true)
})
.catch((error) => {
if (vcsRun.get(mode) !== run) return
console.debug("[session-review] failed to load vcs diff", { mode, error })
setVcs("diff", mode, [])
setVcs("ready", mode, true)
})
.finally(() => {
if (vcsTask.get(mode) === task) vcsTask.delete(mode)
})
vcsTask.set(mode, task)
return task
}
const refreshVcs = () => {
resetVcs()
const mode = untrack(vcsMode)
if (!mode) return
if (!untrack(wantsReview)) return
void loadVcs(mode, true)
}
createComputed((prev) => {
const open = desktopReviewOpen()
@@ -582,52 +663,21 @@ export default function Page() {
list.push("turn")
return list
})
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
const wantsReview = createMemo(() =>
isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes",
)
const vcsMode = createMemo<VcsMode | undefined>(() => {
if (store.changes === "git" || store.changes === "branch") return store.changes
})
const vcsKey = createMemo(
() => ["session-vcs", sdk.directory, sync.data.vcs?.branch ?? "", sync.data.vcs?.default_branch ?? ""] as const,
)
const vcsQuery = createQuery(() => {
const mode = vcsMode()
const enabled = wantsReview() && sync.project?.vcs === "git"
return {
queryKey: [...vcsKey(), mode] as const,
enabled,
staleTime: Number.POSITIVE_INFINITY,
gcTime: 60 * 1000,
queryFn: mode
? () =>
sdk.client.vcs
.diff({ mode })
.then((result) => list(result.data))
.catch((error) => {
console.debug("[session-review] failed to load vcs diff", { mode, error })
return []
})
: skipToken,
}
})
const refreshVcs = () => void queryClient.invalidateQueries({ queryKey: vcsKey() })
const reviewDiffs = () => {
if (store.changes === "git" || store.changes === "branch")
// avoids suspense
return vcsQuery.isFetched ? (vcsQuery.data ?? []) : []
const reviewDiffs = createMemo(() => {
if (store.changes === "git") return list(vcs.diff.git)
if (store.changes === "branch") return list(vcs.diff.branch)
return turnDiffs()
}
const reviewCount = () => reviewDiffs().length
const hasReview = () => reviewCount() > 0
const reviewReady = () => {
if (store.changes === "git" || store.changes === "branch") return !vcsQuery.isPending
})
const reviewCount = createMemo(() => reviewDiffs().length)
const hasReview = createMemo(() => reviewCount() > 0)
const reviewReady = createMemo(() => {
if (store.changes === "git") return vcs.ready.git
if (store.changes === "branch") return vcs.ready.branch
return true
}
})
const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create"
@@ -847,6 +897,27 @@ export default function Page() {
),
)
createEffect(
on(
() => sdk.directory,
() => {
resetVcs()
},
{ defer: true },
),
)
createEffect(
on(
() => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const,
(next, prev) => {
if (prev === undefined || same(next, prev)) return
refreshVcs()
},
{ defer: true },
),
)
const stopVcs = sdk.event.listen((evt) => {
if (evt.details.type !== "file.watcher.updated") return
const props =
@@ -980,6 +1051,13 @@ export default function Page() {
}
}
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
const wantsReview = createMemo(() =>
isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes",
)
createEffect(() => {
const list = changesOptions()
if (list.includes(store.changes)) return
@@ -988,12 +1066,22 @@ export default function Page() {
setStore("changes", next)
})
createEffect(() => {
const mode = vcsMode()
if (!mode) return
if (!wantsReview()) return
void loadVcs(mode)
})
createEffect(
on(
() => sync.data.session_status[params.id ?? ""]?.type,
(next, prev) => {
const mode = vcsMode()
if (!mode) return
if (!wantsReview()) return
if (next !== "idle" || prev === undefined || prev === "idle") return
refreshVcs()
void loadVcs(mode, true)
},
{ defer: true },
),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.4.7",
"version": "1.4.6",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.4.7",
"version": "1.4.6",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.4.7",
"version": "1.4.6",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.4.7",
"version": "1.4.6",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.4.7",
"version": "1.4.6",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.4.7",
"version": "1.4.6",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.4.7",
"version": "1.4.6",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.4.7"
version = "1.4.6"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.7/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.6/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.7/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.6/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.7/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.6/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.7/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.6/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.7/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.6/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.4.7",
"version": "1.4.6",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
-4
View File
@@ -23,10 +23,6 @@ See `specs/effect/migration.md` for the compact pattern reference and examples.
- Use `Effect.callback` for callback-based APIs.
- 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.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.4.7",
"version": "1.4.6",
"name": "opencode",
"type": "module",
"license": "MIT",
-230
View File
@@ -1,230 +0,0 @@
#!/usr/bin/env bun
/**
* Automate the full per-file namespace→self-reexport migration:
*
* 1. Create a worktree at ../opencode-worktrees/ns-<slug> on a new branch
* `kit/ns-<slug>` off `origin/dev`.
* 2. Symlink `node_modules` from the main repo into the worktree root so
* builds work without a fresh `bun install`.
* 3. Run `script/unwrap-and-self-reexport.ts` on the target file inside the worktree.
* 4. Verify:
* - `bunx --bun tsgo --noEmit` (pre-existing plugin.ts cross-worktree
* noise ignored — we compare against a pre-change baseline captured
* via `git stash`, so only NEW errors fail).
* - `bun run --conditions=browser ./src/index.ts generate`.
* - Relevant tests under `test/<dir>` if that directory exists.
* 5. Commit, push with `--no-verify`, and open a PR titled after the
* namespace.
*
* Usage:
*
* bun script/batch-unwrap-pr.ts src/file/ignore.ts
* bun script/batch-unwrap-pr.ts src/file/ignore.ts src/file/watcher.ts # multiple
* bun script/batch-unwrap-pr.ts --dry-run src/file/ignore.ts # plan only
*
* Repo assumptions:
*
* - Main checkout at /Users/kit/code/open-source/opencode (configurable via
* --repo-root=...).
* - Worktree root at /Users/kit/code/open-source/opencode-worktrees
* (configurable via --worktree-root=...).
*
* The script does NOT enable auto-merge; that's a separate manual step if we
* want it.
*/
import fs from "node:fs"
import path from "node:path"
import { spawnSync, type SpawnSyncReturns } from "node:child_process"
type Cmd = string[]
function run(
cwd: string,
cmd: Cmd,
opts: { capture?: boolean; allowFail?: boolean; stdin?: string } = {},
): SpawnSyncReturns<string> {
const result = spawnSync(cmd[0], cmd.slice(1), {
cwd,
stdio: opts.capture ? ["pipe", "pipe", "pipe"] : ["inherit", "inherit", "inherit"],
encoding: "utf-8",
input: opts.stdin,
})
if (!opts.allowFail && result.status !== 0) {
const label = `${path.basename(cmd[0])} ${cmd.slice(1).join(" ")}`
console.error(`[fail] ${label} (cwd=${cwd})`)
if (opts.capture) {
if (result.stdout) console.error(result.stdout)
if (result.stderr) console.error(result.stderr)
}
process.exit(result.status ?? 1)
}
return result
}
function fileSlug(fileArg: string): string {
// src/file/ignore.ts → file-ignore
return fileArg
.replace(/^src\//, "")
.replace(/\.tsx?$/, "")
.replace(/[\/_]/g, "-")
}
function readNamespace(absFile: string): string {
const content = fs.readFileSync(absFile, "utf-8")
const match = content.match(/^export\s+namespace\s+(\w+)\s*\{/m)
if (!match) {
console.error(`no \`export namespace\` found in ${absFile}`)
process.exit(1)
}
return match[1]
}
// ---------------------------------------------------------------------------
const args = process.argv.slice(2)
const dryRun = args.includes("--dry-run")
const repoRoot = (
args.find((a) => a.startsWith("--repo-root=")) ?? "--repo-root=/Users/kit/code/open-source/opencode"
).split("=")[1]
const worktreeRoot = (
args.find((a) => a.startsWith("--worktree-root=")) ?? "--worktree-root=/Users/kit/code/open-source/opencode-worktrees"
).split("=")[1]
const targets = args.filter((a) => !a.startsWith("--"))
if (targets.length === 0) {
console.error("Usage: bun script/batch-unwrap-pr.ts <src/path.ts> [more files...] [--dry-run]")
process.exit(1)
}
if (!fs.existsSync(worktreeRoot)) fs.mkdirSync(worktreeRoot, { recursive: true })
for (const rel of targets) {
const absSrc = path.join(repoRoot, "packages", "opencode", rel)
if (!fs.existsSync(absSrc)) {
console.error(`skip ${rel}: file does not exist under ${repoRoot}/packages/opencode`)
continue
}
const slug = fileSlug(rel)
const branch = `kit/ns-${slug}`
const wt = path.join(worktreeRoot, `ns-${slug}`)
const ns = readNamespace(absSrc)
console.log(`\n=== ${rel}${ns} (branch=${branch} wt=${path.basename(wt)}) ===`)
if (dryRun) {
console.log(` would create worktree ${wt}`)
console.log(` would run unwrap on packages/opencode/${rel}`)
console.log(` would commit, push, and open PR`)
continue
}
// Sync dev (fetch only; we branch off origin/dev directly).
run(repoRoot, ["git", "fetch", "origin", "dev", "--quiet"])
// Create worktree + branch.
if (fs.existsSync(wt)) {
console.log(` worktree already exists at ${wt}; skipping`)
continue
}
run(repoRoot, ["git", "worktree", "add", "-b", branch, wt, "origin/dev"])
// Symlink node_modules so bun/tsgo work without a full install.
// We link both the repo root and packages/opencode, since the opencode
// package has its own local node_modules (including bunfig.toml preload deps
// like @opentui/solid) that aren't hoisted to the root.
const wtRootNodeModules = path.join(wt, "node_modules")
if (!fs.existsSync(wtRootNodeModules)) {
fs.symlinkSync(path.join(repoRoot, "node_modules"), wtRootNodeModules)
}
const wtOpencode = path.join(wt, "packages", "opencode")
const wtOpencodeNodeModules = path.join(wtOpencode, "node_modules")
if (!fs.existsSync(wtOpencodeNodeModules)) {
fs.symlinkSync(path.join(repoRoot, "packages", "opencode", "node_modules"), wtOpencodeNodeModules)
}
const wtTarget = path.join(wt, "packages", "opencode", rel)
// Baseline tsgo output (pre-change).
const baselinePath = path.join(wt, ".ns-baseline.txt")
const baseline = run(wtOpencode, ["bunx", "--bun", "tsgo", "--noEmit"], { capture: true, allowFail: true })
fs.writeFileSync(baselinePath, (baseline.stdout ?? "") + (baseline.stderr ?? ""))
// Run the unwrap script from the MAIN repo checkout (where the tooling
// lives) targeting the worktree's file by absolute path. We run from the
// worktree root (not `packages/opencode`) to avoid triggering the
// bunfig.toml preload, which needs `@opentui/solid` that only the TUI
// workspace has installed.
const unwrapScript = path.join(repoRoot, "packages", "opencode", "script", "unwrap-and-self-reexport.ts")
run(wt, ["bun", unwrapScript, wtTarget])
// Post-change tsgo.
const after = run(wtOpencode, ["bunx", "--bun", "tsgo", "--noEmit"], { capture: true, allowFail: true })
const afterText = (after.stdout ?? "") + (after.stderr ?? "")
// Compare line-sets to detect NEW tsgo errors.
const sanitize = (s: string) =>
s
.split("\n")
.map((l) => l.replace(/\s+$/, ""))
.filter(Boolean)
.sort()
.join("\n")
const baselineSorted = sanitize(fs.readFileSync(baselinePath, "utf-8"))
const afterSorted = sanitize(afterText)
if (baselineSorted !== afterSorted) {
console.log(` tsgo output differs from baseline. Showing diff:`)
const diffResult = spawnSync("diff", ["-u", baselinePath, "-"], { input: afterText, encoding: "utf-8" })
if (diffResult.stdout) console.log(diffResult.stdout)
if (diffResult.stderr) console.log(diffResult.stderr)
console.error(` aborting ${rel}; investigate manually in ${wt}`)
process.exit(1)
}
// SDK build.
run(wtOpencode, ["bun", "run", "--conditions=browser", "./src/index.ts", "generate"], { capture: true })
// Run tests for the directory, if a matching test dir exists.
const dirName = path.basename(path.dirname(rel))
const testDir = path.join(wt, "packages", "opencode", "test", dirName)
if (fs.existsSync(testDir)) {
const testResult = run(wtOpencode, ["bun", "run", "test", `test/${dirName}`], { capture: true, allowFail: true })
const combined = (testResult.stdout ?? "") + (testResult.stderr ?? "")
if (testResult.status !== 0) {
console.error(combined)
console.error(` tests failed for ${rel}; aborting`)
process.exit(1)
}
// Surface the summary line if present.
const summary = combined
.split("\n")
.filter((l) => /\bpass\b|\bfail\b/.test(l))
.slice(-3)
.join("\n")
if (summary) console.log(` tests: ${summary.replace(/\n/g, " | ")}`)
} else {
console.log(` tests: no test/${dirName} directory, skipping`)
}
// Clean up baseline file before committing.
fs.unlinkSync(baselinePath)
// Commit, push, open PR.
const commitMsg = `refactor: unwrap ${ns} namespace + self-reexport`
run(wt, ["git", "add", "-A"])
run(wt, ["git", "commit", "-m", commitMsg])
run(wt, ["git", "push", "-u", "origin", branch, "--no-verify"])
const prBody = [
"## Summary",
`- Unwrap the \`${ns}\` namespace in \`packages/opencode/${rel}\` to flat top-level exports.`,
`- Append \`export * as ${ns} from "./${path.basename(rel, ".ts")}"\` so consumers keep the same \`${ns}.x\` import ergonomics.`,
"",
"## Verification (local)",
"- `bunx --bun tsgo --noEmit` — no new errors vs baseline.",
"- `bun run --conditions=browser ./src/index.ts generate` — clean.",
`- \`bun run test test/${dirName}\` — all pass (if applicable).`,
].join("\n")
run(wt, ["gh", "pr", "create", "--title", commitMsg, "--base", "dev", "--body", prBody])
console.log(` PR opened for ${rel}`)
}
@@ -1,246 +0,0 @@
#!/usr/bin/env bun
/**
* Unwrap a single `export namespace` in a file into flat top-level exports
* plus a self-reexport at the bottom of the same file.
*
* Usage:
*
* bun script/unwrap-and-self-reexport.ts src/file/ignore.ts
* bun script/unwrap-and-self-reexport.ts src/file/ignore.ts --dry-run
*
* Input file shape:
*
* // imports ...
*
* export namespace FileIgnore {
* export function ...(...) { ... }
* const helper = ...
* }
*
* Output shape:
*
* // imports ...
*
* export function ...(...) { ... }
* const helper = ...
*
* export * as FileIgnore from "./ignore"
*
* What the script does:
*
* 1. Uses ast-grep to locate the single `export namespace Foo { ... }` block.
* 2. Removes the `export namespace Foo {` line and the matching closing `}`.
* 3. Dedents the body by one indent level (2 spaces).
* 4. Rewrites `Foo.Bar` self-references inside the file to just `Bar`
* (but only for names that are actually exported from the namespace —
* non-exported members get the same treatment so references remain valid).
* 5. Appends `export * as Foo from "./<basename>"` at the end of the file.
*
* What it does NOT do:
*
* - Does not create or modify barrel `index.ts` files.
* - Does not rewrite any consumer imports. Consumers already import from
* the file path itself (e.g. `import { FileIgnore } from "../file/ignore"`);
* the self-reexport keeps that import working unchanged.
* - Does not handle files with more than one `export namespace` declaration.
* The script refuses that case.
*
* Requires: ast-grep (`brew install ast-grep`).
*/
import fs from "node:fs"
import path from "node:path"
const args = process.argv.slice(2)
const dryRun = args.includes("--dry-run")
const targetArg = args.find((a) => !a.startsWith("--"))
if (!targetArg) {
console.error("Usage: bun script/unwrap-and-self-reexport.ts <file> [--dry-run]")
process.exit(1)
}
const absPath = path.resolve(targetArg)
if (!fs.existsSync(absPath) || !fs.statSync(absPath).isFile()) {
console.error(`Not a file: ${absPath}`)
process.exit(1)
}
// Locate the namespace block with ast-grep (accurate AST boundaries).
const ast = Bun.spawnSync(
["ast-grep", "run", "--pattern", "export namespace $NAME { $$$BODY }", "--lang", "typescript", "--json", absPath],
{ stdout: "pipe", stderr: "pipe" },
)
if (ast.exitCode !== 0) {
console.error("ast-grep failed:", ast.stderr.toString())
process.exit(1)
}
type AstMatch = {
range: { start: { line: number; column: number }; end: { line: number; column: number } }
metaVariables: { single: Record<string, { text: string }> }
}
const matches = JSON.parse(ast.stdout.toString()) as AstMatch[]
if (matches.length === 0) {
console.error(`No \`export namespace\` found in ${path.relative(process.cwd(), absPath)}`)
process.exit(1)
}
if (matches.length > 1) {
console.error(`File has ${matches.length} \`export namespace\` declarations — this script handles one per file.`)
for (const m of matches) console.error(` ${m.metaVariables.single.NAME.text} (line ${m.range.start.line + 1})`)
process.exit(1)
}
const match = matches[0]
const nsName = match.metaVariables.single.NAME.text
const startLine = match.range.start.line
const endLine = match.range.end.line
const original = fs.readFileSync(absPath, "utf-8")
const lines = original.split("\n")
// Split the file into before/body/after.
const before = lines.slice(0, startLine)
const body = lines.slice(startLine + 1, endLine)
const after = lines.slice(endLine + 1)
// Dedent body by one indent level (2 spaces).
const dedented = body.map((line) => {
if (line === "") return ""
if (line.startsWith(" ")) return line.slice(2)
return line
})
// Collect all top-level declared identifiers inside the namespace body so we can
// rewrite `Foo.X` → `X` when X is one of them. We gather BOTH exported and
// non-exported names because the namespace body might reference its own
// non-exported helpers via `Foo.helper` too.
const declaredNames = new Set<string>()
const declRe =
/^\s*(?:export\s+)?(?:abstract\s+)?(?:async\s+)?(?:const|let|var|function|class|interface|type|enum)\s+(\w+)/
for (const line of dedented) {
const m = line.match(declRe)
if (m) declaredNames.add(m[1])
}
// Also capture `export { X, Y }` re-exports inside the namespace.
const reExportRe = /export\s*\{\s*([^}]+)\}/g
for (const line of dedented) {
for (const reExport of line.matchAll(reExportRe)) {
for (const part of reExport[1].split(",")) {
const name = part
.trim()
.split(/\s+as\s+/)
.pop()!
.trim()
if (name) declaredNames.add(name)
}
}
}
// Rewrite `Foo.X` → `X` inside the body, avoiding matches in strings, comments,
// templates. We walk the line char-by-char rather than using a regex so we can
// skip over those segments cleanly.
let rewriteCount = 0
function rewriteLine(line: string): string {
const out: string[] = []
let i = 0
let stringQuote: string | null = null
while (i < line.length) {
const ch = line[i]
// String / template literal pass-through.
if (stringQuote) {
out.push(ch)
if (ch === "\\" && i + 1 < line.length) {
out.push(line[i + 1])
i += 2
continue
}
if (ch === stringQuote) stringQuote = null
i++
continue
}
if (ch === '"' || ch === "'" || ch === "`") {
stringQuote = ch
out.push(ch)
i++
continue
}
// Line comment: emit the rest of the line untouched.
if (ch === "/" && line[i + 1] === "/") {
out.push(line.slice(i))
i = line.length
continue
}
// Block comment: emit until "*/" if present on same line; else rest of line.
if (ch === "/" && line[i + 1] === "*") {
const end = line.indexOf("*/", i + 2)
if (end === -1) {
out.push(line.slice(i))
i = line.length
} else {
out.push(line.slice(i, end + 2))
i = end + 2
}
continue
}
// Try to match `Foo.<identifier>` at this position.
if (line.startsWith(nsName + ".", i)) {
// Make sure the char before is NOT a word character (otherwise we'd be in the middle of another identifier).
const prev = i === 0 ? "" : line[i - 1]
if (!/\w/.test(prev)) {
const after = line.slice(i + nsName.length + 1)
const nameMatch = after.match(/^([A-Za-z_$][\w$]*)/)
if (nameMatch && declaredNames.has(nameMatch[1])) {
out.push(nameMatch[1])
i += nsName.length + 1 + nameMatch[1].length
rewriteCount++
continue
}
}
}
out.push(ch)
i++
}
return out.join("")
}
const rewrittenBody = dedented.map(rewriteLine)
// Assemble the new file. Collapse multiple trailing blank lines so the
// self-reexport sits cleanly at the end.
//
// When the file is itself `index.ts`, prefer `"."` over `"./index"` — both are
// valid but `"."` matches the existing convention in the codebase (e.g.
// pty/index.ts, file/index.ts, etc.) and avoids referencing "index" literally.
const basename = path.basename(absPath, ".ts")
const reexportSource = basename === "index" ? "." : `./${basename}`
const assembled = [...before, ...rewrittenBody, ...after].join("\n")
const trimmed = assembled.replace(/\s+$/g, "")
const output = `${trimmed}\n\nexport * as ${nsName} from "${reexportSource}"\n`
if (dryRun) {
console.log(`--- dry run: ${path.relative(process.cwd(), absPath)} ---`)
console.log(`namespace: ${nsName}`)
console.log(`body lines: ${body.length}`)
console.log(`declared names: ${Array.from(declaredNames).join(", ") || "(none)"}`)
console.log(`self-refs rewr: ${rewriteCount}`)
console.log(`self-reexport: export * as ${nsName} from "${reexportSource}"`)
console.log(`output preview (last 10 lines):`)
const outputLines = output.split("\n")
for (const l of outputLines.slice(Math.max(0, outputLines.length - 10))) {
console.log(` ${l}`)
}
process.exit(0)
}
fs.writeFileSync(absPath, output)
console.log(`unwrapped ${path.relative(process.cwd(), absPath)}${nsName}`)
console.log(` body lines: ${body.length}`)
console.log(` self-refs rewr: ${rewriteCount}`)
console.log(` self-reexport: export * as ${nsName} from "${reexportSource}"`)
console.log("")
console.log("Next: verify with")
console.log(" bunx --bun tsgo --noEmit")
console.log(" bun run --conditions=browser ./src/index.ts generate")
console.log(
` bun run test test/${path.relative(path.join(path.dirname(absPath), "..", ".."), absPath).replace(/\.ts$/, "")}*`,
)
+2 -4
View File
@@ -181,10 +181,10 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Account") {}
export const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient.HttpClient> = Layer.effect(
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
Service,
Effect.gen(function* () {
const repo = yield* AccountRepo.Service
const repo = yield* AccountRepo
const http = yield* HttpClient.HttpClient
const httpRead = withTransientReadRetry(http)
const httpOk = HttpClient.filterStatusOk(http)
@@ -452,5 +452,3 @@ export const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient
)
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
export * as Account from "./account"
+24
View File
@@ -0,0 +1,24 @@
export * as Account from "./account"
export {
AccountID,
type AccountError,
AccountRepoError,
AccountServiceError,
AccountTransportError,
AccessToken,
RefreshToken,
DeviceCode,
UserCode,
Info,
Org,
OrgID,
Login,
PollSuccess,
PollPending,
PollSlow,
PollExpired,
PollDenied,
PollError,
type PollResult,
} from "./schema"
export type { AccountOrgs, ActiveOrg } from "./account"
+137 -137
View File
@@ -13,154 +13,154 @@ type DbTransactionCallback<A> = Parameters<typeof Database.transaction<A>>[0]
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 namespace AccountRepo {
export interface Service {
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 class AccountRepo extends Context.Service<AccountRepo, AccountRepo.Service>()("@opencode/AccountRepo") {
static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
AccountRepo,
Effect.gen(function* () {
const decode = Schema.decodeUnknownSync(Info)
export const layer: Layer.Layer<Service> = Layer.effect(
Service,
Effect.gen(function* () {
const decode = Schema.decodeUnknownSync(Info)
const query = <A>(f: DbTransactionCallback<A>) =>
Effect.try({
try: () => Database.use(f),
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
})
const tx = <A>(f: DbTransactionCallback<A>) =>
Effect.try({
try: () => Database.transaction(f),
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
})
const current = (db: DbClient) => {
const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get()
if (!state?.active_account_id) return
const account = 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 = (db: DbClient, 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 },
const query = <A>(f: DbTransactionCallback<A>) =>
Effect.try({
try: () => Database.use(f),
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
})
.run()
}
const active = Effect.fn("AccountRepo.active")(() =>
query((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))),
)
const tx = <A>(f: DbTransactionCallback<A>) =>
Effect.try({
try: () => Database.transaction(f),
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
})
const list = Effect.fn("AccountRepo.list")(() =>
query((db) =>
db
.select()
.from(AccountTable)
.all()
.map((row: AccountRow) => decode({ ...row, active_org_id: null })),
),
)
const current = (db: DbClient) => {
const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get()
if (!state?.active_account_id) return
const account = 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 remove = Effect.fn("AccountRepo.remove")((accountID: AccountID) =>
tx((db) => {
db.update(AccountStateTable)
.set({ active_account_id: null, active_org_id: null })
.where(eq(AccountStateTable.active_account_id, accountID))
.run()
db.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()
}).pipe(Effect.asVoid),
)
const use = Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
query((db) => state(db, accountID, orgID)).pipe(Effect.asVoid),
)
const getRow = Effect.fn("AccountRepo.getRow")((accountID: AccountID) =>
query((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
Effect.map(Option.fromNullishOr),
),
)
const persistToken = Effect.fn("AccountRepo.persistToken")((input) =>
query((db) =>
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) =>
tx((db) => {
const url = normalizeServerUrl(input.url)
db.insert(AccountTable)
.values({
id: input.id,
email: input.email,
url,
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
})
const state = (db: DbClient, 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: AccountTable.id,
set: {
target: AccountStateTable.id,
set: { active_account_id: accountID, active_org_id: id },
})
.run()
}
const active = Effect.fn("AccountRepo.active")(() =>
query((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))),
)
const list = Effect.fn("AccountRepo.list")(() =>
query((db) =>
db
.select()
.from(AccountTable)
.all()
.map((row: AccountRow) => decode({ ...row, active_org_id: null })),
),
)
const remove = Effect.fn("AccountRepo.remove")((accountID: AccountID) =>
tx((db) => {
db.update(AccountStateTable)
.set({ active_account_id: null, active_org_id: null })
.where(eq(AccountStateTable.active_account_id, accountID))
.run()
db.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()
}).pipe(Effect.asVoid),
)
const use = Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
query((db) => state(db, accountID, orgID)).pipe(Effect.asVoid),
)
const getRow = Effect.fn("AccountRepo.getRow")((accountID: AccountID) =>
query((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
Effect.map(Option.fromNullishOr),
),
)
const persistToken = Effect.fn("AccountRepo.persistToken")((input) =>
query((db) =>
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) =>
tx((db) => {
const url = normalizeServerUrl(input.url)
db.insert(AccountTable)
.values({
id: input.id,
email: input.email,
url,
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
},
})
.run()
void state(db, input.id, input.orgID)
}).pipe(Effect.asVoid),
)
})
.onConflictDoUpdate({
target: AccountTable.id,
set: {
email: input.email,
url,
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
},
})
.run()
void state(db, input.id, input.orgID)
}).pipe(Effect.asVoid),
)
return Service.of({
active,
list,
remove,
use,
getRow,
persistToken,
persistAccount,
})
}),
)
export * as AccountRepo from "./repo"
return AccountRepo.of({
active,
list,
remove,
use,
getRow,
persistToken,
persistAccount,
})
}),
)
}
File diff suppressed because it is too large Load Diff
+382 -381
View File
@@ -24,388 +24,389 @@ import { InstanceState } from "@/effect"
import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer"
export const Info = z
.object({
name: z.string(),
description: z.string().optional(),
mode: z.enum(["subagent", "primary", "all"]),
native: z.boolean().optional(),
hidden: z.boolean().optional(),
topP: z.number().optional(),
temperature: z.number().optional(),
color: z.string().optional(),
permission: Permission.Ruleset.zod,
model: z
.object({
modelID: ModelID.zod,
providerID: ProviderID.zod,
})
.optional(),
variant: z.string().optional(),
prompt: z.string().optional(),
options: z.record(z.string(), z.any()),
steps: z.number().int().positive().optional(),
})
.meta({
ref: "Agent",
})
export type Info = z.infer<typeof Info>
export interface Interface {
readonly get: (agent: string) => Effect.Effect<Info>
readonly list: () => Effect.Effect<Info[]>
readonly defaultAgent: () => Effect.Effect<string>
readonly generate: (input: {
description: string
model?: { providerID: ProviderID; modelID: ModelID }
}) => Effect.Effect<{
identifier: string
whenToUse: string
systemPrompt: string
}>
}
type State = Omit<Interface, "generate">
export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
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 state = yield* InstanceState.make<State>(
Effect.fn("Agent.state")(function* (_ctx) {
const cfg = yield* config.get()
const skillDirs = yield* skill.dirs()
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
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",
},
export namespace Agent {
export const Info = z
.object({
name: z.string(),
description: z.string().optional(),
mode: z.enum(["subagent", "primary", "all"]),
native: z.boolean().optional(),
hidden: z.boolean().optional(),
topP: z.number().optional(),
temperature: z.number().optional(),
color: z.string().optional(),
permission: Permission.Ruleset.zod,
model: z
.object({
modelID: ModelID.zod,
providerID: ProviderID.zod,
})
const user = Permission.fromConfig(cfg.permission ?? {})
const agents: Record<string, Info> = {
build: {
name: "build",
description: "The default agent. Executes tools based on configured permissions.",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
plan_enter: "allow",
}),
user,
),
mode: "primary",
native: true,
},
plan: {
name: "plan",
description: "Plan mode. Disallows all edit tools.",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
plan_exit: "allow",
external_directory: {
[path.join(Global.Path.data, "plans", "*")]: "allow",
},
edit: {
"*": "deny",
[path.join(".opencode", "plans", "*.md")]: "allow",
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "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",
codesearch: "allow",
read: "allow",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
}),
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,
},
}
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 === "build"), "desc"],
[(x) => x.name, "asc"],
),
)
})
const defaultAgent = 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.name
}
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.name
})
return {
get,
list,
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())
}),
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: ProviderID; modelID: ModelID }
}) {
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: z.object({
identifier: z.string(),
whenToUse: z.string(),
systemPrompt: z.string(),
}),
} 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))
}),
.optional(),
variant: z.string().optional(),
prompt: z.string().optional(),
options: z.record(z.string(), z.any()),
steps: z.number().int().positive().optional(),
})
}),
)
.meta({
ref: "Agent",
})
export type Info = z.infer<typeof Info>
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),
)
export interface Interface {
readonly get: (agent: string) => Effect.Effect<Agent.Info>
readonly list: () => Effect.Effect<Agent.Info[]>
readonly defaultAgent: () => Effect.Effect<string>
readonly generate: (input: {
description: string
model?: { providerID: ProviderID; modelID: ModelID }
}) => Effect.Effect<{
identifier: string
whenToUse: string
systemPrompt: string
}>
}
export * as Agent from "./agent"
type State = Omit<Interface, "generate">
export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
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 state = yield* InstanceState.make<State>(
Effect.fn("Agent.state")(function* (_ctx) {
const cfg = yield* config.get()
const skillDirs = yield* skill.dirs()
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
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> = {
build: {
name: "build",
description: "The default agent. Executes tools based on configured permissions.",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
plan_enter: "allow",
}),
user,
),
mode: "primary",
native: true,
},
plan: {
name: "plan",
description: "Plan mode. Disallows all edit tools.",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
plan_exit: "allow",
external_directory: {
[path.join(Global.Path.data, "plans", "*")]: "allow",
},
edit: {
"*": "deny",
[path.join(".opencode", "plans", "*.md")]: "allow",
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]:
"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",
codesearch: "allow",
read: "allow",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
}),
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,
},
}
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 === "build"), "desc"],
[(x) => x.name, "asc"],
),
)
})
const defaultAgent = 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.name
}
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.name
})
return {
get,
list,
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())
}),
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: ProviderID; modelID: ModelID }
}) {
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: z.object({
identifier: z.string(),
whenToUse: z.string(),
systemPrompt: z.string(),
}),
} 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),
)
}
+95
View File
@@ -0,0 +1,95 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
import { zod } from "@/util/effect-zod"
import { Global } from "../global"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
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: Schema.Number,
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,
}) {}
const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" })
export const Info = Object.assign(_Info, { zod: zod(_Info) })
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* AppFileSystem.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(AppFileSystem.defaultLayer))
+2 -97
View File
@@ -1,97 +1,2 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
import { zod } from "@/util/effect-zod"
import { Global } from "../global"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
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: Schema.Number,
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,
}) {}
const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" })
export const Info = Object.assign(_Info, { zod: zod(_Info) })
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* AppFileSystem.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(AppFileSystem.defaultLayer))
export * as Auth from "."
export * as Auth from "./auth"
export { OAUTH_DUMMY_KEY } from "./auth"
+25 -25
View File
@@ -1,33 +1,33 @@
import z from "zod"
import type { ZodType } from "zod"
export type Definition = ReturnType<typeof define>
export namespace BusEvent {
export type Definition = ReturnType<typeof define>
const registry = new Map<string, Definition>()
const registry = new Map<string, Definition>()
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
const result = {
type,
properties,
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
const result = {
type,
properties,
}
registry.set(type, result)
return result
}
registry.set(type, result)
return result
}
export function payloads() {
return registry
.entries()
.map(([type, def]) => {
return z
.object({
type: z.literal(type),
properties: def.properties,
})
.meta({
ref: `Event.${def.type}`,
})
})
.toArray()
export function payloads() {
return registry
.entries()
.map(([type, def]) => {
return z
.object({
type: z.literal(type),
properties: def.properties,
})
.meta({
ref: `Event.${def.type}`,
})
})
.toArray()
}
}
export * as BusEvent from "./bus-event"
+2 -2
View File
@@ -1,8 +1,8 @@
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 { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
import { type AccountError } from "@/account/schema"
import { AppRuntime } from "@/effect/app-runtime"
import * as Prompt from "../effect/prompt"
import open from "open"
@@ -0,0 +1,130 @@
import { BoxRenderable, RGBA } from "@opentui/core"
import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js"
import { tint, useTheme } from "@tui/context/theme"
const PERIOD = 4600
const RINGS = 3
const WIDTH = 3.8
const TAIL = 9.5
const AMP = 0.55
const TAIL_AMP = 0.16
const BREATH_AMP = 0.05
const BREATH_SPEED = 0.0008
// Offset so bg ring emits from GO center at the moment the logo pulse peaks.
const PHASE_OFFSET = 0.29
export type BgPulseMask = {
x: number
y: number
width: number
height: number
pad?: number
strength?: number
}
export function BgPulse(props: { centerX?: number; centerY?: number; masks?: BgPulseMask[] }) {
const { theme } = useTheme()
const [now, setNow] = createSignal(performance.now())
const [size, setSize] = createSignal<{ width: number; height: number }>({ width: 0, height: 0 })
let box: BoxRenderable | undefined
const timer = setInterval(() => setNow(performance.now()), 50)
onCleanup(() => clearInterval(timer))
const sync = () => {
if (!box) return
setSize({ width: box.width, height: box.height })
}
onMount(() => {
sync()
box?.on("resize", sync)
})
onCleanup(() => {
box?.off("resize", sync)
})
const grid = createMemo(() => {
const t = now()
const w = size().width
const h = size().height
if (w === 0 || h === 0) return [] as RGBA[][]
const cxv = props.centerX ?? w / 2
const cyv = props.centerY ?? h / 2
const reach = Math.hypot(Math.max(cxv, w - cxv), Math.max(cyv, h - cyv) * 2) + TAIL
const ringStates = Array.from({ length: RINGS }, (_, i) => {
const offset = i / RINGS
const phase = (t / PERIOD + offset - PHASE_OFFSET + 1) % 1
const envelope = Math.sin(phase * Math.PI)
const eased = envelope * envelope * (3 - 2 * envelope)
return {
head: phase * reach,
eased,
}
})
const normalizedMasks = props.masks?.map((m) => {
const pad = m.pad ?? 2
return {
left: m.x - pad,
right: m.x + m.width + pad,
top: m.y - pad,
bottom: m.y + m.height + pad,
pad,
strength: m.strength ?? 0.85,
}
})
const rows = [] as RGBA[][]
for (let y = 0; y < h; y++) {
const row = [] as RGBA[]
for (let x = 0; x < w; x++) {
const dx = x + 0.5 - cxv
const dy = (y + 0.5 - cyv) * 2
const dist = Math.hypot(dx, dy)
let level = 0
for (const ring of ringStates) {
const delta = dist - ring.head
const crest = Math.abs(delta) < WIDTH ? 0.5 + 0.5 * Math.cos((delta / WIDTH) * Math.PI) : 0
const tail = delta < 0 && delta > -TAIL ? (1 + delta / TAIL) ** 2.3 : 0
level += (crest * AMP + tail * TAIL_AMP) * ring.eased
}
const edgeFalloff = Math.max(0, 1 - (dist / (reach * 0.85)) ** 2)
const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP
let maskAtten = 1
if (normalizedMasks) {
for (const m of normalizedMasks) {
if (x < m.left || x > m.right || y < m.top || y > m.bottom) continue
const inX = Math.min(x - m.left, m.right - x)
const inY = Math.min(y - m.top, m.bottom - y)
const edge = Math.min(inX / m.pad, inY / m.pad, 1)
const eased = edge * edge * (3 - 2 * edge)
const reduce = 1 - m.strength * eased
if (reduce < maskAtten) maskAtten = reduce
}
}
const strength = Math.min(1, ((level / RINGS) * edgeFalloff + breath * edgeFalloff) * maskAtten)
row.push(tint(theme.backgroundPanel, theme.primary, strength * 0.7))
}
rows.push(row)
}
return rows
})
return (
<box ref={(item: BoxRenderable) => (box = item)} width="100%" height="100%">
<For each={grid()}>
{(row) => (
<box flexDirection="row">
<For each={row}>
{(color) => (
<text bg={color} fg={color} selectable={false}>
{" "}
</text>
)}
</For>
</box>
)}
</For>
</box>
)
}
@@ -1,12 +1,16 @@
import { RGBA, TextAttributes } from "@opentui/core"
import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import open from "open"
import { createSignal } from "solid-js"
import { createSignal, onCleanup, onMount } from "solid-js"
import { selectedForeground, useTheme } from "@tui/context/theme"
import { useDialog, type DialogContext } from "@tui/ui/dialog"
import { Link } from "@tui/ui/link"
import { GoLogo } from "./logo"
import { BgPulse, type BgPulseMask } from "./bg-pulse"
const GO_URL = "https://opencode.ai/go"
const PAD_X = 3
const PAD_TOP_OUTER = 1
export type DialogGoUpsellProps = {
onClose?: (dontShowAgain?: boolean) => void
@@ -27,62 +31,116 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
const dialog = useDialog()
const { theme } = useTheme()
const fg = selectedForeground(theme)
const [selected, setSelected] = createSignal(0)
const [selected, setSelected] = createSignal<"dismiss" | "subscribe">("subscribe")
const [center, setCenter] = createSignal<{ x: number; y: number } | undefined>()
const [masks, setMasks] = createSignal<BgPulseMask[]>([])
let content: BoxRenderable | undefined
let logoBox: BoxRenderable | undefined
let headingBox: BoxRenderable | undefined
let descBox: BoxRenderable | undefined
let buttonsBox: BoxRenderable | undefined
const sync = () => {
if (!content || !logoBox) return
setCenter({
x: logoBox.x - content.x + logoBox.width / 2,
y: logoBox.y - content.y + logoBox.height / 2 + PAD_TOP_OUTER,
})
const next: BgPulseMask[] = []
const baseY = PAD_TOP_OUTER
for (const b of [headingBox, descBox, buttonsBox]) {
if (!b) continue
next.push({
x: b.x - content.x,
y: b.y - content.y + baseY,
width: b.width,
height: b.height,
pad: 2,
strength: 0.78,
})
}
setMasks(next)
}
onMount(() => {
sync()
for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.on("resize", sync)
})
onCleanup(() => {
for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.off("resize", sync)
})
useKeyboard((evt) => {
if (evt.name === "left" || evt.name === "right" || evt.name === "tab") {
setSelected((s) => (s === 0 ? 1 : 0))
setSelected((s) => (s === "subscribe" ? "dismiss" : "subscribe"))
return
}
if (evt.name !== "return") return
if (selected() === 0) subscribe(props, dialog)
else dismiss(props, dialog)
if (evt.name === "return") {
if (selected() === "subscribe") subscribe(props, dialog)
else dismiss(props, dialog)
}
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Free limit reached
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
<box ref={(item: BoxRenderable) => (content = item)}>
<box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
<BgPulse centerX={center()?.x} centerY={center()?.y} masks={masks()} />
</box>
<box gap={1} paddingBottom={1}>
<text fg={theme.textMuted}>
Subscribe to OpenCode Go to keep going with reliable access to the best open-source models, starting at
$5/month.
</text>
<box flexDirection="row" gap={1}>
<box paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
<box ref={(item: BoxRenderable) => (headingBox = item)} flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Free limit reached
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box ref={(item: BoxRenderable) => (descBox = item)} gap={0}>
<box flexDirection="row">
<text fg={theme.textMuted}>Subscribe to </text>
<text attributes={TextAttributes.BOLD} fg={theme.textMuted}>
OpenCode Go
</text>
<text fg={theme.textMuted}> for reliable access to the</text>
</box>
<text fg={theme.textMuted}>best open-source models, starting at $5/month.</text>
</box>
<box alignItems="center" gap={1} paddingBottom={1}>
<box ref={(item: BoxRenderable) => (logoBox = item)}>
<GoLogo />
</box>
<Link href={GO_URL} fg={theme.primary} />
</box>
</box>
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={selected() === 0 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setSelected(0)}
onMouseUp={() => subscribe(props, dialog)}
>
<text fg={selected() === 0 ? fg : theme.text} attributes={selected() === 0 ? TextAttributes.BOLD : undefined}>
subscribe
</text>
</box>
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={selected() === 1 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setSelected(1)}
onMouseUp={() => dismiss(props, dialog)}
>
<text
fg={selected() === 1 ? fg : theme.textMuted}
attributes={selected() === 1 ? TextAttributes.BOLD : undefined}
<box ref={(item: BoxRenderable) => (buttonsBox = item)} flexDirection="row" justifyContent="space-between">
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={selected() === "dismiss" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setSelected("dismiss")}
onMouseUp={() => dismiss(props, dialog)}
>
don't show again
</text>
<text
fg={selected() === "dismiss" ? fg : theme.textMuted}
attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
>
don't show again
</text>
</box>
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={selected() === "subscribe" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setSelected("subscribe")}
onMouseUp={() => subscribe(props, dialog)}
>
<text
fg={selected() === "subscribe" ? fg : theme.text}
attributes={selected() === "subscribe" ? TextAttributes.BOLD : undefined}
>
subscribe
</text>
</box>
</box>
</box>
</box>
@@ -113,11 +113,7 @@ export function DialogSessionList() {
const today = new Date().toDateString()
return sessions()
.filter((x) => x.parentID === undefined)
.toSorted((a, b) => {
const updatedDay = new Date(b.time.updated).setHours(0, 0, 0, 0) - new Date(a.time.updated).setHours(0, 0, 0, 0)
if (updatedDay !== 0) return updatedDay
return b.time.created - a.time.created
})
.toSorted((a, b) => b.time.updated - a.time.updated)
.map((x) => {
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
@@ -1,8 +1,61 @@
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js"
import { useTheme, tint } from "@tui/context/theme"
import * as Sound from "@tui/util/sound"
import { logo } from "@/cli/logo"
import { go, logo } from "@/cli/logo"
export type LogoShape = {
left: string[]
right: string[]
}
type ShimmerConfig = {
period: number
rings: number
sweepFraction: number
coreWidth: number
coreAmp: number
softWidth: number
softAmp: number
tail: number
tailAmp: number
haloWidth: number
haloOffset: number
haloAmp: number
breathBase: number
noise: number
ambientAmp: number
ambientCenter: number
ambientWidth: number
shadowMix: number
primaryMix: number
originX: number
originY: number
}
const shimmerConfig: ShimmerConfig = {
period: 4600,
rings: 2,
sweepFraction: 1,
coreWidth: 1.2,
coreAmp: 1.9,
softWidth: 10,
softAmp: 1.6,
tail: 5,
tailAmp: 0.64,
haloWidth: 4.3,
haloOffset: 0.6,
haloAmp: 0.16,
breathBase: 0.04,
noise: 0.1,
ambientAmp: 0.36,
ambientCenter: 0.5,
ambientWidth: 0.34,
shadowMix: 0.1,
primaryMix: 0.3,
originX: 4.5,
originY: 13.5,
}
// Shadow markers (rendered chars in parens):
// _ = full shadow cell (space with bg=shadow)
@@ -74,9 +127,6 @@ type Frame = {
spark: number
}
const LEFT = logo.left[0]?.length ?? 0
const FULL = logo.left.map((line, i) => line + " ".repeat(GAP) + logo.right[i])
const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94
const NEAR = [
[1, 0],
[1, 1],
@@ -140,7 +190,7 @@ function noise(x: number, y: number, t: number) {
}
function lit(char: string) {
return char !== " " && char !== "_" && char !== "~"
return char !== " " && char !== "_" && char !== "~" && char !== ","
}
function key(x: number, y: number) {
@@ -188,12 +238,12 @@ function route(list: Array<{ x: number; y: number }>) {
return path
}
function mapGlyphs() {
function mapGlyphs(full: string[]) {
const cells = [] as Array<{ x: number; y: number }>
for (let y = 0; y < FULL.length; y++) {
for (let x = 0; x < (FULL[y]?.length ?? 0); x++) {
if (lit(FULL[y]?.[x] ?? " ")) cells.push({ x, y })
for (let y = 0; y < full.length; y++) {
for (let x = 0; x < (full[y]?.length ?? 0); x++) {
if (lit(full[y]?.[x] ?? " ")) cells.push({ x, y })
}
}
@@ -237,9 +287,25 @@ function mapGlyphs() {
return { glyph, trace, center }
}
const MAP = mapGlyphs()
type LogoContext = {
LEFT: number
FULL: string[]
SPAN: number
MAP: ReturnType<typeof mapGlyphs>
shape: LogoShape
}
function shimmer(x: number, y: number, frame: Frame) {
function build(shape: LogoShape): LogoContext {
const LEFT = shape.left[0]?.length ?? 0
const FULL = shape.left.map((line, i) => line + " ".repeat(GAP) + shape.right[i])
const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94
return { LEFT, FULL, SPAN, MAP: mapGlyphs(FULL), shape }
}
const DEFAULT = build(logo)
const GO = build(go)
function shimmer(x: number, y: number, frame: Frame, ctx: LogoContext) {
return frame.list.reduce((best, item) => {
const age = frame.t - item.at
if (age < SHIMMER_IN || age > LIFE) return best
@@ -247,7 +313,7 @@ function shimmer(x: number, y: number, frame: Frame) {
const dy = y * 2 + 1 - item.y
const dist = Math.hypot(dx, dy)
const p = age / LIFE
const r = SPAN * (1 - (1 - p) ** EXPAND)
const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
const lag = r - dist
if (lag < 0.18 || lag > SHIMMER_OUT) return best
const band = Math.exp(-(((lag - 1.05) / 0.68) ** 2))
@@ -258,19 +324,19 @@ function shimmer(x: number, y: number, frame: Frame) {
}, 0)
}
function remain(x: number, y: number, item: Release, t: number) {
function remain(x: number, y: number, item: Release, t: number, ctx: LogoContext) {
const age = t - item.at
if (age < 0 || age > LIFE) return 0
const p = age / LIFE
const dx = x + 0.5 - item.x - 0.5
const dy = y * 2 + 1 - item.y * 2 - 1
const dist = Math.hypot(dx, dy)
const r = SPAN * (1 - (1 - p) ** EXPAND)
const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
if (dist > r) return 1
return clamp((r - dist) / 1.35 < 1 ? 1 - (r - dist) / 1.35 : 0)
}
function wave(x: number, y: number, frame: Frame, live: boolean) {
function wave(x: number, y: number, frame: Frame, live: boolean, ctx: LogoContext) {
return frame.list.reduce((sum, item) => {
const age = frame.t - item.at
if (age < 0 || age > LIFE) return sum
@@ -278,7 +344,7 @@ function wave(x: number, y: number, frame: Frame, live: boolean) {
const dx = x + 0.5 - item.x
const dy = y * 2 + 1 - item.y
const dist = Math.hypot(dx, dy)
const r = SPAN * (1 - (1 - p) ** EXPAND)
const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
const fade = (1 - p) ** 1.32
const j = 1.02 + noise(x + item.x * 0.7, y + item.y * 0.7, item.at * 0.002 + age * 0.06) * 0.52
const edge = Math.exp(-(((dist - r) / WIDTH) ** 2)) * GAIN * fade * item.force * j
@@ -292,7 +358,7 @@ function wave(x: number, y: number, frame: Frame, live: boolean) {
}, 0)
}
function field(x: number, y: number, frame: Frame) {
function field(x: number, y: number, frame: Frame, ctx: LogoContext) {
const held = frame.hold
const rest = frame.release
const item = held ?? rest
@@ -326,11 +392,11 @@ function field(x: number, y: number, frame: Frame) {
Math.max(0, noise(item.x * 3.1, item.y * 2.7, frame.t * 1.7) - 0.72) *
Math.exp(-(dist * dist) / 0.15) *
lerp(0.08, 0.42, body)
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
return (core + shell + ember + ring + fork + glitch + lash + flicker - dim) * fade
}
function pick(x: number, y: number, frame: Frame) {
function pick(x: number, y: number, frame: Frame, ctx: LogoContext) {
const held = frame.hold
const rest = frame.release
const item = held ?? rest
@@ -339,26 +405,26 @@ function pick(x: number, y: number, frame: Frame) {
const dx = x + 0.5 - item.x - 0.5
const dy = y * 2 + 1 - item.y * 2 - 1
const dist = Math.hypot(dx, dy)
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
return Math.exp(-(dist * dist) / 1.7) * lerp(0.2, 0.96, rise) * fade
}
function select(x: number, y: number) {
const direct = MAP.glyph.get(key(x, y))
function select(x: number, y: number, ctx: LogoContext) {
const direct = ctx.MAP.glyph.get(key(x, y))
if (direct !== undefined) return direct
const near = NEAR.map(([dx, dy]) => MAP.glyph.get(key(x + dx, y + dy))).find(
const near = NEAR.map(([dx, dy]) => ctx.MAP.glyph.get(key(x + dx, y + dy))).find(
(item): item is number => item !== undefined,
)
return near
}
function trace(x: number, y: number, frame: Frame) {
function trace(x: number, y: number, frame: Frame, ctx: LogoContext) {
const held = frame.hold
const rest = frame.release
const item = held ?? rest
if (!item || item.glyph === undefined) return 0
const step = MAP.trace.get(key(x, y))
const step = ctx.MAP.trace.get(key(x, y))
if (!step || step.glyph !== item.glyph || step.l < 2) return 0
const age = frame.t - item.at
const rise = held ? ramp(age, HOLD, CHARGE) : rest!.rise
@@ -368,29 +434,125 @@ function trace(x: number, y: number, frame: Frame) {
const dist = Math.min(Math.abs(step.i - head), step.l - Math.abs(step.i - head))
const tail = (head - TAIL + step.l) % step.l
const lag = Math.min(Math.abs(step.i - tail), step.l - Math.abs(step.i - tail))
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
const core = Math.exp(-((dist / 1.05) ** 2)) * lerp(0.8, 2.35, rise)
const glow = Math.exp(-((dist / 1.85) ** 2)) * lerp(0.08, 0.34, rise)
const trail = Math.exp(-((lag / 1.45) ** 2)) * lerp(0.04, 0.42, rise)
return (core + glow + trail) * appear * fade
}
function bloom(x: number, y: number, frame: Frame) {
function idle(
x: number,
pixelY: number,
frame: Frame,
ctx: LogoContext,
state: IdleState,
): { glow: number; peak: number; primary: number } {
const cfg = state.cfg
const dx = x + 0.5 - cfg.originX
const dy = pixelY - cfg.originY
const dist = Math.hypot(dx, dy)
const angle = Math.atan2(dy, dx)
const wob1 = noise(x * 0.32, pixelY * 0.25, frame.t * 0.0005) - 0.5
const wob2 = noise(x * 0.12, pixelY * 0.08, frame.t * 0.00022) - 0.5
const ripple = Math.sin(angle * 3 + frame.t * 0.0012) * 0.3
const jitter = (wob1 * 0.55 + wob2 * 0.32 + ripple * 0.18) * cfg.noise
const traveled = dist + jitter
let glow = 0
let peak = 0
let halo = 0
let primary = 0
let ambient = 0
for (const active of state.active) {
const head = active.head
const eased = active.eased
const delta = traveled - head
// Use shallower exponent (1.6 vs 2) for softer edges on the Gaussians
// so adjacent pixels have smaller brightness deltas
const core = Math.exp(-(Math.abs(delta / cfg.coreWidth) ** 1.8))
const soft = Math.exp(-(Math.abs(delta / cfg.softWidth) ** 1.6))
const tailRange = cfg.tail * 2.6
const tail = delta < 0 && delta > -tailRange ? (1 + delta / tailRange) ** 2.6 : 0
const haloDelta = delta + cfg.haloOffset
const haloBand = Math.exp(-(Math.abs(haloDelta / cfg.haloWidth) ** 1.6))
glow += (soft * cfg.softAmp + tail * cfg.tailAmp) * eased
peak += core * cfg.coreAmp * eased
halo += haloBand * cfg.haloAmp * eased
// Primary-tinted fringe follows the halo (which trails behind the core) and the tail
primary += (haloBand + tail * 0.6) * eased
ambient += active.ambient
}
ambient /= state.rings
return {
glow: glow / state.rings,
peak: cfg.breathBase + ambient + (peak + halo) / state.rings,
primary: (primary / state.rings) * cfg.primaryMix,
}
}
function bloom(x: number, y: number, frame: Frame, ctx: LogoContext) {
const item = frame.glow
if (!item) return 0
const glyph = MAP.glyph.get(key(x, y))
const glyph = ctx.MAP.glyph.get(key(x, y))
if (glyph !== item.glyph) return 0
const age = frame.t - item.at
if (age < 0 || age > GLOW_OUT) return 0
const p = age / GLOW_OUT
const flash = (1 - p) ** 2
const dx = x + 0.5 - MAP.center.get(item.glyph)!.x
const dy = y * 2 + 1 - MAP.center.get(item.glyph)!.y
const dx = x + 0.5 - ctx.MAP.center.get(item.glyph)!.x
const dy = y * 2 + 1 - ctx.MAP.center.get(item.glyph)!.y
const bias = Math.exp(-((Math.hypot(dx, dy) / 2.8) ** 2))
return lerp(item.force, item.force * 0.18, p) * lerp(0.72, 1.1, bias) * flash
}
export function Logo() {
type IdleState = {
cfg: ShimmerConfig
reach: number
rings: number
active: Array<{
head: number
eased: number
ambient: number
}>
}
function buildIdleState(t: number, ctx: LogoContext): IdleState {
const cfg = shimmerConfig
const w = ctx.FULL[0]?.length ?? 1
const h = ctx.FULL.length * 2
const corners: [number, number][] = [
[0, 0],
[w, 0],
[0, h],
[w, h],
]
let maxCorner = 0
for (const [cx, cy] of corners) {
const d = Math.hypot(cx - cfg.originX, cy - cfg.originY)
if (d > maxCorner) maxCorner = d
}
const reach = maxCorner + cfg.tail * 2
const rings = Math.max(1, Math.floor(cfg.rings))
const active = [] as IdleState["active"]
for (let i = 0; i < rings; i++) {
const offset = i / rings
const cyclePhase = (t / cfg.period + offset) % 1
if (cyclePhase >= cfg.sweepFraction) continue
const phase = cyclePhase / cfg.sweepFraction
const envelope = Math.sin(phase * Math.PI)
const eased = envelope * envelope * (3 - 2 * envelope)
const d = (phase - cfg.ambientCenter) / cfg.ambientWidth
active.push({
head: phase * reach,
eased,
ambient: Math.abs(d) < 1 ? (1 - d * d) ** 2 * cfg.ambientAmp : 0,
})
}
return { cfg, reach, rings, active }
}
export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = {}) {
const ctx = props.shape ? build(props.shape) : DEFAULT
const { theme } = useTheme()
const [rings, setRings] = createSignal<Ring[]>([])
const [hold, setHold] = createSignal<Hold>()
@@ -430,6 +592,7 @@ export function Logo() {
}
if (!live) setRelease(undefined)
if (live || hold() || release() || glow()) return
if (props.idle) return
stop()
}
@@ -438,8 +601,20 @@ export function Logo() {
timer = setInterval(tick, 16)
}
onCleanup(() => {
stop()
hum = false
Sound.dispose()
})
onMount(() => {
if (!props.idle) return
setNow(performance.now())
start()
})
const hit = (x: number, y: number) => {
const char = FULL[y]?.[x]
const char = ctx.FULL[y]?.[x]
return char !== undefined && char !== " "
}
@@ -448,7 +623,7 @@ export function Logo() {
if (last) burst(last.x, last.y)
setNow(t)
if (!last) setRelease(undefined)
setHold({ x, y, at: t, glyph: select(x, y) })
setHold({ x, y, at: t, glyph: select(x, y, ctx) })
hum = false
start()
}
@@ -508,6 +683,8 @@ export function Logo() {
}
})
const idleState = createMemo(() => (props.idle ? buildIdleState(frame().t, ctx) : undefined))
const renderLine = (
line: string,
y: number,
@@ -516,24 +693,64 @@ export function Logo() {
off: number,
frame: Frame,
dusk: Frame,
state: IdleState | undefined,
): JSX.Element[] => {
const shadow = tint(theme.background, ink, 0.25)
const attrs = bold ? TextAttributes.BOLD : undefined
return Array.from(line).map((char, i) => {
const h = field(off + i, y, frame)
const n = wave(off + i, y, frame, lit(char)) + h
const s = wave(off + i, y, dusk, false) + h
const p = lit(char) ? pick(off + i, y, frame) : 0
const e = lit(char) ? trace(off + i, y, frame) : 0
const b = lit(char) ? bloom(off + i, y, frame) : 0
const q = shimmer(off + i, y, frame)
if (char === " ") {
return (
<text fg={ink} attributes={attrs} selectable={false}>
{char}
</text>
)
}
const h = field(off + i, y, frame, ctx)
const charLit = lit(char)
// Sub-pixel sampling: cells are 2 pixels tall. Sample at top (y*2) and bottom (y*2+1) pixel rows.
const pulseTop = state ? idle(off + i, y * 2, frame, ctx, state) : { glow: 0, peak: 0, primary: 0 }
const pulseBot = state ? idle(off + i, y * 2 + 1, frame, ctx, state) : { glow: 0, peak: 0, primary: 0 }
const peakMixTop = charLit ? Math.min(1, pulseTop.peak) : 0
const peakMixBot = charLit ? Math.min(1, pulseBot.peak) : 0
const primaryMixTop = charLit ? Math.min(1, pulseTop.primary) : 0
const primaryMixBot = charLit ? Math.min(1, pulseBot.primary) : 0
// Layer primary tint first, then white peak on top — so the halo/tail pulls toward primary,
// while the bright core stays pure white
const inkTopTint = primaryMixTop > 0 ? tint(ink, theme.primary, primaryMixTop) : ink
const inkBotTint = primaryMixBot > 0 ? tint(ink, theme.primary, primaryMixBot) : ink
const inkTop = peakMixTop > 0 ? tint(inkTopTint, PEAK, peakMixTop) : inkTopTint
const inkBot = peakMixBot > 0 ? tint(inkBotTint, PEAK, peakMixBot) : inkBotTint
// For the non-peak-aware brightness channels, use the average of top/bot
const pulse = {
glow: (pulseTop.glow + pulseBot.glow) / 2,
peak: (pulseTop.peak + pulseBot.peak) / 2,
primary: (pulseTop.primary + pulseBot.primary) / 2,
}
const peakMix = charLit ? Math.min(1, pulse.peak) : 0
const primaryMix = charLit ? Math.min(1, pulse.primary) : 0
const inkPrimary = primaryMix > 0 ? tint(ink, theme.primary, primaryMix) : ink
const inkTinted = peakMix > 0 ? tint(inkPrimary, PEAK, peakMix) : inkPrimary
const shadowMixCfg = state?.cfg.shadowMix ?? shimmerConfig.shadowMix
const shadowMixTop = Math.min(1, pulseTop.peak * shadowMixCfg)
const shadowMixBot = Math.min(1, pulseBot.peak * shadowMixCfg)
const shadowTop = shadowMixTop > 0 ? tint(shadow, PEAK, shadowMixTop) : shadow
const shadowBot = shadowMixBot > 0 ? tint(shadow, PEAK, shadowMixBot) : shadow
const shadowMix = Math.min(1, pulse.peak * shadowMixCfg)
const shadowTinted = shadowMix > 0 ? tint(shadow, PEAK, shadowMix) : shadow
const n = wave(off + i, y, frame, charLit, ctx) + h
const s = wave(off + i, y, dusk, false, ctx) + h
const p = charLit ? pick(off + i, y, frame, ctx) : 0
const e = charLit ? trace(off + i, y, frame, ctx) : 0
const b = charLit ? bloom(off + i, y, frame, ctx) : 0
const q = shimmer(off + i, y, frame, ctx)
if (char === "_") {
return (
<text
fg={shade(ink, theme, s * 0.08)}
bg={shade(shadow, theme, ghost(s, 0.24) + ghost(q, 0.06))}
fg={shade(inkTinted, theme, s * 0.08)}
bg={shade(shadowTinted, theme, ghost(s, 0.24) + ghost(q, 0.06))}
attributes={attrs}
selectable={false}
>
@@ -545,8 +762,8 @@ export function Logo() {
if (char === "^") {
return (
<text
fg={shade(ink, theme, n + p + e + b)}
bg={shade(shadow, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))}
fg={shade(inkTop, theme, n + p + e + b)}
bg={shade(shadowBot, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))}
attributes={attrs}
selectable={false}
>
@@ -557,34 +774,60 @@ export function Logo() {
if (char === "~") {
return (
<text fg={shade(shadow, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
<text fg={shade(shadowTop, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
</text>
)
}
if (char === " ") {
if (char === ",") {
return (
<text fg={ink} attributes={attrs} selectable={false}>
{char}
<text fg={shade(shadowBot, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
</text>
)
}
// Solid █: render as ▀ so the top pixel (fg) and bottom pixel (bg) can carry independent shimmer values
if (char === "█") {
return (
<text
fg={shade(inkTop, theme, n + p + e + b)}
bg={shade(inkBot, theme, n + p + e + b)}
attributes={attrs}
selectable={false}
>
</text>
)
}
// ▀ top-half-lit: fg uses top-pixel sample, bg stays transparent/panel
if (char === "▀") {
return (
<text fg={shade(inkTop, theme, n + p + e + b)} attributes={attrs} selectable={false}>
</text>
)
}
// ▄ bottom-half-lit: fg uses bottom-pixel sample
if (char === "▄") {
return (
<text fg={shade(inkBot, theme, n + p + e + b)} attributes={attrs} selectable={false}>
</text>
)
}
return (
<text fg={shade(ink, theme, n + p + e + b)} attributes={attrs} selectable={false}>
<text fg={shade(inkTinted, theme, n + p + e + b)} attributes={attrs} selectable={false}>
{char}
</text>
)
})
}
onCleanup(() => {
stop()
hum = false
Sound.dispose()
})
const mouse = (evt: MouseEvent) => {
if (!box) return
if ((evt.type === "down" || evt.type === "drag") && evt.button === MouseButton.LEFT) {
@@ -613,17 +856,28 @@ export function Logo() {
position="absolute"
top={0}
left={0}
width={FULL[0]?.length ?? 0}
height={FULL.length}
width={ctx.FULL[0]?.length ?? 0}
height={ctx.FULL.length}
zIndex={1}
onMouse={mouse}
/>
<For each={logo.left}>
<For each={ctx.shape.left}>
{(line, index) => (
<box flexDirection="row" gap={1}>
<box flexDirection="row">{renderLine(line, index(), theme.textMuted, false, 0, frame(), dusk())}</box>
<box flexDirection="row">
{renderLine(logo.right[index()], index(), theme.text, true, LEFT + GAP, frame(), dusk())}
{renderLine(line, index(), props.ink ?? theme.textMuted, !!props.ink, 0, frame(), dusk(), idleState())}
</box>
<box flexDirection="row">
{renderLine(
ctx.shape.right[index()],
index(),
props.ink ?? theme.text,
true,
ctx.LEFT + GAP,
frame(),
dusk(),
idleState(),
)}
</box>
</box>
)}
@@ -631,3 +885,9 @@ export function Logo() {
</box>
)
}
export function GoLogo() {
const { theme } = useTheme()
const base = tint(theme.background, theme.text, 0.62)
return <Logo shape={go} ink={base} idle />
}
@@ -26,6 +26,7 @@ const TuiLegacy = z
interface MigrateInput {
cwd: string
directories: string[]
custom?: string
}
/**
+166 -168
View File
@@ -1,5 +1,3 @@
export * as TuiConfig from "./tui"
import z from "zod"
import { mergeDeep, unique } from "remeda"
import { Context, Effect, Fiber, Layer } from "effect"
@@ -18,198 +16,198 @@ import { ConfigKeybinds } from "@/config/keybinds"
import { InstallationLocal, InstallationVersion } from "@/installation/version"
import { makeRuntime } from "@/cli/effect/runtime"
import { Filesystem, Log } from "@/util"
import { ConfigVariable } from "@/config/variable"
const log = Log.create({ service: "tui.config" })
export namespace TuiConfig {
const log = Log.create({ service: "tui.config" })
export const Info = TuiInfo
export const Info = TuiInfo
type Acc = {
result: Info
}
type Acc = {
result: Info
}
type State = {
config: Info
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
}
type State = {
config: Info
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
}
export type Info = z.output<typeof Info> & {
// Internal resolved plugin list used by runtime loading.
plugin_origins?: ConfigPlugin.Origin[]
}
export type Info = z.output<typeof Info> & {
// Internal resolved plugin list used by runtime loading.
plugin_origins?: ConfigPlugin.Origin[]
}
export interface Interface {
readonly get: () => Effect.Effect<Info>
readonly waitForDependencies: () => Effect.Effect<void>
}
export interface Interface {
readonly get: () => Effect.Effect<Info>
readonly waitForDependencies: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope {
if (Filesystem.contains(ctx.directory, file)) return "local"
// if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
return "global"
}
function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope {
if (Filesystem.contains(ctx.directory, file)) return "local"
// if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
return "global"
}
function normalize(raw: Record<string, unknown>) {
const data = { ...raw }
if (!("tui" in data)) return data
if (!isRecord(data.tui)) {
function customPath() {
return Flag.OPENCODE_TUI_CONFIG
}
function normalize(raw: Record<string, unknown>) {
const data = { ...raw }
if (!("tui" in data)) return data
if (!isRecord(data.tui)) {
delete data.tui
return data
}
const tui = data.tui
delete data.tui
return data
}
const tui = data.tui
delete data.tui
return {
...tui,
...data,
}
}
async function resolvePlugins(config: Info, configFilepath: string) {
if (!config.plugin) return config
for (let i = 0; i < config.plugin.length; i++) {
config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
}
return config
}
async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
const data = await loadFile(file)
acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return
const scope = pluginScope(file, ctx)
const plugins = ConfigPlugin.deduplicatePluginOrigins([
...(acc.result.plugin_origins ?? []),
...data.plugin.map((spec) => ({ spec, scope, source: file })),
])
acc.result.plugin = plugins.map((item) => item.spec)
acc.result.plugin_origins = plugins
}
async function loadState(ctx: { directory: string }) {
// Every config dir we may read from: global config dir, any `.opencode`
// folders between cwd and home, and OPENCODE_CONFIG_DIR.
const directories = await ConfigPaths.directories(ctx.directory)
// One-time migration: extract tui keys (theme/keybinds/tui) from existing
// opencode.json files into sibling tui.json files.
await migrateTuiConfig({ directories, cwd: ctx.directory })
const projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
const acc: Acc = {
result: {},
}
// 1. Global tui config (lowest precedence).
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
await mergeFile(acc, file, ctx)
}
// 2. Explicit OPENCODE_TUI_CONFIG override, if set.
if (Flag.OPENCODE_TUI_CONFIG) {
await mergeFile(acc, Flag.OPENCODE_TUI_CONFIG, ctx)
log.debug("loaded custom tui config", { path: Flag.OPENCODE_TUI_CONFIG })
}
// 3. Project tui files, applied root-first so the closest file wins.
for (const file of projectFiles) {
await mergeFile(acc, file, ctx)
}
// 4. `.opencode` directories (and OPENCODE_CONFIG_DIR) discovered while
// walking up the tree. Also returned below so callers can install plugin
// dependencies from each location.
const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
for (const dir of dirs) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
await mergeFile(acc, file, ctx)
return {
...tui,
...data,
}
}
const keybinds = { ...(acc.result.keybinds ?? {}) }
if (process.platform === "win32") {
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
keybinds.terminal_suspend = "none"
keybinds.input_undo ??= unique([
"ctrl+z",
...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
]).join(",")
async function resolvePlugins(config: Info, configFilepath: string) {
if (!config.plugin) return config
for (let i = 0; i < config.plugin.length; i++) {
config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
}
return config
}
acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
return {
config: acc.result,
dirs: acc.result.plugin?.length ? dirs : [],
async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
const data = await loadFile(file)
acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return
const scope = pluginScope(file, ctx)
const plugins = ConfigPlugin.deduplicatePluginOrigins([
...(acc.result.plugin_origins ?? []),
...data.plugin.map((spec) => ({ spec, scope, source: file })),
])
acc.result.plugin = plugins.map((item) => item.spec)
acc.result.plugin_origins = plugins
}
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const directory = yield* CurrentWorkingDirectory
const npm = yield* Npm.Service
const data = yield* Effect.promise(() => loadState({ directory }))
const deps = yield* Effect.forEach(
data.dirs,
(dir) =>
npm
.install(dir, {
add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
})
.pipe(Effect.forkScoped),
{
concurrency: "unbounded",
},
)
async function loadState(ctx: { directory: string }) {
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
const directories = await ConfigPaths.directories(ctx.directory)
const custom = customPath()
await migrateTuiConfig({ directories, custom, cwd: ctx.directory })
// Re-compute after migration since migrateTuiConfig may have created new tui.json files
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config))
const acc: Acc = {
result: {},
}
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
)
return Service.of({ get, waitForDependencies })
}).pipe(Effect.withSpan("TuiConfig.layer")),
)
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
await mergeFile(acc, file, ctx)
}
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer))
if (custom) {
await mergeFile(acc, custom, ctx)
log.debug("loaded custom tui config", { path: custom })
}
const { runPromise } = makeRuntime(Service, defaultLayer)
for (const file of projectFiles) {
await mergeFile(acc, file, ctx)
}
export async function waitForDependencies() {
await runPromise((svc) => svc.waitForDependencies())
}
const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
export async function get() {
return runPromise((svc) => svc.get())
}
for (const dir of dirs) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
await mergeFile(acc, file, ctx)
}
}
async function loadFile(filepath: string): Promise<Info> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}
return load(text, filepath).catch((error) => {
log.warn("failed to load tui config", { path: filepath, error })
return {}
})
}
const keybinds = { ...(acc.result.keybinds ?? {}) }
if (process.platform === "win32") {
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
keybinds.terminal_suspend = "none"
keybinds.input_undo ??= unique([
"ctrl+z",
...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
]).join(",")
}
acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
async function load(text: string, configFilepath: string): Promise<Info> {
return ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" })
.then((expanded) => ConfigParse.jsonc(expanded, configFilepath))
.then((data) => {
if (!isRecord(data)) return {}
return {
config: acc.result,
dirs: acc.result.plugin?.length ? dirs : [],
}
}
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied.
return ConfigParse.schema(Info, normalize(data), configFilepath)
})
.then((data) => resolvePlugins(data, configFilepath))
.catch((error) => {
log.warn("invalid tui config", { path: configFilepath, error })
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const directory = yield* CurrentWorkingDirectory
const npm = yield* Npm.Service
const data = yield* Effect.promise(() => loadState({ directory }))
const deps = yield* Effect.forEach(
data.dirs,
(dir) =>
npm
.install(dir, {
add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
})
.pipe(Effect.forkScoped),
{
concurrency: "unbounded",
},
)
const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config))
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
)
return Service.of({ get, waitForDependencies })
}).pipe(Effect.withSpan("TuiConfig.layer")),
)
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function waitForDependencies() {
await runPromise((svc) => svc.waitForDependencies())
}
export async function get() {
return runPromise((svc) => svc.get())
}
async function loadFile(filepath: string): Promise<Info> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}
return load(text, filepath).catch((error) => {
log.warn("failed to load tui config", { path: filepath, error })
return {}
})
}
async function load(text: string, configFilepath: string): Promise<Info> {
return ConfigParse.load(Info, text, {
type: "path",
path: configFilepath,
missing: "empty",
normalize: (data) => {
if (!isRecord(data)) return {}
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied.
return normalize(data)
},
})
.then((data) => resolvePlugins(data, configFilepath))
.catch((error) => {
log.warn("invalid tui config", { path: configFilepath, error })
return {}
})
}
}
@@ -12,7 +12,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
const [store, setStore] = createStore<Record<string, any>>()
const filePath = path.join(Global.Path.state, "kv.json")
Filesystem.readJson<Record<string, any>>(filePath)
Filesystem.readJson(filePath)
.then((x) => {
setStore(x)
})
@@ -463,7 +463,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return store.status
},
get ready() {
if (process.env.OPENCODE_FAST_BOOT) return true
return store.status !== "loading"
},
get path() {
@@ -918,113 +918,113 @@ async function installPluginBySpec(
}
}
let dir = ""
let loaded: Promise<void> | undefined
let runtime: RuntimeState | undefined
export const Slot = View
export namespace TuiPluginRuntime {
let dir = ""
let loaded: Promise<void> | undefined
let runtime: RuntimeState | undefined
export const Slot = View
export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) {
const cwd = process.cwd()
if (loaded) {
if (dir !== cwd) {
throw new Error(`TuiPluginRuntime.init() called with a different working directory. expected=${dir} got=${cwd}`)
export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) {
const cwd = process.cwd()
if (loaded) {
if (dir !== cwd) {
throw new Error(`TuiPluginRuntime.init() called with a different working directory. expected=${dir} got=${cwd}`)
}
return loaded
}
dir = cwd
loaded = load(input)
return loaded
}
dir = cwd
loaded = load(input)
return loaded
}
export function list() {
if (!runtime) return []
return listPluginStatus(runtime)
}
export async function activatePlugin(id: string) {
return activatePluginById(runtime, id, true)
}
export async function deactivatePlugin(id: string) {
return deactivatePluginById(runtime, id, true)
}
export async function addPlugin(spec: string) {
return addPluginBySpec(runtime, spec)
}
export async function installPlugin(spec: string, options?: { global?: boolean }) {
return installPluginBySpec(runtime, spec, options?.global)
}
export async function dispose() {
const task = loaded
loaded = undefined
dir = ""
if (task) await task
const state = runtime
runtime = undefined
if (!state) return
const queue = [...state.plugins].reverse()
for (const plugin of queue) {
await deactivatePluginEntry(state, plugin, false)
export function list() {
if (!runtime) return []
return listPluginStatus(runtime)
}
}
async function load(input: { api: Api; config: TuiConfig.Info }) {
const { api, config } = input
const cwd = process.cwd()
const slots = setupSlots(api)
const next: RuntimeState = {
directory: cwd,
api,
slots,
plugins: [],
plugins_by_id: new Map(),
pending: new Map(),
export async function activatePlugin(id: string) {
return activatePluginById(runtime, id, true)
}
runtime = next
try {
await Instance.provide({
export async function deactivatePlugin(id: string) {
return deactivatePluginById(runtime, id, true)
}
export async function addPlugin(spec: string) {
return addPluginBySpec(runtime, spec)
}
export async function installPlugin(spec: string, options?: { global?: boolean }) {
return installPluginBySpec(runtime, spec, options?.global)
}
export async function dispose() {
const task = loaded
loaded = undefined
dir = ""
if (task) await task
const state = runtime
runtime = undefined
if (!state) return
const queue = [...state.plugins].reverse()
for (const plugin of queue) {
await deactivatePluginEntry(state, plugin, false)
}
}
async function load(input: { api: Api; config: TuiConfig.Info }) {
const { api, config } = input
const cwd = process.cwd()
const slots = setupSlots(api)
const next: RuntimeState = {
directory: cwd,
fn: async () => {
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
}
api,
slots,
plugins: [],
plugins_by_id: new Map(),
pending: new Map(),
}
runtime = next
try {
await Instance.provide({
directory: cwd,
fn: async () => {
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
}
for (const item of INTERNAL_TUI_PLUGINS) {
log.info("loading internal tui plugin", { id: item.id })
const entry = loadInternalPlugin(item)
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
addPluginEntry(next, {
id: entry.id,
load: entry,
meta,
themes: {},
plugin: entry.module.tui,
enabled: true,
})
}
for (const item of INTERNAL_TUI_PLUGINS) {
log.info("loading internal tui plugin", { id: item.id })
const entry = loadInternalPlugin(item)
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
addPluginEntry(next, {
id: entry.id,
load: entry,
meta,
themes: {},
plugin: entry.module.tui,
enabled: true,
})
}
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
await addExternalPluginEntries(next, ready)
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
await addExternalPluginEntries(next, ready)
applyInitialPluginEnabledState(next, config)
for (const plugin of next.plugins) {
if (!plugin.enabled) continue
// Keep plugin execution sequential for deterministic side effects:
// command registration order affects keybind/command precedence,
// route registration is last-wins when ids collide,
// and hook chains rely on stable plugin ordering.
await activatePluginEntry(next, plugin, false)
}
},
})
} catch (error) {
fail("failed to load tui plugins", { directory: cwd, error })
applyInitialPluginEnabledState(next, config)
for (const plugin of next.plugins) {
if (!plugin.enabled) continue
// Keep plugin execution sequential for deterministic side effects:
// command registration order affects keybind/command precedence,
// route registration is last-wins when ids collide,
// and hook chains rely on stable plugin ordering.
await activatePluginEntry(next, plugin, false)
}
},
})
} catch (error) {
fail("failed to load tui plugins", { directory: cwd, error })
}
}
}
export * as TuiPluginRuntime from "./runtime"
@@ -54,6 +54,7 @@ import { useSDK } from "@tui/context/sdk"
import { useCommandDialog } from "@tui/component/dialog-command"
import type { DialogContext } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind"
import { parsePatch } from "diff"
import { useDialog } from "../../ui/dialog"
import { TodoItem } from "../../component/todo-item"
import { DialogMessage } from "./dialog-message"
@@ -87,7 +88,6 @@ import { getScrollAcceleration } from "../../util/scroll"
import { TuiPluginRuntime } from "../../plugin"
import { DialogGoUpsell } from "../../component/dialog-go-upsell"
import { SessionRetry } from "@/session/retry"
import { getRevertDiffFiles } from "../../util/revert-diff"
addDefaultParsers(parsers.parsers)
@@ -991,7 +991,31 @@ export function Session() {
const revertInfo = createMemo(() => session()?.revert)
const revertMessageID = createMemo(() => revertInfo()?.messageID)
const revertDiffFiles = createMemo(() => getRevertDiffFiles(revertInfo()?.diff ?? ""))
const revertDiffFiles = createMemo(() => {
const diffText = revertInfo()?.diff ?? ""
if (!diffText) return []
try {
const patches = parsePatch(diffText)
return patches.map((patch) => {
const filename = patch.newFileName || patch.oldFileName || "unknown"
const cleanFilename = filename.replace(/^[ab]\//, "")
return {
filename: cleanFilename,
additions: patch.hunks.reduce(
(sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length,
0,
),
deletions: patch.hunks.reduce(
(sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length,
0,
),
}
})
} catch {
return []
}
})
const revertRevertedMessages = createMemo(() => {
const messageID = revertMessageID()
@@ -1,18 +0,0 @@
import { parsePatch } from "diff"
export function getRevertDiffFiles(diffText: string) {
if (!diffText) return []
try {
return parsePatch(diffText).map((patch) => {
const filename = [patch.newFileName, patch.oldFileName].find((item) => item && item !== "/dev/null") ?? "unknown"
return {
filename: filename.replace(/^[ab]\//, ""),
additions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length, 0),
deletions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length, 0),
}
})
} catch {
return []
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ export const memoMap = Layer.makeMemoMapUnsafe()
export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
const getRuntime = () =>
(rt ??= ManagedRuntime.make(Layer.provideMerge(layer, Observability.layer) as Layer.Layer<I, E>, { memoMap }))
(rt ??= ManagedRuntime.make(Layer.merge(layer, Observability.layer) as Layer.Layer<I, E>, { memoMap }))
return {
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)),
+4 -4
View File
@@ -28,10 +28,10 @@ export function FormatError(input: unknown) {
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
if (NamedError.hasName(input, "ProviderModelNotFoundError")) {
const data = (input as ErrorLike).data
const suggestions: string[] = Array.isArray(data?.suggestions) ? data.suggestions : []
const suggestions = data?.suggestions as string[] | undefined
return [
`Model not found: ${data?.providerID}/${data?.modelID}`,
...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
`Try: \`opencode models\` to list available models`,
`Or check your config (opencode.json) provider/model names`,
].join("\n")
@@ -64,10 +64,10 @@ export function FormatError(input: unknown) {
const data = (input as ErrorLike).data
const path = data?.path
const message = data?.message
const issues: Array<{ message: string; path: string[] }> = Array.isArray(data?.issues) ? data.issues : []
const issues = data?.issues as Array<{ message: string; path: string[] }> | undefined
return [
`Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""),
...issues.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")),
...(issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
].join("\n")
}
+41 -41
View File
@@ -8,52 +8,52 @@ const log = Log.create({ service: "heap" })
const MINUTE = 60_000
const LIMIT = 2 * 1024 * 1024 * 1024
let timer: Timer | undefined
let lock = false
let armed = true
export namespace Heap {
let timer: Timer | undefined
let lock = false
let armed = true
export function start() {
if (!Flag.OPENCODE_AUTO_HEAP_SNAPSHOT) return
if (timer) return
export function start() {
if (!Flag.OPENCODE_AUTO_HEAP_SNAPSHOT) return
if (timer) return
const run = async () => {
if (lock) return
const run = async () => {
if (lock) return
const stat = process.memoryUsage()
if (stat.rss <= LIMIT) {
armed = true
return
}
if (!armed) return
const stat = process.memoryUsage()
if (stat.rss <= LIMIT) {
armed = true
return
}
if (!armed) return
lock = true
armed = false
const file = path.join(
Global.Path.log,
`heap-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.heapsnapshot`,
)
log.warn("heap usage exceeded limit", {
rss: stat.rss,
heap: stat.heapUsed,
file,
})
await Promise.resolve()
.then(() => writeHeapSnapshot(file))
.catch((err) => {
log.error("failed to write heap snapshot", {
error: err instanceof Error ? err.message : String(err),
file,
})
lock = true
armed = false
const file = path.join(
Global.Path.log,
`heap-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.heapsnapshot`,
)
log.warn("heap usage exceeded limit", {
rss: stat.rss,
heap: stat.heapUsed,
file,
})
lock = false
await Promise.resolve()
.then(() => writeHeapSnapshot(file))
.catch((err) => {
log.error("failed to write heap snapshot", {
error: err instanceof Error ? err.message : String(err),
file,
})
})
lock = false
}
timer = setInterval(() => {
void run()
}, MINUTE)
timer.unref?.()
}
timer = setInterval(() => {
void run()
}, MINUTE)
timer.unref?.()
}
export * as Heap from "./heap"
+6 -1
View File
@@ -3,4 +3,9 @@ export const logo = {
right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"],
}
export const marks = "_^~"
export const go = {
left: [" ", "█▀▀▀", "█_^█", "▀▀▀▀"],
right: [" ", "█▀▀█", "█__█", "▀▀▀▀"],
}
export const marks = "_^~,"
+113 -113
View File
@@ -3,131 +3,131 @@ import { EOL } from "os"
import { NamedError } from "@opencode-ai/shared/util/error"
import { logo as glyphs } from "./logo"
const wordmark = [
``,
`█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█`,
` █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀`,
`▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`,
]
export namespace UI {
const wordmark = [
``,
`▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀`,
`█ █ █ █ █▀▀▀ █ █ █ █ █ █ █▀▀▀`,
`▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`,
]
export const CancelledError = NamedError.create("UICancelledError", z.void())
export const CancelledError = NamedError.create("UICancelledError", z.void())
export const Style = {
TEXT_HIGHLIGHT: "\x1b[96m",
TEXT_HIGHLIGHT_BOLD: "\x1b[96m\x1b[1m",
TEXT_DIM: "\x1b[90m",
TEXT_DIM_BOLD: "\x1b[90m\x1b[1m",
TEXT_NORMAL: "\x1b[0m",
TEXT_NORMAL_BOLD: "\x1b[1m",
TEXT_WARNING: "\x1b[93m",
TEXT_WARNING_BOLD: "\x1b[93m\x1b[1m",
TEXT_DANGER: "\x1b[91m",
TEXT_DANGER_BOLD: "\x1b[91m\x1b[1m",
TEXT_SUCCESS: "\x1b[92m",
TEXT_SUCCESS_BOLD: "\x1b[92m\x1b[1m",
TEXT_INFO: "\x1b[94m",
TEXT_INFO_BOLD: "\x1b[94m\x1b[1m",
}
export const Style = {
TEXT_HIGHLIGHT: "\x1b[96m",
TEXT_HIGHLIGHT_BOLD: "\x1b[96m\x1b[1m",
TEXT_DIM: "\x1b[90m",
TEXT_DIM_BOLD: "\x1b[90m\x1b[1m",
TEXT_NORMAL: "\x1b[0m",
TEXT_NORMAL_BOLD: "\x1b[1m",
TEXT_WARNING: "\x1b[93m",
TEXT_WARNING_BOLD: "\x1b[93m\x1b[1m",
TEXT_DANGER: "\x1b[91m",
TEXT_DANGER_BOLD: "\x1b[91m\x1b[1m",
TEXT_SUCCESS: "\x1b[92m",
TEXT_SUCCESS_BOLD: "\x1b[92m\x1b[1m",
TEXT_INFO: "\x1b[94m",
TEXT_INFO_BOLD: "\x1b[94m\x1b[1m",
}
export function println(...message: string[]) {
print(...message)
process.stderr.write(EOL)
}
export function println(...message: string[]) {
print(...message)
process.stderr.write(EOL)
}
export function print(...message: string[]) {
blank = false
process.stderr.write(message.join(" "))
}
export function print(...message: string[]) {
blank = false
process.stderr.write(message.join(" "))
}
let blank = false
export function empty() {
if (blank) return
println("" + Style.TEXT_NORMAL)
blank = true
}
let blank = false
export function empty() {
if (blank) return
println("" + Style.TEXT_NORMAL)
blank = true
}
export function logo(pad?: string) {
if (!process.stdout.isTTY && !process.stderr.isTTY) {
const result = []
for (const row of wordmark) {
if (pad) result.push(pad)
result.push(row)
result.push(EOL)
export function logo(pad?: string) {
if (!process.stdout.isTTY && !process.stderr.isTTY) {
const result = []
for (const row of wordmark) {
if (pad) result.push(pad)
result.push(row)
result.push(EOL)
}
return result.join("").trimEnd()
}
const result: string[] = []
const reset = "\x1b[0m"
const left = {
fg: "\x1b[90m",
shadow: "\x1b[38;5;235m",
bg: "\x1b[48;5;235m",
}
const right = {
fg: reset,
shadow: "\x1b[38;5;238m",
bg: "\x1b[48;5;238m",
}
const gap = " "
const draw = (line: string, fg: string, shadow: string, bg: string) => {
const parts: string[] = []
for (const char of line) {
if (char === "_") {
parts.push(bg, " ", reset)
continue
}
if (char === "^") {
parts.push(fg, bg, "▀", reset)
continue
}
if (char === "~") {
parts.push(shadow, "▀", reset)
continue
}
if (char === " ") {
parts.push(" ")
continue
}
parts.push(fg, char, reset)
}
return parts.join("")
}
glyphs.left.forEach((row, index) => {
if (pad) result.push(pad)
result.push(draw(row, left.fg, left.shadow, left.bg))
result.push(gap)
const other = glyphs.right[index] ?? ""
result.push(draw(other, right.fg, right.shadow, right.bg))
result.push(EOL)
})
return result.join("").trimEnd()
}
const result: string[] = []
const reset = "\x1b[0m"
const left = {
fg: "\x1b[90m",
shadow: "\x1b[38;5;235m",
bg: "\x1b[48;5;235m",
}
const right = {
fg: reset,
shadow: "\x1b[38;5;238m",
bg: "\x1b[48;5;238m",
}
const gap = " "
const draw = (line: string, fg: string, shadow: string, bg: string) => {
const parts: string[] = []
for (const char of line) {
if (char === "_") {
parts.push(bg, " ", reset)
continue
}
if (char === "^") {
parts.push(fg, bg, "▀", reset)
continue
}
if (char === "~") {
parts.push(shadow, "▀", reset)
continue
}
if (char === " ") {
parts.push(" ")
continue
}
parts.push(fg, char, reset)
}
return parts.join("")
}
glyphs.left.forEach((row, index) => {
if (pad) result.push(pad)
result.push(draw(row, left.fg, left.shadow, left.bg))
result.push(gap)
const other = glyphs.right[index] ?? ""
result.push(draw(other, right.fg, right.shadow, right.bg))
result.push(EOL)
})
return result.join("").trimEnd()
}
export async function input(prompt: string): Promise<string> {
const readline = require("readline")
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
return new Promise((resolve) => {
rl.question(prompt, (answer: string) => {
rl.close()
resolve(answer.trim())
export async function input(prompt: string): Promise<string> {
const readline = require("readline")
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
})
}
export function error(message: string) {
if (message.startsWith("Error: ")) {
message = message.slice("Error: ".length)
return new Promise((resolve) => {
rl.question(prompt, (answer: string) => {
rl.close()
resolve(answer.trim())
})
})
}
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
}
export function markdown(text: string): string {
return text
}
export function error(message: string) {
if (message.startsWith("Error: ")) {
message = message.slice("Error: ".length)
}
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
}
export * as UI from "./ui"
export function markdown(text: string): string {
return text
}
}
+75 -19
View File
@@ -12,12 +12,14 @@ import { Auth } from "../auth"
import { Env } from "../env"
import { applyEdits, modify } from "jsonc-parser"
import { Instance, type InstanceContext } from "../project/instance"
import * as LSPServer from "../lsp/server"
import { InstallationLocal, InstallationVersion } from "@/installation/version"
import { existsSync } from "fs"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { Account } from "@/account/account"
import { Account } from "@/account"
import { isRecord } from "@/util/record"
import { InvalidError, JsonError } from "./error"
import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { InstanceState } from "@/effect"
@@ -36,9 +38,6 @@ import { ConfigPermission } from "./permission"
import { ConfigProvider } from "./provider"
import { ConfigSkills } from "./skills"
import { ConfigPaths } from "./paths"
import { ConfigFormatter } from "./formatter"
import { ConfigLSP } from "./lsp"
import { ConfigVariable } from "./variable"
const log = Log.create({ service: "config" })
@@ -188,8 +187,56 @@ export const Info = z
)
.optional()
.describe("MCP (Model Context Protocol) server configurations"),
formatter: ConfigFormatter.Info.optional(),
lsp: ConfigLSP.Info.optional(),
formatter: z
.union([
z.literal(false),
z.record(
z.string(),
z.object({
disabled: z.boolean().optional(),
command: z.array(z.string()).optional(),
environment: z.record(z.string(), z.string()).optional(),
extensions: z.array(z.string()).optional(),
}),
),
])
.optional(),
lsp: z
.union([
z.literal(false),
z.record(
z.string(),
z.union([
z.object({
disabled: z.literal(true),
}),
z.object({
command: z.array(z.string()),
extensions: z.array(z.string()).optional(),
disabled: z.boolean().optional(),
env: z.record(z.string(), z.string()).optional(),
initialization: z.record(z.string(), z.any()).optional(),
}),
]),
),
])
.optional()
.refine(
(data) => {
if (!data) return true
if (typeof data === "boolean") return true
const serverIds = new Set(Object.values(LSPServer).map((s) => s.id))
return Object.entries(data).every(([id, config]) => {
if (config.disabled) return true
if (serverIds.has(id)) return true
return Boolean(config.extensions)
})
},
{
error: "For custom LSP servers, 'extensions' array is required.",
},
),
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
permission: ConfigPermission.Info.optional(),
@@ -328,16 +375,24 @@ export const layer = Layer.effect(
text: string,
options: { path: string } | { dir: string; source: string },
) {
const source = "path" in options ? options.path : options.source
const expanded = yield* Effect.promise(() =>
ConfigVariable.substitute(
"path" in options ? { text, type: "path", path: options.path } : { text, type: "virtual", ...options },
),
)
const parsed = ConfigParse.jsonc(expanded, source)
const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source)
if (!("path" in options)) return data
if (!("path" in options)) {
return yield* Effect.promise(() =>
ConfigParse.load(Info, text, {
type: "virtual",
dir: options.dir,
source: options.source,
normalize: normalizeLoadedConfig,
}),
)
}
const data = yield* Effect.promise(() =>
ConfigParse.load(Info, text, {
type: "path",
path: options.path,
normalize: normalizeLoadedConfig,
}),
)
yield* Effect.promise(() => resolveLoadedPlugins(data, options.path))
if (!data.$schema) {
data.$schema = "https://opencode.ai/config.json"
@@ -718,16 +773,17 @@ export const layer = Layer.effect(
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
const file = globalConfigFile()
const before = (yield* readConfigFile(file)) ?? "{}"
const input = writable(config)
let next: Info
if (!file.endsWith(".jsonc")) {
const existing = ConfigParse.schema(Info, ConfigParse.jsonc(before, file), file)
const merged = mergeDeep(writable(existing), writable(config))
const existing = ConfigParse.parse(Info, before, file)
const merged = mergeDeep(writable(existing), input)
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
next = merged
} else {
const updated = patchJsonc(before, writable(config))
next = ConfigParse.schema(Info, ConfigParse.jsonc(updated, file), file)
const updated = patchJsonc(before, input)
next = ConfigParse.parse(Info, updated, file)
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}
-13
View File
@@ -1,13 +0,0 @@
export * as ConfigFormatter from "./formatter"
import z from "zod"
export const Entry = z.object({
disabled: z.boolean().optional(),
command: z.array(z.string()).optional(),
environment: z.record(z.string(), z.string()).optional(),
extensions: z.array(z.string()).optional(),
})
export const Info = z.union([z.boolean(), z.record(z.string(), Entry)])
export type Info = z.infer<typeof Info>
-2
View File
@@ -2,8 +2,6 @@ export * as Config from "./config"
export * as ConfigAgent from "./agent"
export * as ConfigCommand from "./command"
export * as ConfigError from "./error"
export * as ConfigFormatter from "./formatter"
export * as ConfigLSP from "./lsp"
export * as ConfigVariable from "./variable"
export { ConfigManaged } from "./managed"
export * as ConfigMarkdown from "./markdown"
+2 -12
View File
@@ -106,12 +106,7 @@ export const Keybinds = z
input_delete_to_line_start: z.string().optional().default("ctrl+u").describe("Delete to start of line in input"),
input_backspace: z.string().optional().default("backspace,shift+backspace").describe("Backspace in input"),
input_delete: z.string().optional().default("ctrl+d,delete,shift+delete").describe("Delete character in input"),
input_undo: z
.string()
.optional()
// On Windows prepend ctrl+z since terminal_suspend releases the binding.
.default(process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z")
.describe("Undo in input"),
input_undo: z.string().optional().default("ctrl+-,super+z").describe("Undo in input"),
input_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"),
input_word_forward: z
.string()
@@ -149,12 +144,7 @@ export const Keybinds = z
session_child_cycle: z.string().optional().default("right").describe("Go to next child session"),
session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"),
session_parent: z.string().optional().default("up").describe("Go to parent session"),
terminal_suspend: z
.string()
.optional()
.default("ctrl+z")
.transform((v) => (process.platform === "win32" ? "none" : v))
.describe("Suspend terminal"),
terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"),
terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"),
tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"),
plugin_manager: z.string().optional().default("none").describe("Open plugin manager dialog"),
-37
View File
@@ -1,37 +0,0 @@
export * as ConfigLSP from "./lsp"
import z from "zod"
import * as LSPServer from "../lsp/server"
export const Disabled = z.object({
disabled: z.literal(true),
})
export const Entry = z.union([
Disabled,
z.object({
command: z.array(z.string()),
extensions: z.array(z.string()).optional(),
disabled: z.boolean().optional(),
env: z.record(z.string(), z.string()).optional(),
initialization: z.record(z.string(), z.any()).optional(),
}),
])
export const Info = z.union([z.boolean(), z.record(z.string(), Entry)]).refine(
(data) => {
if (typeof data === "boolean") return true
const serverIds = new Set(Object.values(LSPServer).map((server) => server.id))
return Object.entries(data).every(([id, config]) => {
if (config.disabled) return true
if (serverIds.has(id)) return true
return Boolean(config.extensions)
})
},
{
error: "For custom LSP servers, 'extensions' array is required.",
},
)
export type Info = z.infer<typeof Info>
+65 -63
View File
@@ -1,68 +1,70 @@
import z from "zod"
export const Local = z
.object({
type: z.literal("local").describe("Type of MCP server connection"),
command: z.string().array().describe("Command and arguments to run the MCP server"),
environment: z
.record(z.string(), z.string())
.optional()
.describe("Environment variables to set when running the MCP server"),
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
timeout: z
.number()
.int()
.positive()
.optional()
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
})
.strict()
.meta({
ref: "McpLocalConfig",
})
export namespace ConfigMCP {
export const Local = z
.object({
type: z.literal("local").describe("Type of MCP server connection"),
command: z.string().array().describe("Command and arguments to run the MCP server"),
environment: z
.record(z.string(), z.string())
.optional()
.describe("Environment variables to set when running the MCP server"),
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
timeout: z
.number()
.int()
.positive()
.optional()
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
})
.strict()
.meta({
ref: "McpLocalConfig",
})
export const OAuth = z
.object({
clientId: z
.string()
.optional()
.describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
scope: z.string().optional().describe("OAuth scopes to request during authorization"),
redirectUri: z
.string()
.optional()
.describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
})
.strict()
.meta({
ref: "McpOAuthConfig",
})
export type OAuth = z.infer<typeof OAuth>
export const OAuth = z
.object({
clientId: z
.string()
.optional()
.describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
scope: z.string().optional().describe("OAuth scopes to request during authorization"),
redirectUri: z
.string()
.optional()
.describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
})
.strict()
.meta({
ref: "McpOAuthConfig",
})
export type OAuth = z.infer<typeof OAuth>
export const Remote = z
.object({
type: z.literal("remote").describe("Type of MCP server connection"),
url: z.string().describe("URL of the remote MCP server"),
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"),
oauth: z
.union([OAuth, z.literal(false)])
.optional()
.describe("OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection."),
timeout: z
.number()
.int()
.positive()
.optional()
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
})
.strict()
.meta({
ref: "McpRemoteConfig",
})
export const Remote = z
.object({
type: z.literal("remote").describe("Type of MCP server connection"),
url: z.string().describe("URL of the remote MCP server"),
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"),
oauth: z
.union([OAuth, z.literal(false)])
.optional()
.describe(
"OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.",
),
timeout: z
.number()
.int()
.positive()
.optional()
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
})
.strict()
.meta({
ref: "McpRemoteConfig",
})
export const Info = z.discriminatedUnion("type", [Local, Remote])
export type Info = z.infer<typeof Info>
export * as ConfigMCP from "./mcp"
export const Info = z.discriminatedUnion("type", [Local, Remote])
export type Info = z.infer<typeof Info>
}
+59 -23
View File
@@ -1,44 +1,80 @@
export * as ConfigParse from "./parse"
import { type ParseError as JsoncParseError, parse as parseJsoncImpl, printParseErrorCode } from "jsonc-parser"
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
import z from "zod"
import { ConfigVariable } from "./variable"
import { InvalidError, JsonError } from "./error"
type Schema<T> = z.ZodType<T>
type VariableMode = "error" | "empty"
export function jsonc(text: string, filepath: string): unknown {
export type LoadOptions =
| {
type: "path"
path: string
missing?: VariableMode
normalize?: (data: unknown, source: string) => unknown
}
| {
type: "virtual"
dir: string
source: string
missing?: VariableMode
normalize?: (data: unknown, source: string) => unknown
}
function issues(text: string, errors: JsoncParseError[]) {
const lines = text.split("\n")
return errors
.map((e) => {
const beforeOffset = text.substring(0, e.offset).split("\n")
const line = beforeOffset.length
const column = beforeOffset[beforeOffset.length - 1].length + 1
const problemLine = lines[line - 1]
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
if (!problemLine) return error
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
})
.join("\n")
}
export function parse<T>(schema: Schema<T>, text: string, filepath: string): T {
const errors: JsoncParseError[] = []
const data = parseJsoncImpl(text, errors, { allowTrailingComma: true })
const data = parseJsonc(text, errors, { allowTrailingComma: true })
if (errors.length) {
const lines = text.split("\n")
const issues = errors
.map((e) => {
const beforeOffset = text.substring(0, e.offset).split("\n")
const line = beforeOffset.length
const column = beforeOffset[beforeOffset.length - 1].length + 1
const problemLine = lines[line - 1]
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
if (!problemLine) return error
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
})
.join("\n")
throw new JsonError({
path: filepath,
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${issues}\n--- End ---`,
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${issues(text, errors)}\n--- End ---`,
})
}
return data
}
export function schema<T>(schema: Schema<T>, data: unknown, source: string): T {
const parsed = schema.safeParse(data)
if (parsed.success) return parsed.data
throw new InvalidError({
path: source,
path: filepath,
issues: parsed.error.issues,
})
}
export async function load<T>(schema: Schema<T>, text: string, options: LoadOptions): Promise<T> {
const source = options.type === "path" ? options.path : options.source
const expanded = await ConfigVariable.substitute(
text,
options.type === "path" ? { type: "path", path: options.path } : options,
options.missing,
)
const data = parse(z.unknown(), expanded, source)
const normalized = options.normalize ? options.normalize(data, source) : data
const parsed = schema.safeParse(normalized)
if (!parsed.success) {
throw new InvalidError({
path: source,
issues: parsed.error.issues,
})
}
return parsed.data
}
+115 -115
View File
@@ -1,120 +1,120 @@
import z from "zod"
export const Model = z
.object({
id: z.string(),
name: z.string(),
family: z.string().optional(),
release_date: z.string(),
attachment: z.boolean(),
reasoning: z.boolean(),
temperature: z.boolean(),
tool_call: z.boolean(),
interleaved: z
.union([
z.literal(true),
z
.object({
field: z.enum(["reasoning_content", "reasoning_details"]),
})
.strict(),
])
.optional(),
cost: z
.object({
input: z.number(),
export namespace ConfigProvider {
export const Model = z
.object({
id: z.string(),
name: z.string(),
family: z.string().optional(),
release_date: z.string(),
attachment: z.boolean(),
reasoning: z.boolean(),
temperature: z.boolean(),
tool_call: z.boolean(),
interleaved: z
.union([
z.literal(true),
z
.object({
field: z.enum(["reasoning_content", "reasoning_details"]),
})
.strict(),
])
.optional(),
cost: z
.object({
input: z.number(),
output: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
context_over_200k: z
.object({
input: z.number(),
output: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
})
.optional(),
})
.optional(),
limit: z.object({
context: z.number(),
input: z.number().optional(),
output: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
context_over_200k: z
.object({
input: z.number(),
output: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
})
.optional(),
})
.optional(),
limit: z.object({
context: z.number(),
input: z.number().optional(),
output: z.number(),
}),
modalities: z
.object({
input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
})
.optional(),
experimental: z.boolean().optional(),
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
options: z.record(z.string(), z.any()),
headers: z.record(z.string(), z.string()).optional(),
variants: z
.record(
z.string(),
z
.object({
disabled: z.boolean().optional().describe("Disable this variant for the model"),
})
.catchall(z.any()),
)
.optional()
.describe("Variant-specific configuration"),
})
.partial()
}),
modalities: z
.object({
input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
})
.optional(),
experimental: z.boolean().optional(),
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
options: z.record(z.string(), z.any()),
headers: z.record(z.string(), z.string()).optional(),
variants: z
.record(
z.string(),
z
.object({
disabled: z.boolean().optional().describe("Disable this variant for the model"),
})
.catchall(z.any()),
)
.optional()
.describe("Variant-specific configuration"),
})
.partial()
export const Info = z
.object({
api: z.string().optional(),
name: z.string(),
env: z.array(z.string()),
id: z.string(),
npm: z.string().optional(),
whitelist: z.array(z.string()).optional(),
blacklist: z.array(z.string()).optional(),
options: z
.object({
apiKey: z.string().optional(),
baseURL: z.string().optional(),
enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"),
setCacheKey: z.boolean().optional().describe("Enable promptCacheKey for this provider (default false)"),
timeout: z
.union([
z
.number()
.int()
.positive()
.describe(
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
),
z.literal(false).describe("Disable timeout for this provider entirely."),
])
.optional()
.describe(
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
),
chunkTimeout: z
.number()
.int()
.positive()
.optional()
.describe(
"Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.",
),
})
.catchall(z.any())
.optional(),
models: z.record(z.string(), Model).optional(),
})
.partial()
.strict()
.meta({
ref: "ProviderConfig",
})
export const Info = z
.object({
api: z.string().optional(),
name: z.string(),
env: z.array(z.string()),
id: z.string(),
npm: z.string().optional(),
whitelist: z.array(z.string()).optional(),
blacklist: z.array(z.string()).optional(),
options: z
.object({
apiKey: z.string().optional(),
baseURL: z.string().optional(),
enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"),
setCacheKey: z.boolean().optional().describe("Enable promptCacheKey for this provider (default false)"),
timeout: z
.union([
z
.number()
.int()
.positive()
.describe(
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
),
z.literal(false).describe("Disable timeout for this provider entirely."),
])
.optional()
.describe(
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
),
chunkTimeout: z
.number()
.int()
.positive()
.optional()
.describe(
"Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.",
),
})
.catchall(z.any())
.optional(),
models: z.record(z.string(), Model).optional(),
})
.partial()
.strict()
.meta({
ref: "ProviderConfig",
})
export type Info = z.infer<typeof Info>
export * as ConfigProvider from "./provider"
export type Info = z.infer<typeof Info>
}
+10 -10
View File
@@ -1,13 +1,13 @@
import z from "zod"
export const Info = z.object({
paths: z.array(z.string()).optional().describe("Additional paths to skill folders"),
urls: z
.array(z.string())
.optional()
.describe("URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)"),
})
export namespace ConfigSkills {
export const Info = z.object({
paths: z.array(z.string()).optional().describe("Additional paths to skill folders"),
urls: z
.array(z.string())
.optional()
.describe("URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)"),
})
export type Info = z.infer<typeof Info>
export * as ConfigSkills from "./skills"
export type Info = z.infer<typeof Info>
}
+2 -8
View File
@@ -16,11 +16,6 @@ type ParseSource =
dir: string
}
type SubstituteInput = ParseSource & {
text: string
missing?: "error" | "empty"
}
function source(input: ParseSource) {
return input.type === "path" ? input.path : input.source
}
@@ -30,9 +25,8 @@ function dir(input: ParseSource) {
}
/** Apply {env:VAR} and {file:path} substitutions to config text. */
export async function substitute(input: SubstituteInput) {
const missing = input.missing ?? "error"
let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
export async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
@@ -1,16 +1,12 @@
import { lazy } from "@/util/lazy"
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import type { ProjectID } from "@/project/schema"
import type { WorkspaceAdaptor } from "../types"
export const WorkspaceAdaptorEntry = Schema.Struct({
type: Schema.String,
name: Schema.String,
description: Schema.String,
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type WorkspaceAdaptorEntry = Schema.Schema.Type<typeof WorkspaceAdaptorEntry>
export type WorkspaceAdaptorEntry = {
type: string
name: string
description: string
}
const BUILTIN: Record<string, () => Promise<WorkspaceAdaptor>> = {
worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor),
@@ -4,9 +4,9 @@ import { Worktree } from "@/worktree"
import { type WorkspaceAdaptor, WorkspaceInfo } from "../types"
const WorktreeConfig = z.object({
name: z.string(),
branch: z.string(),
directory: z.string(),
name: WorkspaceInfo.shape.name,
branch: WorkspaceInfo.shape.branch.unwrap(),
directory: WorkspaceInfo.shape.directory.unwrap(),
})
export const WorktreeAdaptor: WorkspaceAdaptor = {
+9 -14
View File
@@ -1,22 +1,17 @@
import z from "zod"
import { Schema } from "effect"
import { ProjectID } from "@/project/schema"
import { WorkspaceID } from "./schema"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
export const WorkspaceInfo = Schema.Struct({
id: WorkspaceID,
type: Schema.String,
name: Schema.String,
branch: Schema.NullOr(Schema.String),
directory: Schema.NullOr(Schema.String),
extra: Schema.NullOr(Schema.Unknown),
projectID: ProjectID,
export const WorkspaceInfo = z.object({
id: WorkspaceID.zod,
type: z.string(),
name: z.string(),
branch: z.string().nullable(),
directory: z.string().nullable(),
extra: z.unknown().nullable(),
projectID: ProjectID.zod,
})
.annotate({ identifier: "Workspace" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type WorkspaceInfo = Schema.Schema.Type<typeof WorkspaceInfo>
export type WorkspaceInfo = z.infer<typeof WorkspaceInfo>
export type Target =
| {
+439 -440
View File
@@ -25,241 +25,174 @@ import { errorData } from "@/util/error"
import { AppRuntime } from "@/effect/app-runtime"
import { EventSequenceTable } from "@/sync/event.sql"
import { waitEvent } from "./util"
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
export const Info = WorkspaceInfo
export type Info = WorkspaceInfo
export namespace Workspace {
export const Info = WorkspaceInfo.meta({
ref: "Workspace",
})
export type Info = z.infer<typeof Info>
export const ConnectionStatus = Schema.Struct({
workspaceID: WorkspaceID,
status: Schema.Literals(["connected", "connecting", "disconnected", "error"]),
error: Schema.optional(Schema.String),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type ConnectionStatus = Schema.Schema.Type<typeof ConnectionStatus>
export const ConnectionStatus = z.object({
workspaceID: WorkspaceID.zod,
status: z.enum(["connected", "connecting", "disconnected", "error"]),
error: z.string().optional(),
})
export type ConnectionStatus = z.infer<typeof ConnectionStatus>
const Restore = z.object({
workspaceID: WorkspaceID.zod,
sessionID: SessionID.zod,
total: z.number().int().min(0),
step: z.number().int().min(0),
})
export const Event = {
Ready: BusEvent.define(
"workspace.ready",
z.object({
name: z.string(),
}),
),
Failed: BusEvent.define(
"workspace.failed",
z.object({
message: z.string(),
}),
),
Restore: BusEvent.define("workspace.restore", Restore),
Status: BusEvent.define("workspace.status", ConnectionStatus.zod),
}
function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
return {
id: row.id,
type: row.type,
branch: row.branch,
name: row.name,
directory: row.directory,
extra: row.extra,
projectID: row.project_id,
}
}
const CreateInput = z.object({
id: WorkspaceID.zod.optional(),
type: zod(Schema.String),
branch: zod(Schema.NullOr(Schema.String)),
projectID: ProjectID.zod,
extra: zod(Schema.NullOr(Schema.Unknown)),
})
export const create = fn(CreateInput, async (input) => {
const id = WorkspaceID.ascending(input.id)
const adaptor = await getAdaptor(input.projectID, input.type)
const config = await adaptor.configure({ ...input, id, name: Slug.create(), directory: null })
const info: Info = {
id,
type: config.type,
branch: config.branch ?? null,
name: config.name ?? null,
directory: config.directory ?? null,
extra: config.extra ?? null,
projectID: input.projectID,
}
Database.use((db) => {
db.insert(WorkspaceTable)
.values({
id: info.id,
type: info.type,
branch: info.branch,
name: info.name,
directory: info.directory,
extra: info.extra,
project_id: info.projectID,
})
.run()
const Restore = z.object({
workspaceID: WorkspaceID.zod,
sessionID: SessionID.zod,
total: z.number().int().min(0),
step: z.number().int().min(0),
})
const env = {
OPENCODE_AUTH_CONTENT: JSON.stringify(await AppRuntime.runPromise(Auth.Service.use((auth) => auth.all()))),
OPENCODE_WORKSPACE_ID: config.id,
OPENCODE_EXPERIMENTAL_WORKSPACES: "true",
export const Event = {
Ready: BusEvent.define(
"workspace.ready",
z.object({
name: z.string(),
}),
),
Failed: BusEvent.define(
"workspace.failed",
z.object({
message: z.string(),
}),
),
Restore: BusEvent.define("workspace.restore", Restore),
Status: BusEvent.define("workspace.status", ConnectionStatus),
}
await adaptor.create(config, env)
startSync(info)
function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
return {
id: row.id,
type: row.type,
branch: row.branch,
name: row.name,
directory: row.directory,
extra: row.extra,
projectID: row.project_id,
}
}
await waitEvent({
timeout: TIMEOUT,
fn(event) {
if (event.workspace === info.id && event.payload.type === Event.Status.type) {
const { status } = event.payload.properties
return status === "error" || status === "connected"
}
return false
},
const CreateInput = z.object({
id: WorkspaceID.zod.optional(),
type: Info.shape.type,
branch: Info.shape.branch,
projectID: ProjectID.zod,
extra: Info.shape.extra,
})
return info
})
export const create = fn(CreateInput, async (input) => {
const id = WorkspaceID.ascending(input.id)
const adaptor = await getAdaptor(input.projectID, input.type)
const SessionRestoreInput = z.object({
workspaceID: WorkspaceID.zod,
sessionID: SessionID.zod,
})
const config = await adaptor.configure({ ...input, id, name: Slug.create(), directory: null })
export const sessionRestore = fn(SessionRestoreInput, async (input) => {
log.info("session restore requested", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
})
try {
const space = await get(input.workspaceID)
if (!space) throw new Error(`Workspace not found: ${input.workspaceID}`)
const info: Info = {
id,
type: config.type,
branch: config.branch ?? null,
name: config.name ?? null,
directory: config.directory ?? null,
extra: config.extra ?? null,
projectID: input.projectID,
}
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
Database.use((db) => {
db.insert(WorkspaceTable)
.values({
id: info.id,
type: info.type,
branch: info.branch,
name: info.name,
directory: info.directory,
extra: info.extra,
project_id: info.projectID,
})
.run()
})
// Need to switch the workspace of the session
SyncEvent.run(Session.Event.Updated, {
sessionID: input.sessionID,
info: {
workspaceID: input.workspaceID,
const env = {
OPENCODE_AUTH_CONTENT: JSON.stringify(await AppRuntime.runPromise(Auth.Service.use((auth) => auth.all()))),
OPENCODE_WORKSPACE_ID: config.id,
OPENCODE_EXPERIMENTAL_WORKSPACES: "true",
}
await adaptor.create(config, env)
startSync(info)
await waitEvent({
timeout: TIMEOUT,
fn(event) {
if (event.workspace === info.id && event.payload.type === Event.Status.type) {
const { status } = event.payload.properties
return status === "error" || status === "connected"
}
return false
},
})
const rows = Database.use((db) =>
db
.select({
id: EventTable.id,
aggregateID: EventTable.aggregate_id,
seq: EventTable.seq,
type: EventTable.type,
data: EventTable.data,
})
.from(EventTable)
.where(eq(EventTable.aggregate_id, input.sessionID))
.orderBy(asc(EventTable.seq))
.all(),
)
if (rows.length === 0) throw new Error(`No events found for session: ${input.sessionID}`)
return info
})
const all = rows
const SessionRestoreInput = z.object({
workspaceID: WorkspaceID.zod,
sessionID: SessionID.zod,
})
const size = 10
const sets = Array.from({ length: Math.ceil(all.length / size) }, (_, i) => all.slice(i * size, (i + 1) * size))
const total = sets.length
log.info("session restore prepared", {
export const sessionRestore = fn(SessionRestoreInput, async (input) => {
log.info("session restore requested", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
workspaceType: space.type,
directory: space.directory,
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
events: all.length,
batches: total,
first: all[0]?.seq,
last: all.at(-1)?.seq,
})
GlobalBus.emit("event", {
directory: "global",
workspace: input.workspaceID,
payload: {
type: Event.Restore.type,
properties: {
try {
const space = await get(input.workspaceID)
if (!space) throw new Error(`Workspace not found: ${input.workspaceID}`)
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
// Need to switch the workspace of the session
SyncEvent.run(Session.Event.Updated, {
sessionID: input.sessionID,
info: {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total,
step: 0,
},
},
})
for (const [i, events] of sets.entries()) {
log.info("session restore batch starting", {
})
const rows = Database.use((db) =>
db
.select({
id: EventTable.id,
aggregateID: EventTable.aggregate_id,
seq: EventTable.seq,
type: EventTable.type,
data: EventTable.data,
})
.from(EventTable)
.where(eq(EventTable.aggregate_id, input.sessionID))
.orderBy(asc(EventTable.seq))
.all(),
)
if (rows.length === 0) throw new Error(`No events found for session: ${input.sessionID}`)
const all = rows
const size = 10
const sets = Array.from({ length: Math.ceil(all.length / size) }, (_, i) => all.slice(i * size, (i + 1) * size))
const total = sets.length
log.info("session restore prepared", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
workspaceType: space.type,
directory: space.directory,
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
events: all.length,
batches: total,
first: all[0]?.seq,
last: all.at(-1)?.seq,
})
if (target.type === "local") {
SyncEvent.replayAll(events)
log.info("session restore batch replayed locally", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
events: events.length,
})
} else {
const url = route(target.url, "/sync/replay")
const headers = new Headers(target.headers)
headers.set("content-type", "application/json")
const res = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({
directory: space.directory ?? "",
events,
}),
})
if (!res.ok) {
const body = await res.text()
log.error("session restore batch failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: res.status,
body,
})
throw new Error(
`Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`,
)
}
log.info("session restore batch posted", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: res.status,
})
}
GlobalBus.emit("event", {
directory: "global",
workspace: input.workspaceID,
@@ -269,263 +202,329 @@ export const sessionRestore = fn(SessionRestoreInput, async (input) => {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total,
step: i + 1,
step: 0,
},
},
})
}
log.info("session restore complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
batches: total,
})
return {
total,
}
} catch (err) {
log.error("session restore failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
})
throw err
}
})
export function list(project: Project.Info) {
const rows = Database.use((db) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
)
const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
for (const space of spaces) startSync(space)
return spaces
}
function lookup(id: WorkspaceID) {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return
return fromRow(row)
}
export const get = fn(WorkspaceID.zod, async (id) => {
const space = lookup(id)
if (!space) return
startSync(space)
return space
})
export const remove = fn(WorkspaceID.zod, async (id) => {
const sessions = Database.use((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, id)).all(),
)
for (const session of sessions) {
await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(session.id)))
}
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (row) {
stopSync(id)
const info = fromRow(row)
try {
const adaptor = await getAdaptor(info.projectID, row.type)
await adaptor.remove(info)
} catch {
log.error("adaptor not available when removing workspace", { type: row.type })
}
Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
return info
}
})
const connections = new Map<WorkspaceID, ConnectionStatus>()
const aborts = new Map<WorkspaceID, AbortController>()
const TIMEOUT = 5000
function setStatus(id: WorkspaceID, status: ConnectionStatus["status"], error?: string) {
const prev = connections.get(id)
if (prev?.status === status && prev?.error === error) return
const next = { workspaceID: id, status, error }
connections.set(id, next)
if (status === "error") {
aborts.delete(id)
}
GlobalBus.emit("event", {
directory: "global",
workspace: id,
payload: {
type: Event.Status.type,
properties: next,
},
})
}
export function status(): ConnectionStatus[] {
return [...connections.values()]
}
function synced(state: Record<string, number>) {
const ids = Object.keys(state)
if (ids.length === 0) return true
const done = Object.fromEntries(
Database.use((db) =>
db
.select({
id: EventSequenceTable.aggregate_id,
seq: EventSequenceTable.seq,
for (const [i, events] of sets.entries()) {
log.info("session restore batch starting", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
})
.from(EventSequenceTable)
.where(inArray(EventSequenceTable.aggregate_id, ids))
.all(),
).map((row) => [row.id, row.seq]),
) as Record<string, number>
return ids.every((id) => {
return (done[id] ?? -1) >= state[id]
})
}
export async function isSyncing(workspaceID: WorkspaceID) {
return aborts.has(workspaceID)
}
export async function waitForSync(workspaceID: WorkspaceID, state: Record<string, number>, signal?: AbortSignal) {
if (synced(state)) return
try {
await waitEvent({
timeout: TIMEOUT,
signal,
fn(event) {
if (event.workspace !== workspaceID && event.payload.type !== "sync") {
return false
if (target.type === "local") {
SyncEvent.replayAll(events)
log.info("session restore batch replayed locally", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
events: events.length,
})
} else {
const url = route(target.url, "/sync/replay")
const headers = new Headers(target.headers)
headers.set("content-type", "application/json")
const res = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({
directory: space.directory ?? "",
events,
}),
})
if (!res.ok) {
const body = await res.text()
log.error("session restore batch failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: res.status,
body,
})
throw new Error(
`Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`,
)
}
log.info("session restore batch posted", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: res.status,
})
}
return synced(state)
GlobalBus.emit("event", {
directory: "global",
workspace: input.workspaceID,
payload: {
type: Event.Restore.type,
properties: {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total,
step: i + 1,
},
},
})
}
log.info("session restore complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
batches: total,
})
return {
total,
}
} catch (err) {
log.error("session restore failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
})
throw err
}
})
export function list(project: Project.Info) {
const rows = Database.use((db) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
)
const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
for (const space of spaces) startSync(space)
return spaces
}
function lookup(id: WorkspaceID) {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return
return fromRow(row)
}
export const get = fn(WorkspaceID.zod, async (id) => {
const space = lookup(id)
if (!space) return
startSync(space)
return space
})
export const remove = fn(WorkspaceID.zod, async (id) => {
const sessions = Database.use((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, id)).all(),
)
for (const session of sessions) {
await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(session.id)))
}
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (row) {
stopSync(id)
const info = fromRow(row)
try {
const adaptor = await getAdaptor(info.projectID, row.type)
await adaptor.remove(info)
} catch {
log.error("adaptor not available when removing workspace", { type: row.type })
}
Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
return info
}
})
const connections = new Map<WorkspaceID, ConnectionStatus>()
const aborts = new Map<WorkspaceID, AbortController>()
const TIMEOUT = 5000
function setStatus(id: WorkspaceID, status: ConnectionStatus["status"], error?: string) {
const prev = connections.get(id)
if (prev?.status === status && prev?.error === error) return
const next = { workspaceID: id, status, error }
connections.set(id, next)
if (status === "error") {
aborts.delete(id)
}
GlobalBus.emit("event", {
directory: "global",
workspace: id,
payload: {
type: Event.Status.type,
properties: next,
},
})
} catch {
if (signal?.aborted) throw signal.reason ?? new Error("Request aborted")
throw new Error(`Timed out waiting for sync fence: ${JSON.stringify(state)}`)
}
}
const log = Log.create({ service: "workspace-sync" })
export function status(): ConnectionStatus[] {
return [...connections.values()]
}
function route(url: string | URL, path: string) {
const next = new URL(url)
next.pathname = `${next.pathname.replace(/\/$/, "")}${path}`
next.search = ""
next.hash = ""
return next
}
function synced(state: Record<string, number>) {
const ids = Object.keys(state)
if (ids.length === 0) return true
async function syncWorkspace(space: Info, signal: AbortSignal) {
while (!signal.aborted) {
log.info("connecting to global sync", { workspace: space.name })
setStatus(space.id, "connecting")
const done = Object.fromEntries(
Database.use((db) =>
db
.select({
id: EventSequenceTable.aggregate_id,
seq: EventSequenceTable.seq,
})
.from(EventSequenceTable)
.where(inArray(EventSequenceTable.aggregate_id, ids))
.all(),
).map((row) => [row.id, row.seq]),
) as Record<string, number>
return ids.every((id) => {
return (done[id] ?? -1) >= state[id]
})
}
export async function isSyncing(workspaceID: WorkspaceID) {
return aborts.has(workspaceID)
}
export async function waitForSync(workspaceID: WorkspaceID, state: Record<string, number>, signal?: AbortSignal) {
if (synced(state)) return
try {
await waitEvent({
timeout: TIMEOUT,
signal,
fn(event) {
if (event.workspace !== workspaceID && event.payload.type !== "sync") {
return false
}
return synced(state)
},
})
} catch {
if (signal?.aborted) throw signal.reason ?? new Error("Request aborted")
throw new Error(`Timed out waiting for sync fence: ${JSON.stringify(state)}`)
}
}
const log = Log.create({ service: "workspace-sync" })
function route(url: string | URL, path: string) {
const next = new URL(url)
next.pathname = `${next.pathname.replace(/\/$/, "")}${path}`
next.search = ""
next.hash = ""
return next
}
async function syncWorkspace(space: Info, signal: AbortSignal) {
while (!signal.aborted) {
log.info("connecting to global sync", { workspace: space.name })
setStatus(space.id, "connecting")
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
if (target.type === "local") return
const res = await fetch(route(target.url, "/global/event"), {
method: "GET",
headers: target.headers,
signal,
}).catch((err: unknown) => {
setStatus(space.id, "error", err instanceof Error ? err.message : String(err))
log.info("failed to connect to global sync", {
workspace: space.name,
error: err,
})
return undefined
})
if (!res || !res.ok || !res.body) {
const error = !res ? "No response from global sync" : `Global sync HTTP ${res.status}`
log.info("failed to connect to global sync", { workspace: space.name, error })
setStatus(space.id, "error", error)
await sleep(1000)
continue
}
log.info("global sync connected", { workspace: space.name })
setStatus(space.id, "connected")
await parseSSE(res.body, signal, (evt: any) => {
try {
if (!("payload" in evt)) return
if (evt.payload.type === "sync") {
SyncEvent.replay(evt.payload.syncEvent as SyncEvent.SerializedEvent)
}
GlobalBus.emit("event", {
directory: evt.directory,
project: evt.project,
workspace: space.id,
payload: evt.payload,
})
} catch (err) {
log.info("failed to replay global event", {
workspaceID: space.id,
error: err,
})
}
})
log.info("disconnected from global sync: " + space.id)
setStatus(space.id, "disconnected")
// TODO: Implement exponential backoff
await sleep(1000)
}
}
async function startSync(space: Info) {
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
if (target.type === "local") return
const res = await fetch(route(target.url, "/global/event"), {
method: "GET",
headers: target.headers,
signal,
}).catch((err: unknown) => {
setStatus(space.id, "error", err instanceof Error ? err.message : String(err))
log.info("failed to connect to global sync", {
workspace: space.name,
error: err,
if (target.type === "local") {
void Filesystem.exists(target.directory).then((exists) => {
setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist")
})
return undefined
})
if (!res || !res.ok || !res.body) {
const error = !res ? "No response from global sync" : `Global sync HTTP ${res.status}`
log.info("failed to connect to global sync", { workspace: space.name, error })
setStatus(space.id, "error", error)
await sleep(1000)
continue
return
}
log.info("global sync connected", { workspace: space.name })
setStatus(space.id, "connected")
if (aborts.has(space.id)) return true
await parseSSE(res.body, signal, (evt: any) => {
try {
if (!("payload" in evt)) return
if (evt.payload.type === "sync") {
SyncEvent.replay(evt.payload.syncEvent as SyncEvent.SerializedEvent)
}
GlobalBus.emit("event", {
directory: evt.directory,
project: evt.project,
workspace: space.id,
payload: evt.payload,
})
} catch (err) {
log.info("failed to replay global event", {
workspaceID: space.id,
error: err,
})
}
})
log.info("disconnected from global sync: " + space.id)
setStatus(space.id, "disconnected")
// TODO: Implement exponential backoff
await sleep(1000)
}
}
const abort = new AbortController()
aborts.set(space.id, abort)
async function startSync(space: Info) {
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
void syncWorkspace(space, abort.signal).catch((error) => {
aborts.delete(space.id)
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
if (target.type === "local") {
void Filesystem.exists(target.directory).then((exists) => {
setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist")
setStatus(space.id, "error", String(error))
log.warn("workspace listener failed", {
workspaceID: space.id,
error,
})
})
return
}
if (aborts.has(space.id)) return true
setStatus(space.id, "disconnected")
const abort = new AbortController()
aborts.set(space.id, abort)
void syncWorkspace(space, abort.signal).catch((error) => {
aborts.delete(space.id)
setStatus(space.id, "error", String(error))
log.warn("workspace listener failed", {
workspaceID: space.id,
error,
})
})
function stopSync(id: WorkspaceID) {
aborts.get(id)?.abort()
aborts.delete(id)
connections.delete(id)
}
}
function stopSync(id: WorkspaceID) {
aborts.get(id)?.abort()
aborts.delete(id)
connections.delete(id)
}
export * as Workspace from "./workspace"
+1 -1
View File
@@ -5,7 +5,7 @@ import * as Observability from "./observability"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Bus } from "@/bus"
import { Auth } from "@/auth"
import { Account } from "@/account/account"
import { Account } from "@/account"
import { Config } from "@/config"
import { Git } from "@/git"
import { Ripgrep } from "@/file/ripgrep"
+70 -70
View File
@@ -1,81 +1,81 @@
import { Glob } from "@opencode-ai/shared/util/glob"
const FOLDERS = new Set([
"node_modules",
"bower_components",
".pnpm-store",
"vendor",
".npm",
"dist",
"build",
"out",
".next",
"target",
"bin",
"obj",
".git",
".svn",
".hg",
".vscode",
".idea",
".turbo",
".output",
"desktop",
".sst",
".cache",
".webkit-cache",
"__pycache__",
".pytest_cache",
"mypy_cache",
".history",
".gradle",
])
export namespace FileIgnore {
const FOLDERS = new Set([
"node_modules",
"bower_components",
".pnpm-store",
"vendor",
".npm",
"dist",
"build",
"out",
".next",
"target",
"bin",
"obj",
".git",
".svn",
".hg",
".vscode",
".idea",
".turbo",
".output",
"desktop",
".sst",
".cache",
".webkit-cache",
"__pycache__",
".pytest_cache",
"mypy_cache",
".history",
".gradle",
])
const FILES = [
"**/*.swp",
"**/*.swo",
const FILES = [
"**/*.swp",
"**/*.swo",
"**/*.pyc",
"**/*.pyc",
// OS
"**/.DS_Store",
"**/Thumbs.db",
// OS
"**/.DS_Store",
"**/Thumbs.db",
// Logs & temp
"**/logs/**",
"**/tmp/**",
"**/temp/**",
"**/*.log",
// Logs & temp
"**/logs/**",
"**/tmp/**",
"**/temp/**",
"**/*.log",
// Coverage/test outputs
"**/coverage/**",
"**/.nyc_output/**",
]
// Coverage/test outputs
"**/coverage/**",
"**/.nyc_output/**",
]
export const PATTERNS = [...FILES, ...FOLDERS]
export const PATTERNS = [...FILES, ...FOLDERS]
export function match(
filepath: string,
opts?: {
extra?: string[]
whitelist?: string[]
},
) {
for (const pattern of opts?.whitelist || []) {
if (Glob.match(pattern, filepath)) return false
export function match(
filepath: string,
opts?: {
extra?: string[]
whitelist?: string[]
},
) {
for (const pattern of opts?.whitelist || []) {
if (Glob.match(pattern, filepath)) return false
}
const parts = filepath.split(/[/\\]/)
for (let i = 0; i < parts.length; i++) {
if (FOLDERS.has(parts[i])) return true
}
const extra = opts?.extra || []
for (const pattern of [...FILES, ...extra]) {
if (Glob.match(pattern, filepath)) return true
}
return false
}
const parts = filepath.split(/[/\\]/)
for (let i = 0; i < parts.length; i++) {
if (FOLDERS.has(parts[i])) return true
}
const extra = opts?.extra || []
for (const pattern of [...FILES, ...extra]) {
if (Glob.match(pattern, filepath)) return true
}
return false
}
export * as FileIgnore from "./ignore"
+18 -18
View File
@@ -37,23 +37,23 @@ const DARWIN_ROOT = ["/.DocumentRevisions-V100", "/.Spotlight-V100", "/.Trashes"
const WIN32_HOME = ["AppData", "Downloads", "Desktop", "Documents", "Pictures", "Music", "Videos", "OneDrive"]
/** Directory basenames to skip when scanning the home directory. */
export function names(): ReadonlySet<string> {
if (process.platform === "darwin") return new Set(DARWIN_HOME)
if (process.platform === "win32") return new Set(WIN32_HOME)
return new Set()
}
export namespace Protected {
/** Directory basenames to skip when scanning the home directory. */
export function names(): ReadonlySet<string> {
if (process.platform === "darwin") return new Set(DARWIN_HOME)
if (process.platform === "win32") return new Set(WIN32_HOME)
return new Set()
}
/** Absolute paths that should never be watched, stated, or scanned. */
export function paths(): string[] {
if (process.platform === "darwin")
return [
...DARWIN_HOME.map((n) => path.join(home, n)),
...DARWIN_LIBRARY.map((n) => path.join(home, "Library", n)),
...DARWIN_ROOT,
]
if (process.platform === "win32") return WIN32_HOME.map((n) => path.join(home, n))
return []
/** Absolute paths that should never be watched, stated, or scanned. */
export function paths(): string[] {
if (process.platform === "darwin")
return [
...DARWIN_HOME.map((n) => path.join(home, n)),
...DARWIN_LIBRARY.map((n) => path.join(home, "Library", n)),
...DARWIN_ROOT,
]
if (process.platform === "win32") return WIN32_HOME.map((n) => path.join(home, n))
return []
}
}
export * as Protected from "./protected"
File diff suppressed because it is too large Load Diff
+93 -93
View File
@@ -5,109 +5,109 @@ import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema"
import { Log } from "../util"
const log = Log.create({ service: "file.time" })
export namespace FileTime {
const log = Log.create({ service: "file.time" })
export type Stamp = {
readonly read: Date
readonly mtime: number | undefined
readonly size: number | undefined
}
export type Stamp = {
readonly read: Date
readonly mtime: number | undefined
readonly size: number | undefined
}
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
const value = reads.get(sessionID)
if (value) return value
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
const value = reads.get(sessionID)
if (value) return value
const next = new Map<string, Stamp>()
reads.set(sessionID, next)
return next
}
const next = new Map<string, Stamp>()
reads.set(sessionID, next)
return next
}
interface State {
reads: Map<SessionID, Map<string, Stamp>>
locks: Map<string, Semaphore.Semaphore>
}
interface State {
reads: Map<SessionID, Map<string, Stamp>>
locks: Map<string, Semaphore.Semaphore>
}
export interface Interface {
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
readonly withLock: <T>(filepath: string, fn: () => Effect.Effect<T>) => Effect.Effect<T>
}
export interface Interface {
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
readonly withLock: <T>(filepath: string, fn: () => Effect.Effect<T>) => Effect.Effect<T>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileTime") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileTime") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
const stamp = Effect.fnUntraced(function* (file: string) {
const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.void))
return {
read: yield* DateTime.nowAsDate,
mtime: info ? Option.getOrUndefined(info.mtime)?.getTime() : undefined,
size: info ? Number(info.size) : undefined,
}
})
const state = yield* InstanceState.make<State>(
Effect.fn("FileTime.state")(() =>
Effect.succeed({
reads: new Map<SessionID, Map<string, Stamp>>(),
locks: new Map<string, Semaphore.Semaphore>(),
}),
),
)
const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) {
filepath = AppFileSystem.normalizePath(filepath)
const locks = (yield* InstanceState.get(state)).locks
const lock = locks.get(filepath)
if (lock) return lock
const next = Semaphore.makeUnsafe(1)
locks.set(filepath, next)
return next
})
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
file = AppFileSystem.normalizePath(file)
const reads = (yield* InstanceState.get(state)).reads
log.info("read", { sessionID, file })
session(reads, sessionID).set(file, yield* stamp(file))
})
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
file = AppFileSystem.normalizePath(file)
const reads = (yield* InstanceState.get(state)).reads
return reads.get(sessionID)?.get(file)?.read
})
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
if (disableCheck) return
filepath = AppFileSystem.normalizePath(filepath)
const reads = (yield* InstanceState.get(state)).reads
const time = reads.get(sessionID)?.get(filepath)
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
const next = yield* stamp(filepath)
const changed = next.mtime !== time.mtime || next.size !== time.size
if (!changed) return
throw new Error(
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
const stamp = Effect.fnUntraced(function* (file: string) {
const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.void))
return {
read: yield* DateTime.nowAsDate,
mtime: info ? Option.getOrUndefined(info.mtime)?.getTime() : undefined,
size: info ? Number(info.size) : undefined,
}
})
const state = yield* InstanceState.make<State>(
Effect.fn("FileTime.state")(() =>
Effect.succeed({
reads: new Map<SessionID, Map<string, Stamp>>(),
locks: new Map<string, Semaphore.Semaphore>(),
}),
),
)
})
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Effect.Effect<T>) {
return yield* fn().pipe((yield* getLock(filepath)).withPermits(1))
})
const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) {
filepath = AppFileSystem.normalizePath(filepath)
const locks = (yield* InstanceState.get(state)).locks
const lock = locks.get(filepath)
if (lock) return lock
return Service.of({ read, get, assert, withLock })
}),
).pipe(Layer.orDie)
const next = Semaphore.makeUnsafe(1)
locks.set(filepath, next)
return next
})
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
file = AppFileSystem.normalizePath(file)
const reads = (yield* InstanceState.get(state)).reads
log.info("read", { sessionID, file })
session(reads, sessionID).set(file, yield* stamp(file))
})
export * as FileTime from "./time"
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
file = AppFileSystem.normalizePath(file)
const reads = (yield* InstanceState.get(state)).reads
return reads.get(sessionID)?.get(file)?.read
})
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
if (disableCheck) return
filepath = AppFileSystem.normalizePath(filepath)
const reads = (yield* InstanceState.get(state)).reads
const time = reads.get(sessionID)?.get(filepath)
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
const next = yield* stamp(filepath)
const changed = next.mtime !== time.mtime || next.size !== time.size
if (!changed) return
throw new Error(
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
)
})
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Effect.Effect<T>) {
return yield* fn().pipe((yield* getLock(filepath)).withPermits(1))
})
return Service.of({ read, get, assert, withLock })
}),
).pipe(Layer.orDie)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
}
+137 -137
View File
@@ -19,145 +19,145 @@ import { Log } from "../util"
declare const OPENCODE_LIBC: string | undefined
const log = Log.create({ service: "file.watcher" })
const SUBSCRIBE_TIMEOUT_MS = 10_000
export namespace FileWatcher {
const log = Log.create({ service: "file.watcher" })
const SUBSCRIBE_TIMEOUT_MS = 10_000
export const Event = {
Updated: BusEvent.define(
"file.watcher.updated",
z.object({
file: z.string(),
event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
}),
),
}
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
try {
const binding = require(
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
)
return createWrapper(binding) as typeof import("@parcel/watcher")
} catch (error) {
log.error("failed to load watcher binding", { error })
return
}
})
function getBackend() {
if (process.platform === "win32") return "windows"
if (process.platform === "darwin") return "fs-events"
if (process.platform === "linux") return "inotify"
}
function protecteds(dir: string) {
return Protected.paths().filter((item) => {
const rel = path.relative(dir, item)
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)
})
}
export const hasNativeBinding = () => !!watcher()
export interface Interface {
readonly init: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileWatcher") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const git = yield* Git.Service
const state = yield* InstanceState.make(
Effect.fn("FileWatcher.state")(
function* () {
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
log.info("init", { directory: Instance.directory })
const backend = getBackend()
if (!backend) {
log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform })
return
}
const w = watcher()
if (!w) return
log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend })
const subs: ParcelWatcher.AsyncSubscription[] = []
yield* Effect.addFinalizer(() =>
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
)
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
if (err) return
for (const evt of evts) {
if (evt.type === "create") void Bus.publish(Event.Updated, { file: evt.path, event: "add" })
if (evt.type === "update") void Bus.publish(Event.Updated, { file: evt.path, event: "change" })
if (evt.type === "delete") void Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
}
})
const subscribe = (dir: string, ignore: string[]) => {
const pending = w.subscribe(dir, cb, { ignore, backend })
return Effect.gen(function* () {
const sub = yield* Effect.promise(() => pending)
subs.push(sub)
}).pipe(
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => {
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
pending.then((s) => s.unsubscribe()).catch(() => {})
return Effect.void
}),
)
}
const cfg = yield* config.get()
const cfgIgnores = cfg.watcher?.ignore ?? []
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
yield* subscribe(Instance.directory, [
...FileIgnore.PATTERNS,
...cfgIgnores,
...protecteds(Instance.directory),
])
}
if (Instance.project.vcs === "git") {
const result = yield* git.run(["rev-parse", "--git-dir"], {
cwd: Instance.project.worktree,
})
const vcsDir =
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
(entry) => entry !== "HEAD",
)
yield* subscribe(vcsDir, ignore)
}
}
},
Effect.catchCause((cause) => {
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
return Effect.void
}),
),
)
return Service.of({
init: Effect.fn("FileWatcher.init")(function* () {
yield* InstanceState.get(state)
export const Event = {
Updated: BusEvent.define(
"file.watcher.updated",
z.object({
file: z.string(),
event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
}),
),
}
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
try {
const binding = require(
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
)
return createWrapper(binding) as typeof import("@parcel/watcher")
} catch (error) {
log.error("failed to load watcher binding", { error })
return
}
})
function getBackend() {
if (process.platform === "win32") return "windows"
if (process.platform === "darwin") return "fs-events"
if (process.platform === "linux") return "inotify"
}
function protecteds(dir: string) {
return Protected.paths().filter((item) => {
const rel = path.relative(dir, item)
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)
})
}),
)
}
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
export const hasNativeBinding = () => !!watcher()
export * as FileWatcher from "./watcher"
export interface Interface {
readonly init: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileWatcher") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const git = yield* Git.Service
const state = yield* InstanceState.make(
Effect.fn("FileWatcher.state")(
function* () {
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
log.info("init", { directory: Instance.directory })
const backend = getBackend()
if (!backend) {
log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform })
return
}
const w = watcher()
if (!w) return
log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend })
const subs: ParcelWatcher.AsyncSubscription[] = []
yield* Effect.addFinalizer(() =>
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
)
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
if (err) return
for (const evt of evts) {
if (evt.type === "create") void Bus.publish(Event.Updated, { file: evt.path, event: "add" })
if (evt.type === "update") void Bus.publish(Event.Updated, { file: evt.path, event: "change" })
if (evt.type === "delete") void Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
}
})
const subscribe = (dir: string, ignore: string[]) => {
const pending = w.subscribe(dir, cb, { ignore, backend })
return Effect.gen(function* () {
const sub = yield* Effect.promise(() => pending)
subs.push(sub)
}).pipe(
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => {
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
pending.then((s) => s.unsubscribe()).catch(() => {})
return Effect.void
}),
)
}
const cfg = yield* config.get()
const cfgIgnores = cfg.watcher?.ignore ?? []
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
yield* subscribe(Instance.directory, [
...FileIgnore.PATTERNS,
...cfgIgnores,
...protecteds(Instance.directory),
])
}
if (Instance.project.vcs === "git") {
const result = yield* git.run(["rev-parse", "--git-dir"], {
cwd: Instance.project.worktree,
})
const vcsDir =
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
(entry) => entry !== "HEAD",
)
yield* subscribe(vcsDir, ignore)
}
}
},
Effect.catchCause((cause) => {
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
return Effect.void
}),
),
)
return Service.of({
init: Effect.fn("FileWatcher.init")(function* () {
yield* InstanceState.get(state)
}),
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
}
+131 -77
View File
@@ -10,99 +10,153 @@ function falsy(key: string) {
return value === "false" || value === "0"
}
function number(key: string) {
const value = process.env[key]
if (!value) return undefined
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
}
export namespace Flag {
export const OTEL_EXPORTER_OTLP_ENDPOINT = process.env["OTEL_EXPORTER_OTLP_ENDPOINT"]
export const OTEL_EXPORTER_OTLP_HEADERS = process.env["OTEL_EXPORTER_OTLP_HEADERS"]
const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
const OPENCODE_DISABLE_CLAUDE_CODE = truthy("OPENCODE_DISABLE_CLAUDE_CODE")
const OPENCODE_DISABLE_CLAUDE_CODE_SKILLS =
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
export const Flag = {
OTEL_EXPORTER_OTLP_ENDPOINT: process.env["OTEL_EXPORTER_OTLP_ENDPOINT"],
OTEL_EXPORTER_OTLP_HEADERS: process.env["OTEL_EXPORTER_OTLP_HEADERS"],
OPENCODE_AUTO_SHARE: truthy("OPENCODE_AUTO_SHARE"),
OPENCODE_AUTO_HEAP_SNAPSHOT: truthy("OPENCODE_AUTO_HEAP_SNAPSHOT"),
OPENCODE_GIT_BASH_PATH: process.env["OPENCODE_GIT_BASH_PATH"],
OPENCODE_CONFIG: process.env["OPENCODE_CONFIG"],
OPENCODE_CONFIG_CONTENT: process.env["OPENCODE_CONFIG_CONTENT"],
OPENCODE_DISABLE_AUTOUPDATE: truthy("OPENCODE_DISABLE_AUTOUPDATE"),
OPENCODE_ALWAYS_NOTIFY_UPDATE: truthy("OPENCODE_ALWAYS_NOTIFY_UPDATE"),
OPENCODE_DISABLE_PRUNE: truthy("OPENCODE_DISABLE_PRUNE"),
OPENCODE_DISABLE_TERMINAL_TITLE: truthy("OPENCODE_DISABLE_TERMINAL_TITLE"),
OPENCODE_SHOW_TTFD: truthy("OPENCODE_SHOW_TTFD"),
OPENCODE_PERMISSION: process.env["OPENCODE_PERMISSION"],
OPENCODE_DISABLE_DEFAULT_PLUGINS: truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS"),
OPENCODE_DISABLE_LSP_DOWNLOAD: truthy("OPENCODE_DISABLE_LSP_DOWNLOAD"),
OPENCODE_ENABLE_EXPERIMENTAL_MODELS: truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS"),
OPENCODE_DISABLE_AUTOCOMPACT: truthy("OPENCODE_DISABLE_AUTOCOMPACT"),
OPENCODE_DISABLE_MODELS_FETCH: truthy("OPENCODE_DISABLE_MODELS_FETCH"),
OPENCODE_DISABLE_MOUSE: truthy("OPENCODE_DISABLE_MOUSE"),
OPENCODE_DISABLE_CLAUDE_CODE,
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS,
OPENCODE_DISABLE_EXTERNAL_SKILLS: OPENCODE_DISABLE_CLAUDE_CODE_SKILLS || truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
OPENCODE_ENABLE_QUESTION_TOOL: truthy("OPENCODE_ENABLE_QUESTION_TOOL"),
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
export const OPENCODE_AUTO_HEAP_SNAPSHOT = truthy("OPENCODE_AUTO_HEAP_SNAPSHOT")
export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
export declare const OPENCODE_PURE: boolean
export declare const OPENCODE_TUI_CONFIG: string | undefined
export declare const OPENCODE_CONFIG_DIR: string | undefined
export declare const OPENCODE_PLUGIN_META_FILE: string | undefined
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
export const OPENCODE_ALWAYS_NOTIFY_UPDATE = truthy("OPENCODE_ALWAYS_NOTIFY_UPDATE")
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE")
export const OPENCODE_SHOW_TTFD = truthy("OPENCODE_SHOW_TTFD")
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
export const OPENCODE_DISABLE_DEFAULT_PLUGINS = truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS")
export const OPENCODE_DISABLE_LSP_DOWNLOAD = truthy("OPENCODE_DISABLE_LSP_DOWNLOAD")
export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS")
export const OPENCODE_DISABLE_AUTOCOMPACT = truthy("OPENCODE_DISABLE_AUTOCOMPACT")
export const OPENCODE_DISABLE_MODELS_FETCH = truthy("OPENCODE_DISABLE_MODELS_FETCH")
export const OPENCODE_DISABLE_MOUSE = truthy("OPENCODE_DISABLE_MOUSE")
export const OPENCODE_DISABLE_CLAUDE_CODE = truthy("OPENCODE_DISABLE_CLAUDE_CODE")
export const OPENCODE_DISABLE_CLAUDE_CODE_PROMPT =
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT")
export const OPENCODE_DISABLE_CLAUDE_CODE_SKILLS =
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
export const OPENCODE_DISABLE_EXTERNAL_SKILLS =
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS || truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS")
export declare const OPENCODE_DISABLE_PROJECT_CONFIG: boolean
export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
export declare const OPENCODE_CLIENT: string
export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"]
export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"]
export const OPENCODE_ENABLE_QUESTION_TOOL = truthy("OPENCODE_ENABLE_QUESTION_TOOL")
// Experimental
OPENCODE_EXPERIMENTAL,
OPENCODE_EXPERIMENTAL_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe(
export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
export const OPENCODE_EXPERIMENTAL_FILEWATCHER = Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe(
Config.withDefault(false),
),
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe(
)
export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = Config.boolean(
"OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER",
).pipe(Config.withDefault(false))
export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY =
OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY")
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT =
copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT")
export const OPENCODE_ENABLE_EXA =
truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA")
export const OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS = number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS")
export const OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX = number("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX")
export const OPENCODE_EXPERIMENTAL_OXFMT = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT")
export const OPENCODE_EXPERIMENTAL_LSP_TY = truthy("OPENCODE_EXPERIMENTAL_LSP_TY")
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
export const OPENCODE_DISABLE_FILETIME_CHECK = Config.boolean("OPENCODE_DISABLE_FILETIME_CHECK").pipe(
Config.withDefault(false),
),
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT:
copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"),
OPENCODE_ENABLE_EXA: truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA"),
OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: number("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
OPENCODE_EXPERIMENTAL_OXFMT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT"),
OPENCODE_EXPERIMENTAL_LSP_TY: truthy("OPENCODE_EXPERIMENTAL_LSP_TY"),
OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
OPENCODE_DISABLE_FILETIME_CHECK: Config.boolean("OPENCODE_DISABLE_FILETIME_CHECK").pipe(Config.withDefault(false)),
OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
OPENCODE_EXPERIMENTAL_MARKDOWN: !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN"),
OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],
OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"],
OPENCODE_DISABLE_EMBEDDED_WEB_UI: truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI"),
OPENCODE_DB: process.env["OPENCODE_DB"],
OPENCODE_DISABLE_CHANNEL_DB: truthy("OPENCODE_DISABLE_CHANNEL_DB"),
OPENCODE_SKIP_MIGRATIONS: truthy("OPENCODE_SKIP_MIGRATIONS"),
OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"),
)
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
export const OPENCODE_DISABLE_EMBEDDED_WEB_UI = truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI")
export const OPENCODE_DB = process.env["OPENCODE_DB"]
export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB")
export const OPENCODE_SKIP_MIGRATIONS = truthy("OPENCODE_SKIP_MIGRATIONS")
export const OPENCODE_STRICT_CONFIG_DEPS = truthy("OPENCODE_STRICT_CONFIG_DEPS")
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
OPENCODE_EXPERIMENTAL_HTTPAPI: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_HTTPAPI"),
OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
export const OPENCODE_WORKSPACE_ID = process.env["OPENCODE_WORKSPACE_ID"]
export const OPENCODE_EXPERIMENTAL_HTTPAPI = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_HTTPAPI")
export const OPENCODE_EXPERIMENTAL_WORKSPACES = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES")
// Evaluated at access time (not module load) because tests, the CLI, and
// external tooling set these env vars at runtime.
get OPENCODE_DISABLE_PROJECT_CONFIG() {
function number(key: string) {
const value = process.env[key]
if (!value) return undefined
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
}
}
// Dynamic getter for OPENCODE_DISABLE_PROJECT_CONFIG
// This must be evaluated at access time, not module load time,
// because external tooling may set this env var at runtime
Object.defineProperty(Flag, "OPENCODE_DISABLE_PROJECT_CONFIG", {
get() {
return truthy("OPENCODE_DISABLE_PROJECT_CONFIG")
},
get OPENCODE_TUI_CONFIG() {
enumerable: true,
configurable: false,
})
// Dynamic getter for OPENCODE_TUI_CONFIG
// This must be evaluated at access time, not module load time,
// because tests and external tooling may set this env var at runtime
Object.defineProperty(Flag, "OPENCODE_TUI_CONFIG", {
get() {
return process.env["OPENCODE_TUI_CONFIG"]
},
get OPENCODE_CONFIG_DIR() {
enumerable: true,
configurable: false,
})
// Dynamic getter for OPENCODE_CONFIG_DIR
// This must be evaluated at access time, not module load time,
// because external tooling may set this env var at runtime
Object.defineProperty(Flag, "OPENCODE_CONFIG_DIR", {
get() {
return process.env["OPENCODE_CONFIG_DIR"]
},
get OPENCODE_PURE() {
enumerable: true,
configurable: false,
})
// Dynamic getter for OPENCODE_PURE
// This must be evaluated at access time, not module load time,
// because the CLI can set this flag at runtime
Object.defineProperty(Flag, "OPENCODE_PURE", {
get() {
return truthy("OPENCODE_PURE")
},
get OPENCODE_PLUGIN_META_FILE() {
enumerable: true,
configurable: false,
})
// Dynamic getter for OPENCODE_PLUGIN_META_FILE
// This must be evaluated at access time, not module load time,
// because tests and external tooling may set this env var at runtime
Object.defineProperty(Flag, "OPENCODE_PLUGIN_META_FILE", {
get() {
return process.env["OPENCODE_PLUGIN_META_FILE"]
},
get OPENCODE_CLIENT() {
enumerable: true,
configurable: false,
})
// Dynamic getter for OPENCODE_CLIENT
// This must be evaluated at access time, not module load time,
// because some commands override the client at runtime
Object.defineProperty(Flag, "OPENCODE_CLIENT", {
get() {
return process.env["OPENCODE_CLIENT"] ?? "cli"
},
}
enumerable: true,
configurable: false,
})
+33 -42
View File
@@ -41,6 +41,39 @@ export const layer = Layer.effect(
const commands: Record<string, string[] | false> = {}
const formatters: Record<string, Formatter.Info> = {}
const cfg = yield* config.get()
if (cfg.formatter !== false) {
for (const item of Object.values(Formatter)) {
formatters[item.name] = item
}
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
// Ruff and uv are both the same formatter, so disabling either should disable both.
if (["ruff", "uv"].includes(name) && (cfg.formatter?.ruff?.disabled || cfg.formatter?.uv?.disabled)) {
// TODO combine formatters so shared backends like Ruff/uv don't need linked disable handling here.
delete formatters.ruff
delete formatters.uv
continue
}
if (item.disabled) {
delete formatters[name]
continue
}
const info = mergeDeep(formatters[name] ?? {}, {
extensions: [],
...item,
})
formatters[name] = {
...info,
name,
enabled: async () => info.command ?? false,
}
}
} else {
log.info("all formatters are disabled")
}
async function getCommand(item: Formatter.Info) {
let cmd = commands[item.name]
if (cmd === false || cmd === undefined) {
@@ -116,48 +149,6 @@ export const layer = Layer.effect(
})
}
const cfg = yield* config.get()
if (!cfg.formatter) {
log.info("all formatters are disabled")
log.info("init")
return {
formatters,
isEnabled,
formatFile,
}
}
for (const item of Object.values(Formatter)) {
formatters[item.name] = item
}
if (cfg.formatter !== true) {
for (const [name, item] of Object.entries(cfg.formatter)) {
const builtIn = Formatter[name as keyof typeof Formatter]
// Ruff and uv are both the same formatter, so disabling either should disable both.
if (["ruff", "uv"].includes(name) && (cfg.formatter.ruff?.disabled || cfg.formatter.uv?.disabled)) {
// TODO combine formatters so shared backends like Ruff/uv don't need linked disable handling here.
delete formatters.ruff
delete formatters.uv
continue
}
if (item.disabled) {
delete formatters[name]
continue
}
const info = mergeDeep(builtIn ?? { extensions: [] }, item)
formatters[name] = {
...info,
name,
extensions: info.extensions ?? [],
enabled: builtIn && !info.command ? builtIn.enabled : async () => info.command ?? false,
}
}
}
log.info("init")
return {
+74 -74
View File
@@ -1,86 +1,86 @@
import z from "zod"
import { randomBytes } from "crypto"
const prefixes = {
event: "evt",
session: "ses",
message: "msg",
permission: "per",
question: "que",
user: "usr",
part: "prt",
pty: "pty",
tool: "tool",
workspace: "wrk",
entry: "ent",
} as const
export namespace Identifier {
const prefixes = {
event: "evt",
session: "ses",
message: "msg",
permission: "per",
question: "que",
user: "usr",
part: "prt",
pty: "pty",
tool: "tool",
workspace: "wrk",
entry: "ent",
} as const
export function schema(prefix: keyof typeof prefixes) {
return z.string().startsWith(prefixes[prefix])
}
const LENGTH = 26
// State for monotonic ID generation
let lastTimestamp = 0
let counter = 0
export function ascending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, "ascending", given)
}
export function descending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, "descending", given)
}
function generateID(prefix: keyof typeof prefixes, direction: "descending" | "ascending", given?: string): string {
if (!given) {
return create(prefixes[prefix], direction)
export function schema(prefix: keyof typeof prefixes) {
return z.string().startsWith(prefixes[prefix])
}
if (!given.startsWith(prefixes[prefix])) {
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
}
return given
}
const LENGTH = 26
function randomBase62(length: number): string {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
let result = ""
const bytes = randomBytes(length)
for (let i = 0; i < length; i++) {
result += chars[bytes[i] % 62]
}
return result
}
// State for monotonic ID generation
let lastTimestamp = 0
let counter = 0
export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string {
const currentTimestamp = timestamp ?? Date.now()
if (currentTimestamp !== lastTimestamp) {
lastTimestamp = currentTimestamp
counter = 0
}
counter++
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
now = direction === "descending" ? ~now : now
const timeBytes = Buffer.alloc(6)
for (let i = 0; i < 6; i++) {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
export function ascending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, "ascending", given)
}
return prefix + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
}
export function descending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, "descending", given)
}
/** Extract timestamp from an ascending ID. Does not work with descending IDs. */
export function timestamp(id: string): number {
const prefix = id.split("_")[0]
const hex = id.slice(prefix.length + 1, prefix.length + 13)
const encoded = BigInt("0x" + hex)
return Number(encoded / BigInt(0x1000))
}
function generateID(prefix: keyof typeof prefixes, direction: "descending" | "ascending", given?: string): string {
if (!given) {
return create(prefixes[prefix], direction)
}
export * as Identifier from "./id"
if (!given.startsWith(prefixes[prefix])) {
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
}
return given
}
function randomBase62(length: number): string {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
let result = ""
const bytes = randomBytes(length)
for (let i = 0; i < length; i++) {
result += chars[bytes[i] % 62]
}
return result
}
export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string {
const currentTimestamp = timestamp ?? Date.now()
if (currentTimestamp !== lastTimestamp) {
lastTimestamp = currentTimestamp
counter = 0
}
counter++
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
now = direction === "descending" ? ~now : now
const timeBytes = Buffer.alloc(6)
for (let i = 0; i < 6; i++) {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
}
return prefix + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
}
/** Extract timestamp from an ascending ID. Does not work with descending IDs. */
export function timestamp(id: string): number {
const prefix = id.split("_")[0]
const hex = id.slice(prefix.length + 1, prefix.length + 13)
const encoded = BigInt("0x" + hex)
return Number(encoded / BigInt(0x1000))
}
}
-29
View File
@@ -1,29 +0,0 @@
import * as LSPClient from "./client"
const MAX_PER_FILE = 20
export function pretty(diagnostic: LSPClient.Diagnostic) {
const severityMap = {
1: "ERROR",
2: "WARN",
3: "INFO",
4: "HINT",
}
const severity = severityMap[diagnostic.severity || 1]
const line = diagnostic.range.start.line + 1
const col = diagnostic.range.start.character + 1
return `${severity} [${line}:${col}] ${diagnostic.message}`
}
export function report(file: string, issues: LSPClient.Diagnostic[]) {
const errors = issues.filter((item) => item.severity === 1)
if (errors.length === 0) return ""
const limited = errors.slice(0, MAX_PER_FILE)
const more = errors.length - MAX_PER_FILE
const suffix = more > 0 ? `\n... and ${more} more` : ""
return `<diagnostics file="${file}">\n${limited.map(pretty).join("\n")}${suffix}\n</diagnostics>`
}
export * as Diagnostic from "./diagnostic"
+51 -26
View File
@@ -167,7 +167,7 @@ export const layer = Layer.effect(
const servers: Record<string, LSPServer.Info> = {}
if (!cfg.lsp) {
if (cfg.lsp === false) {
log.info("all LSPs are disabled")
} else {
for (const server of Object.values(LSPServer)) {
@@ -176,27 +176,25 @@ export const layer = Layer.effect(
filterExperimentalServers(servers)
if (cfg.lsp !== true) {
for (const [name, item] of Object.entries(cfg.lsp)) {
const existing = servers[name]
if (item.disabled) {
log.info(`LSP server ${name} is disabled`)
delete servers[name]
continue
}
servers[name] = {
...existing,
id: name,
root: existing?.root ?? (async () => Instance.directory),
extensions: item.extensions ?? existing?.extensions ?? [],
spawn: async (root) => ({
process: lspspawn(item.command[0], item.command.slice(1), {
cwd: root,
env: { ...process.env, ...item.env },
}),
initialization: item.initialization,
for (const [name, item] of Object.entries(cfg.lsp ?? {})) {
const existing = servers[name]
if (item.disabled) {
log.info(`LSP server ${name} is disabled`)
delete servers[name]
continue
}
servers[name] = {
...existing,
id: name,
root: existing?.root ?? (async () => Instance.directory),
extensions: item.extensions ?? existing?.extensions ?? [],
spawn: async (root) => ({
process: lspspawn(item.command[0], item.command.slice(1), {
cwd: root,
env: { ...process.env, ...item.env },
}),
}
initialization: item.initialization,
}),
}
}
@@ -442,11 +440,12 @@ export const layer = Layer.effect(
const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) {
const results = yield* runAll((client) =>
client.connection
.sendRequest<Symbol[]>("workspace/symbol", { query })
.then((result) => result.filter((x) => kinds.includes(x.kind)).slice(0, 10))
.catch(() => [] as Symbol[]),
.sendRequest("workspace/symbol", { query })
.then((result: any) => result.filter((x: Symbol) => kinds.includes(x.kind)))
.then((result: any) => result.slice(0, 10))
.catch(() => []),
)
return results.flat()
return results.flat() as Symbol[]
})
const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) {
@@ -507,4 +506,30 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
export * as Diagnostic from "./diagnostic"
export namespace Diagnostic {
const MAX_PER_FILE = 20
export function pretty(diagnostic: LSPClient.Diagnostic) {
const severityMap = {
1: "ERROR",
2: "WARN",
3: "INFO",
4: "HINT",
}
const severity = severityMap[diagnostic.severity || 1]
const line = diagnostic.range.start.line + 1
const col = diagnostic.range.start.character + 1
return `${severity} [${line}:${col}] ${diagnostic.message}`
}
export function report(file: string, issues: LSPClient.Diagnostic[]) {
const errors = issues.filter((item) => item.severity === 1)
if (errors.length === 0) return ""
const limited = errors.slice(0, MAX_PER_FILE)
const more = errors.length - MAX_PER_FILE
const suffix = more > 0 ? `\n... and ${more} more` : ""
return `<diagnostics file="${file}">\n${limited.map(pretty).join("\n")}${suffix}\n</diagnostics>`
}
}
+121 -121
View File
@@ -4,141 +4,141 @@ import { Global } from "../global"
import { Effect, Layer, Context } from "effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
export const Tokens = z.object({
accessToken: z.string(),
refreshToken: z.string().optional(),
expiresAt: z.number().optional(),
scope: z.string().optional(),
})
export type Tokens = z.infer<typeof Tokens>
export namespace McpAuth {
export const Tokens = z.object({
accessToken: z.string(),
refreshToken: z.string().optional(),
expiresAt: z.number().optional(),
scope: z.string().optional(),
})
export type Tokens = z.infer<typeof Tokens>
export const ClientInfo = z.object({
clientId: z.string(),
clientSecret: z.string().optional(),
clientIdIssuedAt: z.number().optional(),
clientSecretExpiresAt: z.number().optional(),
})
export type ClientInfo = z.infer<typeof ClientInfo>
export const ClientInfo = z.object({
clientId: z.string(),
clientSecret: z.string().optional(),
clientIdIssuedAt: z.number().optional(),
clientSecretExpiresAt: z.number().optional(),
})
export type ClientInfo = z.infer<typeof ClientInfo>
export const Entry = z.object({
tokens: Tokens.optional(),
clientInfo: ClientInfo.optional(),
codeVerifier: z.string().optional(),
oauthState: z.string().optional(),
serverUrl: z.string().optional(),
})
export type Entry = z.infer<typeof Entry>
export const Entry = z.object({
tokens: Tokens.optional(),
clientInfo: ClientInfo.optional(),
codeVerifier: z.string().optional(),
oauthState: z.string().optional(),
serverUrl: z.string().optional(),
})
export type Entry = z.infer<typeof Entry>
const filepath = path.join(Global.Path.data, "mcp-auth.json")
const filepath = path.join(Global.Path.data, "mcp-auth.json")
export interface Interface {
readonly all: () => Effect.Effect<Record<string, Entry>>
readonly get: (mcpName: string) => Effect.Effect<Entry | undefined>
readonly getForUrl: (mcpName: string, serverUrl: string) => Effect.Effect<Entry | undefined>
readonly set: (mcpName: string, entry: Entry, serverUrl?: string) => Effect.Effect<void>
readonly remove: (mcpName: string) => Effect.Effect<void>
readonly updateTokens: (mcpName: string, tokens: Tokens, serverUrl?: string) => Effect.Effect<void>
readonly updateClientInfo: (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) => Effect.Effect<void>
readonly updateCodeVerifier: (mcpName: string, codeVerifier: string) => Effect.Effect<void>
readonly clearCodeVerifier: (mcpName: string) => Effect.Effect<void>
readonly updateOAuthState: (mcpName: string, oauthState: string) => Effect.Effect<void>
readonly getOAuthState: (mcpName: string) => Effect.Effect<string | undefined>
readonly clearOAuthState: (mcpName: string) => Effect.Effect<void>
readonly isTokenExpired: (mcpName: string) => Effect.Effect<boolean | null>
}
export interface Interface {
readonly all: () => Effect.Effect<Record<string, Entry>>
readonly get: (mcpName: string) => Effect.Effect<Entry | undefined>
readonly getForUrl: (mcpName: string, serverUrl: string) => Effect.Effect<Entry | undefined>
readonly set: (mcpName: string, entry: Entry, serverUrl?: string) => Effect.Effect<void>
readonly remove: (mcpName: string) => Effect.Effect<void>
readonly updateTokens: (mcpName: string, tokens: Tokens, serverUrl?: string) => Effect.Effect<void>
readonly updateClientInfo: (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) => Effect.Effect<void>
readonly updateCodeVerifier: (mcpName: string, codeVerifier: string) => Effect.Effect<void>
readonly clearCodeVerifier: (mcpName: string) => Effect.Effect<void>
readonly updateOAuthState: (mcpName: string, oauthState: string) => Effect.Effect<void>
readonly getOAuthState: (mcpName: string) => Effect.Effect<string | undefined>
readonly clearOAuthState: (mcpName: string) => Effect.Effect<void>
readonly isTokenExpired: (mcpName: string) => Effect.Effect<boolean | null>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/McpAuth") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/McpAuth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const all = Effect.fn("McpAuth.all")(function* () {
return yield* fs.readJson(filepath).pipe(
Effect.map((data) => data as Record<string, Entry>),
Effect.catch(() => Effect.succeed({} as Record<string, Entry>)),
)
})
const get = Effect.fn("McpAuth.get")(function* (mcpName: string) {
const data = yield* all()
return data[mcpName]
})
const getForUrl = Effect.fn("McpAuth.getForUrl")(function* (mcpName: string, serverUrl: string) {
const entry = yield* get(mcpName)
if (!entry) return undefined
if (!entry.serverUrl) return undefined
if (entry.serverUrl !== serverUrl) return undefined
return entry
})
const set = Effect.fn("McpAuth.set")(function* (mcpName: string, entry: Entry, serverUrl?: string) {
const data = yield* all()
if (serverUrl) entry.serverUrl = serverUrl
yield* fs.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600).pipe(Effect.orDie)
})
const remove = Effect.fn("McpAuth.remove")(function* (mcpName: string) {
const data = yield* all()
delete data[mcpName]
yield* fs.writeJson(filepath, data, 0o600).pipe(Effect.orDie)
})
const updateField = <K extends keyof Entry>(field: K, spanName: string) =>
Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string, value: NonNullable<Entry[K]>, serverUrl?: string) {
const entry = (yield* get(mcpName)) ?? {}
entry[field] = value
yield* set(mcpName, entry, serverUrl)
const all = Effect.fn("McpAuth.all")(function* () {
return yield* fs.readJson(filepath).pipe(
Effect.map((data) => data as Record<string, Entry>),
Effect.catch(() => Effect.succeed({} as Record<string, Entry>)),
)
})
const clearField = <K extends keyof Entry>(field: K, spanName: string) =>
Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) {
const get = Effect.fn("McpAuth.get")(function* (mcpName: string) {
const data = yield* all()
return data[mcpName]
})
const getForUrl = Effect.fn("McpAuth.getForUrl")(function* (mcpName: string, serverUrl: string) {
const entry = yield* get(mcpName)
if (entry) {
delete entry[field]
yield* set(mcpName, entry)
}
if (!entry) return undefined
if (!entry.serverUrl) return undefined
if (entry.serverUrl !== serverUrl) return undefined
return entry
})
const updateTokens = updateField("tokens", "updateTokens")
const updateClientInfo = updateField("clientInfo", "updateClientInfo")
const updateCodeVerifier = updateField("codeVerifier", "updateCodeVerifier")
const updateOAuthState = updateField("oauthState", "updateOAuthState")
const clearCodeVerifier = clearField("codeVerifier", "clearCodeVerifier")
const clearOAuthState = clearField("oauthState", "clearOAuthState")
const set = Effect.fn("McpAuth.set")(function* (mcpName: string, entry: Entry, serverUrl?: string) {
const data = yield* all()
if (serverUrl) entry.serverUrl = serverUrl
yield* fs.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600).pipe(Effect.orDie)
})
const getOAuthState = Effect.fn("McpAuth.getOAuthState")(function* (mcpName: string) {
const entry = yield* get(mcpName)
return entry?.oauthState
})
const remove = Effect.fn("McpAuth.remove")(function* (mcpName: string) {
const data = yield* all()
delete data[mcpName]
yield* fs.writeJson(filepath, data, 0o600).pipe(Effect.orDie)
})
const isTokenExpired = Effect.fn("McpAuth.isTokenExpired")(function* (mcpName: string) {
const entry = yield* get(mcpName)
if (!entry?.tokens) return null
if (!entry.tokens.expiresAt) return false
return entry.tokens.expiresAt < Date.now() / 1000
})
const updateField = <K extends keyof Entry>(field: K, spanName: string) =>
Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string, value: NonNullable<Entry[K]>, serverUrl?: string) {
const entry = (yield* get(mcpName)) ?? {}
entry[field] = value
yield* set(mcpName, entry, serverUrl)
})
return Service.of({
all,
get,
getForUrl,
set,
remove,
updateTokens,
updateClientInfo,
updateCodeVerifier,
clearCodeVerifier,
updateOAuthState,
getOAuthState,
clearOAuthState,
isTokenExpired,
})
}),
)
const clearField = <K extends keyof Entry>(field: K, spanName: string) =>
Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) {
const entry = yield* get(mcpName)
if (entry) {
delete entry[field]
yield* set(mcpName, entry)
}
})
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const updateTokens = updateField("tokens", "updateTokens")
const updateClientInfo = updateField("clientInfo", "updateClientInfo")
const updateCodeVerifier = updateField("codeVerifier", "updateCodeVerifier")
const updateOAuthState = updateField("oauthState", "updateOAuthState")
const clearCodeVerifier = clearField("codeVerifier", "clearCodeVerifier")
const clearOAuthState = clearField("oauthState", "clearOAuthState")
export * as McpAuth from "./auth"
const getOAuthState = Effect.fn("McpAuth.getOAuthState")(function* (mcpName: string) {
const entry = yield* get(mcpName)
return entry?.oauthState
})
const isTokenExpired = Effect.fn("McpAuth.isTokenExpired")(function* (mcpName: string) {
const entry = yield* get(mcpName)
if (!entry?.tokens) return null
if (!entry.tokens.expiresAt) return false
return entry.tokens.expiresAt < Date.now() / 1000
})
return Service.of({
all,
get,
getForUrl,
set,
remove,
updateTokens,
updateClientInfo,
updateCodeVerifier,
clearCodeVerifier,
updateOAuthState,
getOAuthState,
clearOAuthState,
isTokenExpired,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
}
+160 -160
View File
@@ -56,177 +56,177 @@ interface PendingAuth {
timeout: ReturnType<typeof setTimeout>
}
let server: ReturnType<typeof createServer> | undefined
const pendingAuths = new Map<string, PendingAuth>()
// Reverse index: mcpName → oauthState, so cancelPending(mcpName) can
// find the right entry in pendingAuths (which is keyed by oauthState).
const mcpNameToState = new Map<string, string>()
export namespace McpOAuthCallback {
let server: ReturnType<typeof createServer> | undefined
const pendingAuths = new Map<string, PendingAuth>()
// Reverse index: mcpName → oauthState, so cancelPending(mcpName) can
// find the right entry in pendingAuths (which is keyed by oauthState).
const mcpNameToState = new Map<string, string>()
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
function cleanupStateIndex(oauthState: string) {
for (const [name, state] of mcpNameToState) {
if (state === oauthState) {
mcpNameToState.delete(name)
break
}
}
}
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
const url = new URL(req.url || "/", `http://localhost:${currentPort}`)
if (url.pathname !== currentPath) {
res.writeHead(404)
res.end("Not found")
return
}
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
log.info("received oauth callback", { hasCode: !!code, state, error })
// Enforce state parameter presence
if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack"
log.error("oauth callback missing state parameter", { url: url.toString() })
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (error) {
const errorMsg = errorDescription || error
if (pendingAuths.has(state)) {
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
cleanupStateIndex(state)
pending.reject(new Error(errorMsg))
}
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (!code) {
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR("No authorization code provided"))
return
}
// Validate state parameter
if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
cleanupStateIndex(state)
pending.resolve(code)
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS)
}
export async function ensureRunning(redirectUri?: string): Promise<void> {
// Parse the redirect URI to get port and path (uses defaults if not provided)
const { port, path } = parseRedirectUri(redirectUri)
// If server is running on a different port/path, stop it first
if (server && (currentPort !== port || currentPath !== path)) {
log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port })
await stop()
}
if (server) return
const running = await isPortInUse(port)
if (running) {
log.info("oauth callback server already running on another instance", { port })
return
}
currentPort = port
currentPath = path
server = createServer(handleRequest)
await new Promise<void>((resolve, reject) => {
server!.listen(currentPort, () => {
log.info("oauth callback server started", { port: currentPort, path: currentPath })
resolve()
})
server!.on("error", reject)
})
}
export function waitForCallback(oauthState: string, mcpName?: string): Promise<string> {
if (mcpName) mcpNameToState.set(mcpName, oauthState)
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
if (pendingAuths.has(oauthState)) {
pendingAuths.delete(oauthState)
if (mcpName) mcpNameToState.delete(mcpName)
reject(new Error("OAuth callback timeout - authorization took too long"))
function cleanupStateIndex(oauthState: string) {
for (const [name, state] of mcpNameToState) {
if (state === oauthState) {
mcpNameToState.delete(name)
break
}
}, CALLBACK_TIMEOUT_MS)
}
}
pendingAuths.set(oauthState, { resolve, reject, timeout })
})
}
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
const url = new URL(req.url || "/", `http://localhost:${currentPort}`)
if (url.pathname !== currentPath) {
res.writeHead(404)
res.end("Not found")
return
}
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
log.info("received oauth callback", { hasCode: !!code, state, error })
// Enforce state parameter presence
if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack"
log.error("oauth callback missing state parameter", { url: url.toString() })
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (error) {
const errorMsg = errorDescription || error
if (pendingAuths.has(state)) {
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
cleanupStateIndex(state)
pending.reject(new Error(errorMsg))
}
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (!code) {
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR("No authorization code provided"))
return
}
// Validate state parameter
if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
const pending = pendingAuths.get(state)!
export function cancelPending(mcpName: string): void {
// Look up the oauthState for this mcpName via the reverse index
const oauthState = mcpNameToState.get(mcpName)
const key = oauthState ?? mcpName
const pending = pendingAuths.get(key)
if (pending) {
clearTimeout(pending.timeout)
pendingAuths.delete(key)
mcpNameToState.delete(mcpName)
pending.reject(new Error("Authorization cancelled"))
}
}
pendingAuths.delete(state)
cleanupStateIndex(state)
pending.resolve(code)
export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise<boolean> {
return new Promise((resolve) => {
const socket = createConnection(port, "127.0.0.1")
socket.on("connect", () => {
socket.destroy()
resolve(true)
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS)
}
export async function ensureRunning(redirectUri?: string): Promise<void> {
// Parse the redirect URI to get port and path (uses defaults if not provided)
const { port, path } = parseRedirectUri(redirectUri)
// If server is running on a different port/path, stop it first
if (server && (currentPort !== port || currentPath !== path)) {
log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port })
await stop()
}
if (server) return
const running = await isPortInUse(port)
if (running) {
log.info("oauth callback server already running on another instance", { port })
return
}
currentPort = port
currentPath = path
server = createServer(handleRequest)
await new Promise<void>((resolve, reject) => {
server!.listen(currentPort, () => {
log.info("oauth callback server started", { port: currentPort, path: currentPath })
resolve()
})
server!.on("error", reject)
})
socket.on("error", () => {
resolve(false)
}
export function waitForCallback(oauthState: string, mcpName?: string): Promise<string> {
if (mcpName) mcpNameToState.set(mcpName, oauthState)
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
if (pendingAuths.has(oauthState)) {
pendingAuths.delete(oauthState)
if (mcpName) mcpNameToState.delete(mcpName)
reject(new Error("OAuth callback timeout - authorization took too long"))
}
}, CALLBACK_TIMEOUT_MS)
pendingAuths.set(oauthState, { resolve, reject, timeout })
})
})
}
export async function stop(): Promise<void> {
if (server) {
await new Promise<void>((resolve) => server!.close(() => resolve()))
server = undefined
log.info("oauth callback server stopped")
}
for (const [_name, pending] of pendingAuths) {
clearTimeout(pending.timeout)
pending.reject(new Error("OAuth callback server stopped"))
export function cancelPending(mcpName: string): void {
// Look up the oauthState for this mcpName via the reverse index
const oauthState = mcpNameToState.get(mcpName)
const key = oauthState ?? mcpName
const pending = pendingAuths.get(key)
if (pending) {
clearTimeout(pending.timeout)
pendingAuths.delete(key)
mcpNameToState.delete(mcpName)
pending.reject(new Error("Authorization cancelled"))
}
}
pendingAuths.clear()
mcpNameToState.clear()
}
export function isRunning(): boolean {
return server !== undefined
}
export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise<boolean> {
return new Promise((resolve) => {
const socket = createConnection(port, "127.0.0.1")
socket.on("connect", () => {
socket.destroy()
resolve(true)
})
socket.on("error", () => {
resolve(false)
})
})
}
export * as McpOAuthCallback from "./oauth-callback"
export async function stop(): Promise<void> {
if (server) {
await new Promise<void>((resolve) => server!.close(() => resolve()))
server = undefined
log.info("oauth callback server stopped")
}
for (const [_name, pending] of pendingAuths) {
clearTimeout(pending.timeout)
pending.reject(new Error("OAuth callback server stopped"))
}
pendingAuths.clear()
mcpNameToState.clear()
}
export function isRunning(): boolean {
return server !== undefined
}
}
+2 -11
View File
@@ -124,17 +124,8 @@ export async function install(dir: string) {
return
}
type PackageDeps = Record<string, string>
type PackageJson = {
dependencies?: PackageDeps
devDependencies?: PackageDeps
peerDependencies?: PackageDeps
optionalDependencies?: PackageDeps
}
const pkg: PackageJson = await Filesystem.readJson<PackageJson>(path.join(dir, "package.json")).catch(() => ({}))
const lock: { packages?: Record<string, PackageJson> } = await Filesystem.readJson<{
packages?: Record<string, PackageJson>
}>(path.join(dir, "package-lock.json")).catch(() => ({}))
const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({}))
const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({}))
const declared = new Set([
...Object.keys(pkg.dependencies || {}),
+1 -680
View File
@@ -1,680 +1 @@
import z from "zod"
import * as path from "path"
import * as fs from "fs/promises"
import { readFileSync } from "fs"
import { Log } from "../util"
const log = Log.create({ service: "patch" })
// Schema definitions
export const PatchSchema = z.object({
patchText: z.string().describe("The full patch text that describes all changes to be made"),
})
export type PatchParams = z.infer<typeof PatchSchema>
// Core types matching the Rust implementation
export interface ApplyPatchArgs {
patch: string
hunks: Hunk[]
workdir?: string
}
export type Hunk =
| { type: "add"; path: string; contents: string }
| { type: "delete"; path: string }
| { type: "update"; path: string; move_path?: string; chunks: UpdateFileChunk[] }
export interface UpdateFileChunk {
old_lines: string[]
new_lines: string[]
change_context?: string
is_end_of_file?: boolean
}
export interface ApplyPatchAction {
changes: Map<string, ApplyPatchFileChange>
patch: string
cwd: string
}
export type ApplyPatchFileChange =
| { type: "add"; content: string }
| { type: "delete"; content: string }
| { type: "update"; unified_diff: string; move_path?: string; new_content: string }
export interface AffectedPaths {
added: string[]
modified: string[]
deleted: string[]
}
export enum ApplyPatchError {
ParseError = "ParseError",
IoError = "IoError",
ComputeReplacements = "ComputeReplacements",
ImplicitInvocation = "ImplicitInvocation",
}
export enum MaybeApplyPatch {
Body = "Body",
ShellParseError = "ShellParseError",
PatchParseError = "PatchParseError",
NotApplyPatch = "NotApplyPatch",
}
export enum MaybeApplyPatchVerified {
Body = "Body",
ShellParseError = "ShellParseError",
CorrectnessError = "CorrectnessError",
NotApplyPatch = "NotApplyPatch",
}
// Parser implementation
function parsePatchHeader(
lines: string[],
startIdx: number,
): { filePath: string; movePath?: string; nextIdx: number } | null {
const line = lines[startIdx]
if (line.startsWith("*** Add File:")) {
const filePath = line.slice("*** Add File:".length).trim()
return filePath ? { filePath, nextIdx: startIdx + 1 } : null
}
if (line.startsWith("*** Delete File:")) {
const filePath = line.slice("*** Delete File:".length).trim()
return filePath ? { filePath, nextIdx: startIdx + 1 } : null
}
if (line.startsWith("*** Update File:")) {
const filePath = line.slice("*** Update File:".length).trim()
let movePath: string | undefined
let nextIdx = startIdx + 1
// Check for move directive
if (nextIdx < lines.length && lines[nextIdx].startsWith("*** Move to:")) {
movePath = lines[nextIdx].slice("*** Move to:".length).trim()
nextIdx++
}
return filePath ? { filePath, movePath, nextIdx } : null
}
return null
}
function parseUpdateFileChunks(lines: string[], startIdx: number): { chunks: UpdateFileChunk[]; nextIdx: number } {
const chunks: UpdateFileChunk[] = []
let i = startIdx
while (i < lines.length && !lines[i].startsWith("***")) {
if (lines[i].startsWith("@@")) {
// Parse context line
const contextLine = lines[i].substring(2).trim()
i++
const oldLines: string[] = []
const newLines: string[] = []
let isEndOfFile = false
// Parse change lines
while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) {
const changeLine = lines[i]
if (changeLine === "*** End of File") {
isEndOfFile = true
i++
break
}
if (changeLine.startsWith(" ")) {
// Keep line - appears in both old and new
const content = changeLine.substring(1)
oldLines.push(content)
newLines.push(content)
} else if (changeLine.startsWith("-")) {
// Remove line - only in old
oldLines.push(changeLine.substring(1))
} else if (changeLine.startsWith("+")) {
// Add line - only in new
newLines.push(changeLine.substring(1))
}
i++
}
chunks.push({
old_lines: oldLines,
new_lines: newLines,
change_context: contextLine || undefined,
is_end_of_file: isEndOfFile || undefined,
})
} else {
i++
}
}
return { chunks, nextIdx: i }
}
function parseAddFileContent(lines: string[], startIdx: number): { content: string; nextIdx: number } {
let content = ""
let i = startIdx
while (i < lines.length && !lines[i].startsWith("***")) {
if (lines[i].startsWith("+")) {
content += lines[i].substring(1) + "\n"
}
i++
}
// Remove trailing newline
if (content.endsWith("\n")) {
content = content.slice(0, -1)
}
return { content, nextIdx: i }
}
function stripHeredoc(input: string): string {
// Match heredoc patterns like: cat <<'EOF'\n...\nEOF or <<EOF\n...\nEOF
const heredocMatch = input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)
if (heredocMatch) {
return heredocMatch[2]
}
return input
}
export function parsePatch(patchText: string): { hunks: Hunk[] } {
const cleaned = stripHeredoc(patchText.trim())
const lines = cleaned.split("\n")
const hunks: Hunk[] = []
let i = 0
// Look for Begin/End patch markers
const beginMarker = "*** Begin Patch"
const endMarker = "*** End Patch"
const beginIdx = lines.findIndex((line) => line.trim() === beginMarker)
const endIdx = lines.findIndex((line) => line.trim() === endMarker)
if (beginIdx === -1 || endIdx === -1 || beginIdx >= endIdx) {
throw new Error("Invalid patch format: missing Begin/End markers")
}
// Parse content between markers
i = beginIdx + 1
while (i < endIdx) {
const header = parsePatchHeader(lines, i)
if (!header) {
i++
continue
}
if (lines[i].startsWith("*** Add File:")) {
const { content, nextIdx } = parseAddFileContent(lines, header.nextIdx)
hunks.push({
type: "add",
path: header.filePath,
contents: content,
})
i = nextIdx
} else if (lines[i].startsWith("*** Delete File:")) {
hunks.push({
type: "delete",
path: header.filePath,
})
i = header.nextIdx
} else if (lines[i].startsWith("*** Update File:")) {
const { chunks, nextIdx } = parseUpdateFileChunks(lines, header.nextIdx)
hunks.push({
type: "update",
path: header.filePath,
move_path: header.movePath,
chunks,
})
i = nextIdx
} else {
i++
}
}
return { hunks }
}
// Apply patch functionality
export function maybeParseApplyPatch(
argv: string[],
):
| { type: MaybeApplyPatch.Body; args: ApplyPatchArgs }
| { type: MaybeApplyPatch.PatchParseError; error: Error }
| { type: MaybeApplyPatch.NotApplyPatch } {
const APPLY_PATCH_COMMANDS = ["apply_patch", "applypatch"]
// Direct invocation: apply_patch <patch>
if (argv.length === 2 && APPLY_PATCH_COMMANDS.includes(argv[0])) {
try {
const { hunks } = parsePatch(argv[1])
return {
type: MaybeApplyPatch.Body,
args: {
patch: argv[1],
hunks,
},
}
} catch (error) {
return {
type: MaybeApplyPatch.PatchParseError,
error: error as Error,
}
}
}
// Bash heredoc form: bash -lc 'apply_patch <<"EOF" ...'
if (argv.length === 3 && argv[0] === "bash" && argv[1] === "-lc") {
// Simple extraction - in real implementation would need proper bash parsing
const script = argv[2]
const heredocMatch = script.match(/apply_patch\s*<<['"](\w+)['"]\s*\n([\s\S]*?)\n\1/)
if (heredocMatch) {
const patchContent = heredocMatch[2]
try {
const { hunks } = parsePatch(patchContent)
return {
type: MaybeApplyPatch.Body,
args: {
patch: patchContent,
hunks,
},
}
} catch (error) {
return {
type: MaybeApplyPatch.PatchParseError,
error: error as Error,
}
}
}
}
return { type: MaybeApplyPatch.NotApplyPatch }
}
// File content manipulation
interface ApplyPatchFileUpdate {
unified_diff: string
content: string
}
export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate {
// Read original file content
let originalContent: string
try {
originalContent = readFileSync(filePath, "utf-8")
} catch (error) {
throw new Error(`Failed to read file ${filePath}: ${error}`, { cause: error })
}
let originalLines = originalContent.split("\n")
// Drop trailing empty element for consistent line counting
if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
originalLines.pop()
}
const replacements = computeReplacements(originalLines, filePath, chunks)
let newLines = applyReplacements(originalLines, replacements)
// Ensure trailing newline
if (newLines.length === 0 || newLines[newLines.length - 1] !== "") {
newLines.push("")
}
const newContent = newLines.join("\n")
// Generate unified diff
const unifiedDiff = generateUnifiedDiff(originalContent, newContent)
return {
unified_diff: unifiedDiff,
content: newContent,
}
}
function computeReplacements(
originalLines: string[],
filePath: string,
chunks: UpdateFileChunk[],
): Array<[number, number, string[]]> {
const replacements: Array<[number, number, string[]]> = []
let lineIndex = 0
for (const chunk of chunks) {
// Handle context-based seeking
if (chunk.change_context) {
const contextIdx = seekSequence(originalLines, [chunk.change_context], lineIndex)
if (contextIdx === -1) {
throw new Error(`Failed to find context '${chunk.change_context}' in ${filePath}`)
}
lineIndex = contextIdx + 1
}
// Handle pure addition (no old lines)
if (chunk.old_lines.length === 0) {
const insertionIdx =
originalLines.length > 0 && originalLines[originalLines.length - 1] === ""
? originalLines.length - 1
: originalLines.length
replacements.push([insertionIdx, 0, chunk.new_lines])
continue
}
// Try to match old lines in the file
let pattern = chunk.old_lines
let newSlice = chunk.new_lines
let found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
// Retry without trailing empty line if not found
if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") {
pattern = pattern.slice(0, -1)
if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
newSlice = newSlice.slice(0, -1)
}
found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
}
if (found !== -1) {
replacements.push([found, pattern.length, newSlice])
lineIndex = found + pattern.length
} else {
throw new Error(`Failed to find expected lines in ${filePath}:\n${chunk.old_lines.join("\n")}`)
}
}
// Sort replacements by index to apply in order
replacements.sort((a, b) => a[0] - b[0])
return replacements
}
function applyReplacements(lines: string[], replacements: Array<[number, number, string[]]>): string[] {
// Apply replacements in reverse order to avoid index shifting
const result = [...lines]
for (let i = replacements.length - 1; i >= 0; i--) {
const [startIdx, oldLen, newSegment] = replacements[i]
// Remove old lines
result.splice(startIdx, oldLen)
// Insert new lines
for (let j = 0; j < newSegment.length; j++) {
result.splice(startIdx + j, 0, newSegment[j])
}
}
return result
}
// Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode)
function normalizeUnicode(str: string): string {
return str
.replace(/[\u2018\u2019\u201A\u201B]/g, "'") // single quotes
.replace(/[\u201C\u201D\u201E\u201F]/g, '"') // double quotes
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-") // dashes
.replace(/\u2026/g, "...") // ellipsis
.replace(/\u00A0/g, " ") // non-breaking space
}
type Comparator = (a: string, b: string) => boolean
function tryMatch(lines: string[], pattern: string[], startIndex: number, compare: Comparator, eof: boolean): number {
// If EOF anchor, try matching from end of file first
if (eof) {
const fromEnd = lines.length - pattern.length
if (fromEnd >= startIndex) {
let matches = true
for (let j = 0; j < pattern.length; j++) {
if (!compare(lines[fromEnd + j], pattern[j])) {
matches = false
break
}
}
if (matches) return fromEnd
}
}
// Forward search from startIndex
for (let i = startIndex; i <= lines.length - pattern.length; i++) {
let matches = true
for (let j = 0; j < pattern.length; j++) {
if (!compare(lines[i + j], pattern[j])) {
matches = false
break
}
}
if (matches) return i
}
return -1
}
function seekSequence(lines: string[], pattern: string[], startIndex: number, eof = false): number {
if (pattern.length === 0) return -1
// Pass 1: exact match
const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof)
if (exact !== -1) return exact
// Pass 2: rstrip (trim trailing whitespace)
const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof)
if (rstrip !== -1) return rstrip
// Pass 3: trim (both ends)
const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof)
if (trim !== -1) return trim
// Pass 4: normalized (Unicode punctuation to ASCII)
const normalized = tryMatch(
lines,
pattern,
startIndex,
(a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()),
eof,
)
return normalized
}
function generateUnifiedDiff(oldContent: string, newContent: string): string {
const oldLines = oldContent.split("\n")
const newLines = newContent.split("\n")
// Simple diff generation - in a real implementation you'd use a proper diff algorithm
let diff = "@@ -1 +1 @@\n"
// Find changes (simplified approach)
const maxLen = Math.max(oldLines.length, newLines.length)
let hasChanges = false
for (let i = 0; i < maxLen; i++) {
const oldLine = oldLines[i] || ""
const newLine = newLines[i] || ""
if (oldLine !== newLine) {
if (oldLine) diff += `-${oldLine}\n`
if (newLine) diff += `+${newLine}\n`
hasChanges = true
} else if (oldLine) {
diff += ` ${oldLine}\n`
}
}
return hasChanges ? diff : ""
}
// Apply hunks to filesystem
export async function applyHunksToFiles(hunks: Hunk[]): Promise<AffectedPaths> {
if (hunks.length === 0) {
throw new Error("No files were modified.")
}
const added: string[] = []
const modified: string[] = []
const deleted: string[] = []
for (const hunk of hunks) {
switch (hunk.type) {
case "add":
// Create parent directories
const addDir = path.dirname(hunk.path)
if (addDir !== "." && addDir !== "/") {
await fs.mkdir(addDir, { recursive: true })
}
await fs.writeFile(hunk.path, hunk.contents, "utf-8")
added.push(hunk.path)
log.info(`Added file: ${hunk.path}`)
break
case "delete":
await fs.unlink(hunk.path)
deleted.push(hunk.path)
log.info(`Deleted file: ${hunk.path}`)
break
case "update":
const fileUpdate = deriveNewContentsFromChunks(hunk.path, hunk.chunks)
if (hunk.move_path) {
// Handle file move
const moveDir = path.dirname(hunk.move_path)
if (moveDir !== "." && moveDir !== "/") {
await fs.mkdir(moveDir, { recursive: true })
}
await fs.writeFile(hunk.move_path, fileUpdate.content, "utf-8")
await fs.unlink(hunk.path)
modified.push(hunk.move_path)
log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`)
} else {
// Regular update
await fs.writeFile(hunk.path, fileUpdate.content, "utf-8")
modified.push(hunk.path)
log.info(`Updated file: ${hunk.path}`)
}
break
}
}
return { added, modified, deleted }
}
// Main patch application function
export async function applyPatch(patchText: string): Promise<AffectedPaths> {
const { hunks } = parsePatch(patchText)
return applyHunksToFiles(hunks)
}
// Async version of maybeParseApplyPatchVerified
export async function maybeParseApplyPatchVerified(
argv: string[],
cwd: string,
): Promise<
| { type: MaybeApplyPatchVerified.Body; action: ApplyPatchAction }
| { type: MaybeApplyPatchVerified.CorrectnessError; error: Error }
| { type: MaybeApplyPatchVerified.NotApplyPatch }
> {
// Detect implicit patch invocation (raw patch without apply_patch command)
if (argv.length === 1) {
try {
parsePatch(argv[0])
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: new Error(ApplyPatchError.ImplicitInvocation),
}
} catch {
// Not a patch, continue
}
}
const result = maybeParseApplyPatch(argv)
switch (result.type) {
case MaybeApplyPatch.Body:
const { args } = result
const effectiveCwd = args.workdir ? path.resolve(cwd, args.workdir) : cwd
const changes = new Map<string, ApplyPatchFileChange>()
for (const hunk of args.hunks) {
const resolvedPath = path.resolve(
effectiveCwd,
hunk.type === "update" && hunk.move_path ? hunk.move_path : hunk.path,
)
switch (hunk.type) {
case "add":
changes.set(resolvedPath, {
type: "add",
content: hunk.contents,
})
break
case "delete":
// For delete, we need to read the current content
const deletePath = path.resolve(effectiveCwd, hunk.path)
try {
const content = await fs.readFile(deletePath, "utf-8")
changes.set(resolvedPath, {
type: "delete",
content,
})
} catch {
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: new Error(`Failed to read file for deletion: ${deletePath}`),
}
}
break
case "update":
const updatePath = path.resolve(effectiveCwd, hunk.path)
try {
const fileUpdate = deriveNewContentsFromChunks(updatePath, hunk.chunks)
changes.set(resolvedPath, {
type: "update",
unified_diff: fileUpdate.unified_diff,
move_path: hunk.move_path ? path.resolve(effectiveCwd, hunk.move_path) : undefined,
new_content: fileUpdate.content,
})
} catch (error) {
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: error as Error,
}
}
break
}
}
return {
type: MaybeApplyPatchVerified.Body,
action: {
changes,
patch: args.patch,
cwd: effectiveCwd,
},
}
case MaybeApplyPatch.PatchParseError:
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: result.error,
}
case MaybeApplyPatch.NotApplyPatch:
return { type: MaybeApplyPatchVerified.NotApplyPatch }
}
}
export * as Patch from "."
export * as Patch from "./patch"
+678
View File
@@ -0,0 +1,678 @@
import z from "zod"
import * as path from "path"
import * as fs from "fs/promises"
import { readFileSync } from "fs"
import { Log } from "../util"
const log = Log.create({ service: "patch" })
// Schema definitions
export const PatchSchema = z.object({
patchText: z.string().describe("The full patch text that describes all changes to be made"),
})
export type PatchParams = z.infer<typeof PatchSchema>
// Core types matching the Rust implementation
export interface ApplyPatchArgs {
patch: string
hunks: Hunk[]
workdir?: string
}
export type Hunk =
| { type: "add"; path: string; contents: string }
| { type: "delete"; path: string }
| { type: "update"; path: string; move_path?: string; chunks: UpdateFileChunk[] }
export interface UpdateFileChunk {
old_lines: string[]
new_lines: string[]
change_context?: string
is_end_of_file?: boolean
}
export interface ApplyPatchAction {
changes: Map<string, ApplyPatchFileChange>
patch: string
cwd: string
}
export type ApplyPatchFileChange =
| { type: "add"; content: string }
| { type: "delete"; content: string }
| { type: "update"; unified_diff: string; move_path?: string; new_content: string }
export interface AffectedPaths {
added: string[]
modified: string[]
deleted: string[]
}
export enum ApplyPatchError {
ParseError = "ParseError",
IoError = "IoError",
ComputeReplacements = "ComputeReplacements",
ImplicitInvocation = "ImplicitInvocation",
}
export enum MaybeApplyPatch {
Body = "Body",
ShellParseError = "ShellParseError",
PatchParseError = "PatchParseError",
NotApplyPatch = "NotApplyPatch",
}
export enum MaybeApplyPatchVerified {
Body = "Body",
ShellParseError = "ShellParseError",
CorrectnessError = "CorrectnessError",
NotApplyPatch = "NotApplyPatch",
}
// Parser implementation
function parsePatchHeader(
lines: string[],
startIdx: number,
): { filePath: string; movePath?: string; nextIdx: number } | null {
const line = lines[startIdx]
if (line.startsWith("*** Add File:")) {
const filePath = line.slice("*** Add File:".length).trim()
return filePath ? { filePath, nextIdx: startIdx + 1 } : null
}
if (line.startsWith("*** Delete File:")) {
const filePath = line.slice("*** Delete File:".length).trim()
return filePath ? { filePath, nextIdx: startIdx + 1 } : null
}
if (line.startsWith("*** Update File:")) {
const filePath = line.slice("*** Update File:".length).trim()
let movePath: string | undefined
let nextIdx = startIdx + 1
// Check for move directive
if (nextIdx < lines.length && lines[nextIdx].startsWith("*** Move to:")) {
movePath = lines[nextIdx].slice("*** Move to:".length).trim()
nextIdx++
}
return filePath ? { filePath, movePath, nextIdx } : null
}
return null
}
function parseUpdateFileChunks(lines: string[], startIdx: number): { chunks: UpdateFileChunk[]; nextIdx: number } {
const chunks: UpdateFileChunk[] = []
let i = startIdx
while (i < lines.length && !lines[i].startsWith("***")) {
if (lines[i].startsWith("@@")) {
// Parse context line
const contextLine = lines[i].substring(2).trim()
i++
const oldLines: string[] = []
const newLines: string[] = []
let isEndOfFile = false
// Parse change lines
while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) {
const changeLine = lines[i]
if (changeLine === "*** End of File") {
isEndOfFile = true
i++
break
}
if (changeLine.startsWith(" ")) {
// Keep line - appears in both old and new
const content = changeLine.substring(1)
oldLines.push(content)
newLines.push(content)
} else if (changeLine.startsWith("-")) {
// Remove line - only in old
oldLines.push(changeLine.substring(1))
} else if (changeLine.startsWith("+")) {
// Add line - only in new
newLines.push(changeLine.substring(1))
}
i++
}
chunks.push({
old_lines: oldLines,
new_lines: newLines,
change_context: contextLine || undefined,
is_end_of_file: isEndOfFile || undefined,
})
} else {
i++
}
}
return { chunks, nextIdx: i }
}
function parseAddFileContent(lines: string[], startIdx: number): { content: string; nextIdx: number } {
let content = ""
let i = startIdx
while (i < lines.length && !lines[i].startsWith("***")) {
if (lines[i].startsWith("+")) {
content += lines[i].substring(1) + "\n"
}
i++
}
// Remove trailing newline
if (content.endsWith("\n")) {
content = content.slice(0, -1)
}
return { content, nextIdx: i }
}
function stripHeredoc(input: string): string {
// Match heredoc patterns like: cat <<'EOF'\n...\nEOF or <<EOF\n...\nEOF
const heredocMatch = input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)
if (heredocMatch) {
return heredocMatch[2]
}
return input
}
export function parsePatch(patchText: string): { hunks: Hunk[] } {
const cleaned = stripHeredoc(patchText.trim())
const lines = cleaned.split("\n")
const hunks: Hunk[] = []
let i = 0
// Look for Begin/End patch markers
const beginMarker = "*** Begin Patch"
const endMarker = "*** End Patch"
const beginIdx = lines.findIndex((line) => line.trim() === beginMarker)
const endIdx = lines.findIndex((line) => line.trim() === endMarker)
if (beginIdx === -1 || endIdx === -1 || beginIdx >= endIdx) {
throw new Error("Invalid patch format: missing Begin/End markers")
}
// Parse content between markers
i = beginIdx + 1
while (i < endIdx) {
const header = parsePatchHeader(lines, i)
if (!header) {
i++
continue
}
if (lines[i].startsWith("*** Add File:")) {
const { content, nextIdx } = parseAddFileContent(lines, header.nextIdx)
hunks.push({
type: "add",
path: header.filePath,
contents: content,
})
i = nextIdx
} else if (lines[i].startsWith("*** Delete File:")) {
hunks.push({
type: "delete",
path: header.filePath,
})
i = header.nextIdx
} else if (lines[i].startsWith("*** Update File:")) {
const { chunks, nextIdx } = parseUpdateFileChunks(lines, header.nextIdx)
hunks.push({
type: "update",
path: header.filePath,
move_path: header.movePath,
chunks,
})
i = nextIdx
} else {
i++
}
}
return { hunks }
}
// Apply patch functionality
export function maybeParseApplyPatch(
argv: string[],
):
| { type: MaybeApplyPatch.Body; args: ApplyPatchArgs }
| { type: MaybeApplyPatch.PatchParseError; error: Error }
| { type: MaybeApplyPatch.NotApplyPatch } {
const APPLY_PATCH_COMMANDS = ["apply_patch", "applypatch"]
// Direct invocation: apply_patch <patch>
if (argv.length === 2 && APPLY_PATCH_COMMANDS.includes(argv[0])) {
try {
const { hunks } = parsePatch(argv[1])
return {
type: MaybeApplyPatch.Body,
args: {
patch: argv[1],
hunks,
},
}
} catch (error) {
return {
type: MaybeApplyPatch.PatchParseError,
error: error as Error,
}
}
}
// Bash heredoc form: bash -lc 'apply_patch <<"EOF" ...'
if (argv.length === 3 && argv[0] === "bash" && argv[1] === "-lc") {
// Simple extraction - in real implementation would need proper bash parsing
const script = argv[2]
const heredocMatch = script.match(/apply_patch\s*<<['"](\w+)['"]\s*\n([\s\S]*?)\n\1/)
if (heredocMatch) {
const patchContent = heredocMatch[2]
try {
const { hunks } = parsePatch(patchContent)
return {
type: MaybeApplyPatch.Body,
args: {
patch: patchContent,
hunks,
},
}
} catch (error) {
return {
type: MaybeApplyPatch.PatchParseError,
error: error as Error,
}
}
}
}
return { type: MaybeApplyPatch.NotApplyPatch }
}
// File content manipulation
interface ApplyPatchFileUpdate {
unified_diff: string
content: string
}
export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate {
// Read original file content
let originalContent: string
try {
originalContent = readFileSync(filePath, "utf-8")
} catch (error) {
throw new Error(`Failed to read file ${filePath}: ${error}`, { cause: error })
}
let originalLines = originalContent.split("\n")
// Drop trailing empty element for consistent line counting
if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
originalLines.pop()
}
const replacements = computeReplacements(originalLines, filePath, chunks)
let newLines = applyReplacements(originalLines, replacements)
// Ensure trailing newline
if (newLines.length === 0 || newLines[newLines.length - 1] !== "") {
newLines.push("")
}
const newContent = newLines.join("\n")
// Generate unified diff
const unifiedDiff = generateUnifiedDiff(originalContent, newContent)
return {
unified_diff: unifiedDiff,
content: newContent,
}
}
function computeReplacements(
originalLines: string[],
filePath: string,
chunks: UpdateFileChunk[],
): Array<[number, number, string[]]> {
const replacements: Array<[number, number, string[]]> = []
let lineIndex = 0
for (const chunk of chunks) {
// Handle context-based seeking
if (chunk.change_context) {
const contextIdx = seekSequence(originalLines, [chunk.change_context], lineIndex)
if (contextIdx === -1) {
throw new Error(`Failed to find context '${chunk.change_context}' in ${filePath}`)
}
lineIndex = contextIdx + 1
}
// Handle pure addition (no old lines)
if (chunk.old_lines.length === 0) {
const insertionIdx =
originalLines.length > 0 && originalLines[originalLines.length - 1] === ""
? originalLines.length - 1
: originalLines.length
replacements.push([insertionIdx, 0, chunk.new_lines])
continue
}
// Try to match old lines in the file
let pattern = chunk.old_lines
let newSlice = chunk.new_lines
let found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
// Retry without trailing empty line if not found
if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") {
pattern = pattern.slice(0, -1)
if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
newSlice = newSlice.slice(0, -1)
}
found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
}
if (found !== -1) {
replacements.push([found, pattern.length, newSlice])
lineIndex = found + pattern.length
} else {
throw new Error(`Failed to find expected lines in ${filePath}:\n${chunk.old_lines.join("\n")}`)
}
}
// Sort replacements by index to apply in order
replacements.sort((a, b) => a[0] - b[0])
return replacements
}
function applyReplacements(lines: string[], replacements: Array<[number, number, string[]]>): string[] {
// Apply replacements in reverse order to avoid index shifting
const result = [...lines]
for (let i = replacements.length - 1; i >= 0; i--) {
const [startIdx, oldLen, newSegment] = replacements[i]
// Remove old lines
result.splice(startIdx, oldLen)
// Insert new lines
for (let j = 0; j < newSegment.length; j++) {
result.splice(startIdx + j, 0, newSegment[j])
}
}
return result
}
// Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode)
function normalizeUnicode(str: string): string {
return str
.replace(/[\u2018\u2019\u201A\u201B]/g, "'") // single quotes
.replace(/[\u201C\u201D\u201E\u201F]/g, '"') // double quotes
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-") // dashes
.replace(/\u2026/g, "...") // ellipsis
.replace(/\u00A0/g, " ") // non-breaking space
}
type Comparator = (a: string, b: string) => boolean
function tryMatch(lines: string[], pattern: string[], startIndex: number, compare: Comparator, eof: boolean): number {
// If EOF anchor, try matching from end of file first
if (eof) {
const fromEnd = lines.length - pattern.length
if (fromEnd >= startIndex) {
let matches = true
for (let j = 0; j < pattern.length; j++) {
if (!compare(lines[fromEnd + j], pattern[j])) {
matches = false
break
}
}
if (matches) return fromEnd
}
}
// Forward search from startIndex
for (let i = startIndex; i <= lines.length - pattern.length; i++) {
let matches = true
for (let j = 0; j < pattern.length; j++) {
if (!compare(lines[i + j], pattern[j])) {
matches = false
break
}
}
if (matches) return i
}
return -1
}
function seekSequence(lines: string[], pattern: string[], startIndex: number, eof = false): number {
if (pattern.length === 0) return -1
// Pass 1: exact match
const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof)
if (exact !== -1) return exact
// Pass 2: rstrip (trim trailing whitespace)
const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof)
if (rstrip !== -1) return rstrip
// Pass 3: trim (both ends)
const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof)
if (trim !== -1) return trim
// Pass 4: normalized (Unicode punctuation to ASCII)
const normalized = tryMatch(
lines,
pattern,
startIndex,
(a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()),
eof,
)
return normalized
}
function generateUnifiedDiff(oldContent: string, newContent: string): string {
const oldLines = oldContent.split("\n")
const newLines = newContent.split("\n")
// Simple diff generation - in a real implementation you'd use a proper diff algorithm
let diff = "@@ -1 +1 @@\n"
// Find changes (simplified approach)
const maxLen = Math.max(oldLines.length, newLines.length)
let hasChanges = false
for (let i = 0; i < maxLen; i++) {
const oldLine = oldLines[i] || ""
const newLine = newLines[i] || ""
if (oldLine !== newLine) {
if (oldLine) diff += `-${oldLine}\n`
if (newLine) diff += `+${newLine}\n`
hasChanges = true
} else if (oldLine) {
diff += ` ${oldLine}\n`
}
}
return hasChanges ? diff : ""
}
// Apply hunks to filesystem
export async function applyHunksToFiles(hunks: Hunk[]): Promise<AffectedPaths> {
if (hunks.length === 0) {
throw new Error("No files were modified.")
}
const added: string[] = []
const modified: string[] = []
const deleted: string[] = []
for (const hunk of hunks) {
switch (hunk.type) {
case "add":
// Create parent directories
const addDir = path.dirname(hunk.path)
if (addDir !== "." && addDir !== "/") {
await fs.mkdir(addDir, { recursive: true })
}
await fs.writeFile(hunk.path, hunk.contents, "utf-8")
added.push(hunk.path)
log.info(`Added file: ${hunk.path}`)
break
case "delete":
await fs.unlink(hunk.path)
deleted.push(hunk.path)
log.info(`Deleted file: ${hunk.path}`)
break
case "update":
const fileUpdate = deriveNewContentsFromChunks(hunk.path, hunk.chunks)
if (hunk.move_path) {
// Handle file move
const moveDir = path.dirname(hunk.move_path)
if (moveDir !== "." && moveDir !== "/") {
await fs.mkdir(moveDir, { recursive: true })
}
await fs.writeFile(hunk.move_path, fileUpdate.content, "utf-8")
await fs.unlink(hunk.path)
modified.push(hunk.move_path)
log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`)
} else {
// Regular update
await fs.writeFile(hunk.path, fileUpdate.content, "utf-8")
modified.push(hunk.path)
log.info(`Updated file: ${hunk.path}`)
}
break
}
}
return { added, modified, deleted }
}
// Main patch application function
export async function applyPatch(patchText: string): Promise<AffectedPaths> {
const { hunks } = parsePatch(patchText)
return applyHunksToFiles(hunks)
}
// Async version of maybeParseApplyPatchVerified
export async function maybeParseApplyPatchVerified(
argv: string[],
cwd: string,
): Promise<
| { type: MaybeApplyPatchVerified.Body; action: ApplyPatchAction }
| { type: MaybeApplyPatchVerified.CorrectnessError; error: Error }
| { type: MaybeApplyPatchVerified.NotApplyPatch }
> {
// Detect implicit patch invocation (raw patch without apply_patch command)
if (argv.length === 1) {
try {
parsePatch(argv[0])
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: new Error(ApplyPatchError.ImplicitInvocation),
}
} catch {
// Not a patch, continue
}
}
const result = maybeParseApplyPatch(argv)
switch (result.type) {
case MaybeApplyPatch.Body:
const { args } = result
const effectiveCwd = args.workdir ? path.resolve(cwd, args.workdir) : cwd
const changes = new Map<string, ApplyPatchFileChange>()
for (const hunk of args.hunks) {
const resolvedPath = path.resolve(
effectiveCwd,
hunk.type === "update" && hunk.move_path ? hunk.move_path : hunk.path,
)
switch (hunk.type) {
case "add":
changes.set(resolvedPath, {
type: "add",
content: hunk.contents,
})
break
case "delete":
// For delete, we need to read the current content
const deletePath = path.resolve(effectiveCwd, hunk.path)
try {
const content = await fs.readFile(deletePath, "utf-8")
changes.set(resolvedPath, {
type: "delete",
content,
})
} catch {
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: new Error(`Failed to read file for deletion: ${deletePath}`),
}
}
break
case "update":
const updatePath = path.resolve(effectiveCwd, hunk.path)
try {
const fileUpdate = deriveNewContentsFromChunks(updatePath, hunk.chunks)
changes.set(resolvedPath, {
type: "update",
unified_diff: fileUpdate.unified_diff,
move_path: hunk.move_path ? path.resolve(effectiveCwd, hunk.move_path) : undefined,
new_content: fileUpdate.content,
})
} catch (error) {
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: error as Error,
}
}
break
}
}
return {
type: MaybeApplyPatchVerified.Body,
action: {
changes,
patch: args.patch,
cwd: effectiveCwd,
},
}
case MaybeApplyPatch.PatchParseError:
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: result.error,
}
case MaybeApplyPatch.NotApplyPatch:
return { type: MaybeApplyPatchVerified.NotApplyPatch }
}
}
@@ -1,146 +1,146 @@
import { z } from "zod"
import type { Model } from "@opencode-ai/sdk/v2"
export const schema = z.object({
data: z.array(
z.object({
model_picker_enabled: z.boolean(),
id: z.string(),
name: z.string(),
// every version looks like: `{model.id}-YYYY-MM-DD`
version: z.string(),
supported_endpoints: z.array(z.string()).optional(),
capabilities: z.object({
family: z.string(),
limits: z.object({
max_context_window_tokens: z.number(),
max_output_tokens: z.number(),
max_prompt_tokens: z.number(),
vision: z
.object({
max_prompt_image_size: z.number(),
max_prompt_images: z.number(),
supported_media_types: z.array(z.string()),
})
.optional(),
}),
supports: z.object({
adaptive_thinking: z.boolean().optional(),
max_thinking_budget: z.number().optional(),
min_thinking_budget: z.number().optional(),
reasoning_effort: z.array(z.string()).optional(),
streaming: z.boolean(),
structured_outputs: z.boolean().optional(),
tool_calls: z.boolean(),
vision: z.boolean().optional(),
export namespace CopilotModels {
export const schema = z.object({
data: z.array(
z.object({
model_picker_enabled: z.boolean(),
id: z.string(),
name: z.string(),
// every version looks like: `{model.id}-YYYY-MM-DD`
version: z.string(),
supported_endpoints: z.array(z.string()).optional(),
capabilities: z.object({
family: z.string(),
limits: z.object({
max_context_window_tokens: z.number(),
max_output_tokens: z.number(),
max_prompt_tokens: z.number(),
vision: z
.object({
max_prompt_image_size: z.number(),
max_prompt_images: z.number(),
supported_media_types: z.array(z.string()),
})
.optional(),
}),
supports: z.object({
adaptive_thinking: z.boolean().optional(),
max_thinking_budget: z.number().optional(),
min_thinking_budget: z.number().optional(),
reasoning_effort: z.array(z.string()).optional(),
streaming: z.boolean(),
structured_outputs: z.boolean().optional(),
tool_calls: z.boolean(),
vision: z.boolean().optional(),
}),
}),
}),
}),
),
})
type Item = z.infer<typeof schema>["data"][number]
function build(key: string, remote: Item, url: string, prev?: Model): Model {
const reasoning =
!!remote.capabilities.supports.adaptive_thinking ||
!!remote.capabilities.supports.reasoning_effort?.length ||
remote.capabilities.supports.max_thinking_budget !== undefined ||
remote.capabilities.supports.min_thinking_budget !== undefined
const image =
(remote.capabilities.supports.vision ?? false) ||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
const isMsgApi = remote.supported_endpoints?.includes("/v1/messages")
return {
id: key,
providerID: "github-copilot",
api: {
id: remote.id,
url: isMsgApi ? `${url}/v1` : url,
npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot",
},
// API response wins
status: "active",
limit: {
context: remote.capabilities.limits.max_context_window_tokens,
input: remote.capabilities.limits.max_prompt_tokens,
output: remote.capabilities.limits.max_output_tokens,
},
capabilities: {
temperature: prev?.capabilities.temperature ?? true,
reasoning: prev?.capabilities.reasoning ?? reasoning,
attachment: prev?.capabilities.attachment ?? true,
toolcall: remote.capabilities.supports.tool_calls,
input: {
text: true,
audio: false,
image,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
},
// existing wins
family: prev?.family ?? remote.capabilities.family,
name: prev?.name ?? remote.name,
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
options: prev?.options ?? {},
headers: prev?.headers ?? {},
release_date:
prev?.release_date ??
(remote.version.startsWith(`${remote.id}-`) ? remote.version.slice(remote.id.length + 1) : remote.version),
variants: prev?.variants ?? {},
}
}
export async function get(
baseURL: string,
headers: HeadersInit = {},
existing: Record<string, Model> = {},
): Promise<Record<string, Model>> {
const data = await fetch(`${baseURL}/models`, {
headers,
signal: AbortSignal.timeout(5_000),
}).then(async (res) => {
if (!res.ok) {
throw new Error(`Failed to fetch models: ${res.status}`)
}
return schema.parse(await res.json())
),
})
const result = { ...existing }
const remote = new Map(data.data.filter((m) => m.model_picker_enabled).map((m) => [m.id, m] as const))
type Item = z.infer<typeof schema>["data"][number]
// prune existing models whose api.id isn't in the endpoint response
for (const [key, model] of Object.entries(result)) {
const m = remote.get(model.api.id)
if (!m) {
delete result[key]
continue
function build(key: string, remote: Item, url: string, prev?: Model): Model {
const reasoning =
!!remote.capabilities.supports.adaptive_thinking ||
!!remote.capabilities.supports.reasoning_effort?.length ||
remote.capabilities.supports.max_thinking_budget !== undefined ||
remote.capabilities.supports.min_thinking_budget !== undefined
const image =
(remote.capabilities.supports.vision ?? false) ||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
const isMsgApi = remote.supported_endpoints?.includes("/v1/messages")
return {
id: key,
providerID: "github-copilot",
api: {
id: remote.id,
url: isMsgApi ? `${url}/v1` : url,
npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot",
},
// API response wins
status: "active",
limit: {
context: remote.capabilities.limits.max_context_window_tokens,
input: remote.capabilities.limits.max_prompt_tokens,
output: remote.capabilities.limits.max_output_tokens,
},
capabilities: {
temperature: prev?.capabilities.temperature ?? true,
reasoning: prev?.capabilities.reasoning ?? reasoning,
attachment: prev?.capabilities.attachment ?? true,
toolcall: remote.capabilities.supports.tool_calls,
input: {
text: true,
audio: false,
image,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
},
// existing wins
family: prev?.family ?? remote.capabilities.family,
name: prev?.name ?? remote.name,
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
options: prev?.options ?? {},
headers: prev?.headers ?? {},
release_date:
prev?.release_date ??
(remote.version.startsWith(`${remote.id}-`) ? remote.version.slice(remote.id.length + 1) : remote.version),
variants: prev?.variants ?? {},
}
result[key] = build(key, m, baseURL, model)
}
// add new endpoint models not already keyed in result
for (const [id, m] of remote) {
if (id in result) continue
result[id] = build(id, m, baseURL)
}
export async function get(
baseURL: string,
headers: HeadersInit = {},
existing: Record<string, Model> = {},
): Promise<Record<string, Model>> {
const data = await fetch(`${baseURL}/models`, {
headers,
signal: AbortSignal.timeout(5_000),
}).then(async (res) => {
if (!res.ok) {
throw new Error(`Failed to fetch models: ${res.status}`)
}
return schema.parse(await res.json())
})
return result
const result = { ...existing }
const remote = new Map(data.data.filter((m) => m.model_picker_enabled).map((m) => [m.id, m] as const))
// prune existing models whose api.id isn't in the endpoint response
for (const [key, model] of Object.entries(result)) {
const m = remote.get(model.api.id)
if (!m) {
delete result[key]
continue
}
result[key] = build(key, m, baseURL, model)
}
// add new endpoint models not already keyed in result
for (const [id, m] of remote) {
if (id in result) continue
result[id] = build(id, m, baseURL)
}
return result
}
}
export * as CopilotModels from "./models"
+149 -149
View File
@@ -8,181 +8,181 @@ import { Flock } from "@opencode-ai/shared/util/flock"
import { parsePluginSpecifier, pluginSource } from "./shared"
type Source = "file" | "npm"
export namespace PluginMeta {
type Source = "file" | "npm"
export type Theme = {
src: string
dest: string
mtime?: number
size?: number
}
export type Theme = {
src: string
dest: string
mtime?: number
size?: number
}
export type Entry = {
id: string
source: Source
spec: string
target: string
requested?: string
version?: string
modified?: number
first_time: number
last_time: number
time_changed: number
load_count: number
fingerprint: string
themes?: Record<string, Theme>
}
export type Entry = {
id: string
source: Source
spec: string
target: string
requested?: string
version?: string
modified?: number
first_time: number
last_time: number
time_changed: number
load_count: number
fingerprint: string
themes?: Record<string, Theme>
}
export type State = "first" | "updated" | "same"
export type State = "first" | "updated" | "same"
export type Touch = {
spec: string
target: string
id: string
}
export type Touch = {
spec: string
target: string
id: string
}
type Store = Record<string, Entry>
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint" | "themes">
type Row = Touch & { core: Core }
type Store = Record<string, Entry>
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint" | "themes">
type Row = Touch & { core: Core }
function storePath() {
return Flag.OPENCODE_PLUGIN_META_FILE ?? path.join(Global.Path.state, "plugin-meta.json")
}
function storePath() {
return Flag.OPENCODE_PLUGIN_META_FILE ?? path.join(Global.Path.state, "plugin-meta.json")
}
function lock(file: string) {
return `plugin-meta:${file}`
}
function lock(file: string) {
return `plugin-meta:${file}`
}
function fileTarget(spec: string, target: string) {
if (spec.startsWith("file://")) return fileURLToPath(spec)
if (target.startsWith("file://")) return fileURLToPath(target)
return
}
function fileTarget(spec: string, target: string) {
if (spec.startsWith("file://")) return fileURLToPath(spec)
if (target.startsWith("file://")) return fileURLToPath(target)
return
}
async function modifiedAt(file: string) {
const stat = await Filesystem.statAsync(file)
if (!stat) return
const mtime = stat.mtimeMs
return Math.floor(typeof mtime === "bigint" ? Number(mtime) : mtime)
}
async function modifiedAt(file: string) {
const stat = await Filesystem.statAsync(file)
if (!stat) return
const mtime = stat.mtimeMs
return Math.floor(typeof mtime === "bigint" ? Number(mtime) : mtime)
}
function resolvedTarget(target: string) {
if (target.startsWith("file://")) return fileURLToPath(target)
return target
}
function resolvedTarget(target: string) {
if (target.startsWith("file://")) return fileURLToPath(target)
return target
}
async function npmVersion(target: string) {
const resolved = resolvedTarget(target)
const stat = await Filesystem.statAsync(resolved)
const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
.then((item) => item.version)
.catch(() => undefined)
}
async function npmVersion(target: string) {
const resolved = resolvedTarget(target)
const stat = await Filesystem.statAsync(resolved)
const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
.then((item) => item.version)
.catch(() => undefined)
}
async function entryCore(item: Touch): Promise<Core> {
const spec = item.spec
const target = item.target
const source = pluginSource(spec)
if (source === "file") {
const file = fileTarget(spec, target)
return {
id: item.id,
source,
spec,
target,
modified: file ? await modifiedAt(file) : undefined,
}
}
async function entryCore(item: Touch): Promise<Core> {
const spec = item.spec
const target = item.target
const source = pluginSource(spec)
if (source === "file") {
const file = fileTarget(spec, target)
return {
id: item.id,
source,
spec,
target,
modified: file ? await modifiedAt(file) : undefined,
requested: parsePluginSpecifier(spec).version,
version: await npmVersion(target),
}
}
return {
id: item.id,
source,
spec,
target,
requested: parsePluginSpecifier(spec).version,
version: await npmVersion(target),
function fingerprint(value: Core) {
if (value.source === "file") return [value.target, value.modified ?? ""].join("|")
return [value.target, value.requested ?? "", value.version ?? ""].join("|")
}
}
function fingerprint(value: Core) {
if (value.source === "file") return [value.target, value.modified ?? ""].join("|")
return [value.target, value.requested ?? "", value.version ?? ""].join("|")
}
async function read(file: string): Promise<Store> {
return Filesystem.readJson<Store>(file).catch(() => ({}) as Store)
}
async function row(item: Touch): Promise<Row> {
return {
...item,
core: await entryCore(item),
async function read(file: string): Promise<Store> {
return Filesystem.readJson<Store>(file).catch(() => ({}) as Store)
}
}
function next(prev: Entry | undefined, core: Core, now: number): { state: State; entry: Entry } {
const entry: Entry = {
...core,
first_time: prev?.first_time ?? now,
last_time: now,
time_changed: prev?.time_changed ?? now,
load_count: (prev?.load_count ?? 0) + 1,
fingerprint: fingerprint(core),
themes: prev?.themes,
}
const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated"
if (state === "updated") entry.time_changed = now
return {
state,
entry,
}
}
export async function touchMany(items: Touch[]): Promise<Array<{ state: State; entry: Entry }>> {
if (!items.length) return []
const file = storePath()
const rows = await Promise.all(items.map((item) => row(item)))
return Flock.withLock(lock(file), async () => {
const store = await read(file)
const now = Date.now()
const out: Array<{ state: State; entry: Entry }> = []
for (const item of rows) {
const hit = next(store[item.id], item.core, now)
store[item.id] = hit.entry
out.push(hit)
async function row(item: Touch): Promise<Row> {
return {
...item,
core: await entryCore(item),
}
await Filesystem.writeJson(file, store)
return out
})
}
}
export async function touch(spec: string, target: string, id: string): Promise<{ state: State; entry: Entry }> {
return touchMany([{ spec, target, id }]).then((item) => {
const hit = item[0]
if (hit) return hit
throw new Error("Failed to touch plugin metadata.")
})
}
export async function setTheme(id: string, name: string, theme: Theme): Promise<void> {
const file = storePath()
await Flock.withLock(lock(file), async () => {
const store = await read(file)
const entry = store[id]
if (!entry) return
entry.themes = {
...entry.themes,
[name]: theme,
function next(prev: Entry | undefined, core: Core, now: number): { state: State; entry: Entry } {
const entry: Entry = {
...core,
first_time: prev?.first_time ?? now,
last_time: now,
time_changed: prev?.time_changed ?? now,
load_count: (prev?.load_count ?? 0) + 1,
fingerprint: fingerprint(core),
themes: prev?.themes,
}
await Filesystem.writeJson(file, store)
})
}
const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated"
if (state === "updated") entry.time_changed = now
return {
state,
entry,
}
}
export async function list(): Promise<Store> {
const file = storePath()
return Flock.withLock(lock(file), async () => read(file))
}
export async function touchMany(items: Touch[]): Promise<Array<{ state: State; entry: Entry }>> {
if (!items.length) return []
const file = storePath()
const rows = await Promise.all(items.map((item) => row(item)))
export * as PluginMeta from "./meta"
return Flock.withLock(lock(file), async () => {
const store = await read(file)
const now = Date.now()
const out: Array<{ state: State; entry: Entry }> = []
for (const item of rows) {
const hit = next(store[item.id], item.core, now)
store[item.id] = hit.entry
out.push(hit)
}
await Filesystem.writeJson(file, store)
return out
})
}
export async function touch(spec: string, target: string, id: string): Promise<{ state: State; entry: Entry }> {
return touchMany([{ spec, target, id }]).then((item) => {
const hit = item[0]
if (hit) return hit
throw new Error("Failed to touch plugin metadata.")
})
}
export async function setTheme(id: string, name: string, theme: Theme): Promise<void> {
const file = storePath()
await Flock.withLock(lock(file), async () => {
const store = await read(file)
const entry = store[id]
if (!entry) return
entry.themes = {
...entry.themes,
[name]: theme,
}
await Filesystem.writeJson(file, store)
})
}
export async function list(): Promise<Store> {
const file = storePath()
return Flock.withLock(lock(file), async () => read(file))
}
}
+2 -4
View File
@@ -547,14 +547,12 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
},
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
if (modelID.startsWith("duo-workflow-")) {
const workflowRef = typeof options?.workflowRef === "string" ? options.workflowRef : undefined
const workflowRef = options?.workflowRef as string | undefined
// Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef
const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow"
const workflowDefinition =
typeof options?.workflowDefinition === "string" ? options.workflowDefinition : undefined
const model = sdk.workflowChat(sdkModelID, {
featureFlags,
workflowDefinition,
workflowDefinition: options?.workflowDefinition as string | undefined,
})
if (workflowRef) {
model.selectedModelRef = workflowRef
@@ -798,7 +798,6 @@ export function options(input: {
if (input.model.api.npm === "@ai-sdk/azure") {
result["store"] = true
result["promptCacheKey"] = input.sessionID
}
if (input.model.api.npm === "@openrouter/ai-sdk-provider") {
+192 -192
View File
@@ -8,222 +8,222 @@ import { Log } from "@/util"
import { withStatics } from "@/util/schema"
import { QuestionID } from "./schema"
const log = Log.create({ service: "question" })
export namespace Question {
const log = Log.create({ service: "question" })
// Schemas
// Schemas
export class Option extends Schema.Class<Option>("QuestionOption")({
label: Schema.String.annotate({
description: "Display text (1-5 words, concise)",
}),
description: Schema.String.annotate({
description: "Explanation of choice",
}),
}) {
static readonly zod = zod(this)
}
const base = {
question: Schema.String.annotate({
description: "Complete question",
}),
header: Schema.String.annotate({
description: "Very short label (max 30 chars)",
}),
options: Schema.Array(Option).annotate({
description: "Available choices",
}),
multiple: Schema.optional(Schema.Boolean).annotate({
description: "Allow selecting multiple choices",
}),
}
export class Info extends Schema.Class<Info>("QuestionInfo")({
...base,
custom: Schema.optional(Schema.Boolean).annotate({
description: "Allow typing a custom answer (default: true)",
}),
}) {
static readonly zod = zod(this)
}
export class Prompt extends Schema.Class<Prompt>("QuestionPrompt")(base) {
static readonly zod = zod(this)
}
export class Tool extends Schema.Class<Tool>("QuestionTool")({
messageID: MessageID,
callID: Schema.String,
}) {
static readonly zod = zod(this)
}
export class Request extends Schema.Class<Request>("QuestionRequest")({
id: QuestionID,
sessionID: SessionID,
questions: Schema.Array(Info).annotate({
description: "Questions to ask",
}),
tool: Schema.optional(Tool),
}) {
static readonly zod = zod(this)
}
export const Answer = Schema.Array(Schema.String)
.annotate({ identifier: "QuestionAnswer" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Answer = Schema.Schema.Type<typeof Answer>
export class Reply extends Schema.Class<Reply>("QuestionReply")({
answers: Schema.Array(Answer).annotate({
description: "User answers in order of questions (each answer is an array of selected labels)",
}),
}) {
static readonly zod = zod(this)
}
class Replied extends Schema.Class<Replied>("QuestionReplied")({
sessionID: SessionID,
requestID: QuestionID,
answers: Schema.Array(Answer),
}) {}
class Rejected extends Schema.Class<Rejected>("QuestionRejected")({
sessionID: SessionID,
requestID: QuestionID,
}) {}
export const Event = {
Asked: BusEvent.define("question.asked", Request.zod),
Replied: BusEvent.define("question.replied", zod(Replied)),
Rejected: BusEvent.define("question.rejected", zod(Rejected)),
}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
override get message() {
return "The user dismissed this question"
export class Option extends Schema.Class<Option>("QuestionOption")({
label: Schema.String.annotate({
description: "Display text (1-5 words, concise)",
}),
description: Schema.String.annotate({
description: "Explanation of choice",
}),
}) {
static readonly zod = zod(this)
}
}
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<ReadonlyArray<Answer>, RejectedError>
}
const base = {
question: Schema.String.annotate({
description: "Complete question",
}),
header: Schema.String.annotate({
description: "Very short label (max 30 chars)",
}),
options: Schema.Array(Option).annotate({
description: "Available choices",
}),
multiple: Schema.optional(Schema.Boolean).annotate({
description: "Allow selecting multiple choices",
}),
}
interface State {
pending: Map<QuestionID, PendingEntry>
}
export class Info extends Schema.Class<Info>("QuestionInfo")({
...base,
custom: Schema.optional(Schema.Boolean).annotate({
description: "Allow typing a custom answer (default: true)",
}),
}) {
static readonly zod = zod(this)
}
// Service
export class Prompt extends Schema.Class<Prompt>("QuestionPrompt")(base) {
static readonly zod = zod(this)
}
export interface Interface {
readonly ask: (input: {
sessionID: SessionID
questions: ReadonlyArray<Info>
tool?: Tool
}) => Effect.Effect<ReadonlyArray<Answer>, RejectedError>
readonly reply: (input: { requestID: QuestionID; answers: ReadonlyArray<Answer> }) => Effect.Effect<void>
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
}
export class Tool extends Schema.Class<Tool>("QuestionTool")({
messageID: MessageID,
callID: Schema.String,
}) {
static readonly zod = zod(this)
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Question") {}
export class Request extends Schema.Class<Request>("QuestionRequest")({
id: QuestionID,
sessionID: SessionID,
questions: Schema.Array(Info).annotate({
description: "Questions to ask",
}),
tool: Schema.optional(Tool),
}) {
static readonly zod = zod(this)
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Question.state")(function* () {
const state = {
pending: new Map<QuestionID, PendingEntry>(),
}
export const Answer = Schema.Array(Schema.String)
.annotate({ identifier: "QuestionAnswer" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Answer = Schema.Schema.Type<typeof Answer>
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const item of state.pending.values()) {
yield* Deferred.fail(item.deferred, new RejectedError())
}
state.pending.clear()
}),
)
export class Reply extends Schema.Class<Reply>("QuestionReply")({
answers: Schema.Array(Answer).annotate({
description: "User answers in order of questions (each answer is an array of selected labels)",
}),
}) {
static readonly zod = zod(this)
}
return state
}),
)
class Replied extends Schema.Class<Replied>("QuestionReplied")({
sessionID: SessionID,
requestID: QuestionID,
answers: Schema.Array(Answer),
}) {}
const ask = Effect.fn("Question.ask")(function* (input: {
class Rejected extends Schema.Class<Rejected>("QuestionRejected")({
sessionID: SessionID,
requestID: QuestionID,
}) {}
export const Event = {
Asked: BusEvent.define("question.asked", Request.zod),
Replied: BusEvent.define("question.replied", zod(Replied)),
Rejected: BusEvent.define("question.rejected", zod(Rejected)),
}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
override get message() {
return "The user dismissed this question"
}
}
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<ReadonlyArray<Answer>, RejectedError>
}
interface State {
pending: Map<QuestionID, PendingEntry>
}
// Service
export interface Interface {
readonly ask: (input: {
sessionID: SessionID
questions: ReadonlyArray<Info>
tool?: Tool
}) {
const pending = (yield* InstanceState.get(state)).pending
const id = QuestionID.ascending()
log.info("asking", { id, questions: input.questions.length })
}) => Effect.Effect<ReadonlyArray<Answer>, RejectedError>
readonly reply: (input: { requestID: QuestionID; answers: ReadonlyArray<Answer> }) => Effect.Effect<void>
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
}
const deferred = yield* Deferred.make<ReadonlyArray<Answer>, RejectedError>()
const info = Schema.decodeUnknownSync(Request)({
id,
sessionID: input.sessionID,
questions: input.questions,
tool: input.tool,
})
pending.set(id, { info, deferred })
yield* bus.publish(Event.Asked, info)
export class Service extends Context.Service<Service, Interface>()("@opencode/Question") {}
return yield* Effect.ensuring(
Deferred.await(deferred),
Effect.sync(() => {
pending.delete(id)
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Question.state")(function* () {
const state = {
pending: new Map<QuestionID, PendingEntry>(),
}
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const item of state.pending.values()) {
yield* Deferred.fail(item.deferred, new RejectedError())
}
state.pending.clear()
}),
)
return state
}),
)
})
const reply = Effect.fn("Question.reply")(function* (input: {
requestID: QuestionID
answers: ReadonlyArray<Answer>
}) {
const pending = (yield* InstanceState.get(state)).pending
const existing = pending.get(input.requestID)
if (!existing) {
log.warn("reply for unknown request", { requestID: input.requestID })
return
}
pending.delete(input.requestID)
log.info("replied", { requestID: input.requestID, answers: input.answers })
yield* bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: input.answers,
const ask = Effect.fn("Question.ask")(function* (input: {
sessionID: SessionID
questions: ReadonlyArray<Info>
tool?: Tool
}) {
const pending = (yield* InstanceState.get(state)).pending
const id = QuestionID.ascending()
log.info("asking", { id, questions: input.questions.length })
const deferred = yield* Deferred.make<ReadonlyArray<Answer>, RejectedError>()
const info = Schema.decodeUnknownSync(Request)({
id,
sessionID: input.sessionID,
questions: input.questions,
tool: input.tool,
})
pending.set(id, { info, deferred })
yield* bus.publish(Event.Asked, info)
return yield* Effect.ensuring(
Deferred.await(deferred),
Effect.sync(() => {
pending.delete(id)
}),
)
})
yield* Deferred.succeed(existing.deferred, input.answers)
})
const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
const pending = (yield* InstanceState.get(state)).pending
const existing = pending.get(requestID)
if (!existing) {
log.warn("reject for unknown request", { requestID })
return
}
pending.delete(requestID)
log.info("rejected", { requestID })
yield* bus.publish(Event.Rejected, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
const reply = Effect.fn("Question.reply")(function* (input: {
requestID: QuestionID
answers: ReadonlyArray<Answer>
}) {
const pending = (yield* InstanceState.get(state)).pending
const existing = pending.get(input.requestID)
if (!existing) {
log.warn("reply for unknown request", { requestID: input.requestID })
return
}
pending.delete(input.requestID)
log.info("replied", { requestID: input.requestID, answers: input.answers })
yield* bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: input.answers,
})
yield* Deferred.succeed(existing.deferred, input.answers)
})
yield* Deferred.fail(existing.deferred, new RejectedError())
})
const list = Effect.fn("Question.list")(function* () {
const pending = (yield* InstanceState.get(state)).pending
return Array.from(pending.values(), (x) => x.info)
})
const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
const pending = (yield* InstanceState.get(state)).pending
const existing = pending.get(requestID)
if (!existing) {
log.warn("reject for unknown request", { requestID })
return
}
pending.delete(requestID)
log.info("rejected", { requestID })
yield* bus.publish(Event.Rejected, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
})
yield* Deferred.fail(existing.deferred, new RejectedError())
})
return Service.of({ ask, reply, reject, list })
}),
)
const list = Effect.fn("Question.list")(function* () {
const pending = (yield* InstanceState.get(state)).pending
return Array.from(pending.values(), (x) => x.info)
})
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
return Service.of({ ask, reply, reject, list })
}),
)
export * as Question from "."
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
}
@@ -10,8 +10,7 @@ import { MCP } from "../../mcp"
import { Session } from "../../session"
import { Config } from "../../config"
import { ConsoleState } from "../../config/console-state"
import { Account } from "../../account/account"
import { AccountID, OrgID } from "../../account/schema"
import { Account, AccountID, OrgID } from "../../account"
import { AppRuntime } from "../../effect/app-runtime"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -13,7 +13,6 @@ import { Filesystem } from "@/util"
import { PermissionApi, permissionHandlers } from "./permission"
import { ProviderApi, providerHandlers } from "./provider"
import { QuestionApi, questionHandlers } from "./question"
import { WorkspaceApi, workspaceHandlers } from "./workspace"
const Query = Schema.Struct({
directory: Schema.optional(Schema.String),
@@ -26,108 +25,106 @@ const Headers = Schema.Struct({
"x-opencode-directory": Schema.optional(Schema.String),
})
function decode(input: string) {
try {
return decodeURIComponent(input)
} catch {
return input
export namespace ExperimentalHttpApiServer {
function decode(input: string) {
try {
return decodeURIComponent(input)
} catch {
return input
}
}
}
class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
"Unauthorized",
{ message: Schema.String },
{ httpApiStatus: 401 },
) {}
class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
"Unauthorized",
{ message: Schema.String },
{ httpApiStatus: 401 },
) {}
class Authorization extends HttpApiMiddleware.Service<Authorization>()("@opencode/ExperimentalHttpApiAuthorization", {
error: Unauthorized,
security: {
basic: HttpApiSecurity.basic,
},
}) {}
class Authorization extends HttpApiMiddleware.Service<Authorization>()("@opencode/ExperimentalHttpApiAuthorization", {
error: Unauthorized,
security: {
basic: HttpApiSecurity.basic,
},
}) {}
const normalize = HttpRouter.middleware()(
Effect.gen(function* () {
return (effect) =>
Effect.gen(function* () {
const query = yield* HttpServerRequest.schemaSearchParams(Query)
if (!query.auth_token) return yield* effect
const req = yield* HttpServerRequest.HttpServerRequest
const next = req.modify({
headers: {
...req.headers,
authorization: `Basic ${query.auth_token}`,
},
const normalize = HttpRouter.middleware()(
Effect.gen(function* () {
return (effect) =>
Effect.gen(function* () {
const query = yield* HttpServerRequest.schemaSearchParams(Query)
if (!query.auth_token) return yield* effect
const req = yield* HttpServerRequest.HttpServerRequest
const next = req.modify({
headers: {
...req.headers,
authorization: `Basic ${query.auth_token}`,
},
})
return yield* effect.pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, next))
})
return yield* effect.pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, next))
})
}),
).layer
}),
).layer
const auth = Layer.succeed(
Authorization,
Authorization.of({
basic: (effect, { credential }) =>
Effect.gen(function* () {
if (!Flag.OPENCODE_SERVER_PASSWORD) return yield* effect
const auth = Layer.succeed(
Authorization,
Authorization.of({
basic: (effect, { credential }) =>
Effect.gen(function* () {
if (!Flag.OPENCODE_SERVER_PASSWORD) return yield* effect
const user = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
if (credential.username !== user) {
return yield* new Unauthorized({ message: "Unauthorized" })
}
if (Redacted.value(credential.password) !== Flag.OPENCODE_SERVER_PASSWORD) {
return yield* new Unauthorized({ message: "Unauthorized" })
}
return yield* effect
}),
}),
)
const user = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
if (credential.username !== user) {
return yield* new Unauthorized({ message: "Unauthorized" })
}
if (Redacted.value(credential.password) !== Flag.OPENCODE_SERVER_PASSWORD) {
return yield* new Unauthorized({ message: "Unauthorized" })
}
return yield* effect
}),
}),
)
const instance = HttpRouter.middleware()(
Effect.gen(function* () {
return (effect) =>
Effect.gen(function* () {
const query = yield* HttpServerRequest.schemaSearchParams(Query)
const headers = yield* HttpServerRequest.schemaHeaders(Headers)
const raw = query.directory || headers["x-opencode-directory"] || process.cwd()
const workspace = query.workspace || undefined
const ctx = yield* Effect.promise(() =>
Instance.provide({
directory: Filesystem.resolve(decode(raw)),
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: () => Instance.current,
}),
)
const instance = HttpRouter.middleware()(
Effect.gen(function* () {
return (effect) =>
Effect.gen(function* () {
const query = yield* HttpServerRequest.schemaSearchParams(Query)
const headers = yield* HttpServerRequest.schemaHeaders(Headers)
const raw = query.directory || headers["x-opencode-directory"] || process.cwd()
const workspace = query.workspace || undefined
const ctx = yield* Effect.promise(() =>
Instance.provide({
directory: Filesystem.resolve(decode(raw)),
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: () => Instance.current,
}),
)
const next = workspace ? effect.pipe(Effect.provideService(WorkspaceRef, workspace)) : effect
return yield* next.pipe(Effect.provideService(InstanceRef, ctx))
})
}),
).layer
const next = workspace ? effect.pipe(Effect.provideService(WorkspaceRef, workspace)) : effect
return yield* next.pipe(Effect.provideService(InstanceRef, ctx))
})
}),
).layer
const QuestionSecured = QuestionApi.middleware(Authorization)
const PermissionSecured = PermissionApi.middleware(Authorization)
const ProviderSecured = ProviderApi.middleware(Authorization)
const WorkspaceSecured = WorkspaceApi.middleware(Authorization)
const QuestionSecured = QuestionApi.middleware(Authorization)
const PermissionSecured = PermissionApi.middleware(Authorization)
const ProviderSecured = ProviderApi.middleware(Authorization)
export const routes = Layer.mergeAll(
HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(questionHandlers)),
HttpApiBuilder.layer(PermissionSecured).pipe(Layer.provide(permissionHandlers)),
HttpApiBuilder.layer(ProviderSecured).pipe(Layer.provide(providerHandlers)),
HttpApiBuilder.layer(WorkspaceSecured).pipe(Layer.provide(workspaceHandlers)),
).pipe(
Layer.provide(auth),
Layer.provide(normalize),
Layer.provide(instance),
Layer.provide(HttpServer.layerServices),
Layer.provideMerge(Observability.layer),
)
export const routes = Layer.mergeAll(
HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(questionHandlers)),
HttpApiBuilder.layer(PermissionSecured).pipe(Layer.provide(permissionHandlers)),
HttpApiBuilder.layer(ProviderSecured).pipe(Layer.provide(providerHandlers)),
).pipe(
Layer.provide(auth),
Layer.provide(normalize),
Layer.provide(instance),
Layer.provide(HttpServer.layerServices),
Layer.provideMerge(Observability.layer),
)
export const webHandler = lazy(() =>
HttpRouter.toWebHandler(routes, {
memoMap,
}),
)
export * as ExperimentalHttpApiServer from "./server"
export const webHandler = lazy(() =>
HttpRouter.toWebHandler(routes, {
memoMap,
}),
)
}
@@ -1,71 +0,0 @@
import { listAdaptors, WorkspaceAdaptorEntry } from "@/control-plane/adaptors"
import { Workspace } from "@/control-plane/workspace"
import { Instance } from "@/project/instance"
import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
const root = "/experimental/workspace"
export const WorkspaceApi = HttpApi.make("workspace")
.add(
HttpApiGroup.make("workspace")
.add(
HttpApiEndpoint.get("adaptors", `${root}/adaptor`, {
success: Schema.Array(WorkspaceAdaptorEntry),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.adaptor.list",
summary: "List workspace adaptors",
description: "List all available workspace adaptors for the current project.",
}),
),
HttpApiEndpoint.get("list", root, {
success: Schema.Array(Workspace.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.list",
summary: "List workspaces",
description: "List all workspaces.",
}),
),
HttpApiEndpoint.get("status", `${root}/status`, {
success: Schema.Array(Workspace.ConnectionStatus),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.status",
summary: "Workspace status",
description: "Get connection status for workspaces in the current project.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "workspace",
description: "Experimental HttpApi workspace routes.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () {
return yield* Effect.promise(() => listAdaptors(Instance.project.id))
})
const list = Effect.fn("WorkspaceHttpApi.list")(function* () {
return Workspace.list(Instance.project)
})
const status = Effect.fn("WorkspaceHttpApi.status")(function* () {
const ids = new Set(Workspace.list(Instance.project).map((item) => item.id))
return Workspace.status().filter((item) => ids.has(item.workspaceID))
})
export const workspaceHandlers = HttpApiBuilder.group(WorkspaceApi, "workspace", (handlers) =>
handlers.handle("adaptors", adaptors).handle("list", list).handle("status", status),
)
+7 -11
View File
@@ -1,7 +1,7 @@
import { describeRoute, resolver, validator } from "hono-openapi"
import { Hono } from "hono"
import type { UpgradeWebSocket } from "hono/ws"
import { Context, Effect } from "effect"
import { Effect } from "effect"
import z from "zod"
import { Format } from "../../format"
import { TuiRoutes } from "./tui"
@@ -41,16 +41,12 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
const handler = ExperimentalHttpApiServer.webHandler().handler
const context = Context.empty() as Context.Context<unknown>
app.get("/question", (c) => handler(c.req.raw, context))
app.post("/question/:requestID/reply", (c) => handler(c.req.raw, context))
app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context))
app.get("/permission", (c) => handler(c.req.raw, context))
app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context))
app.get("/experimental/workspace", (c) => handler(c.req.raw, context))
app.get("/experimental/workspace/adaptor", (c) => handler(c.req.raw, context))
app.get("/experimental/workspace/status", (c) => handler(c.req.raw, context))
app.get("/provider/auth", (c) => handler(c.req.raw, context))
app
.all("/question", (c) => handler(c.req.raw))
.all("/question/*", (c) => handler(c.req.raw))
.all("/permission", (c) => handler(c.req.raw))
.all("/permission/*", (c) => handler(c.req.raw))
.all("/provider/auth", (c) => handler(c.req.raw))
}
return app
@@ -2,10 +2,8 @@ import { Hono } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi"
import z from "zod"
import { listAdaptors } from "../../control-plane/adaptors"
import { WorkspaceID } from "../../control-plane/schema"
import { Workspace } from "../../control-plane/workspace"
import { Instance } from "../../project/instance"
import { WorkspaceAdaptorEntry } from "../../control-plane/adaptors"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { Log } from "@/util"
@@ -26,7 +24,15 @@ export const WorkspaceRoutes = lazy(() =>
description: "Workspace adaptors",
content: {
"application/json": {
schema: resolver(WorkspaceAdaptorEntry.zod.array()),
schema: resolver(
z.array(
z.object({
type: z.string(),
name: z.string(),
description: z.string(),
}),
),
),
},
},
},
@@ -47,7 +53,7 @@ export const WorkspaceRoutes = lazy(() =>
description: "Workspace created",
content: {
"application/json": {
schema: resolver(Workspace.Info.zod),
schema: resolver(Workspace.Info),
},
},
},
@@ -80,7 +86,7 @@ export const WorkspaceRoutes = lazy(() =>
description: "Workspaces",
content: {
"application/json": {
schema: resolver(Workspace.Info.zod.array()),
schema: resolver(z.array(Workspace.Info)),
},
},
},
@@ -101,7 +107,7 @@ export const WorkspaceRoutes = lazy(() =>
description: "Workspace status",
content: {
"application/json": {
schema: resolver(Workspace.ConnectionStatus.zod.array()),
schema: resolver(z.array(Workspace.ConnectionStatus)),
},
},
},
@@ -123,7 +129,7 @@ export const WorkspaceRoutes = lazy(() =>
description: "Workspace removed",
content: {
"application/json": {
schema: resolver(Workspace.Info.zod.optional()),
schema: resolver(Workspace.Info.optional()),
},
},
},
@@ -133,7 +139,7 @@ export const WorkspaceRoutes = lazy(() =>
validator(
"param",
z.object({
id: WorkspaceID.zod,
id: Workspace.Info.shape.id,
}),
),
async (c) => {
@@ -163,7 +169,7 @@ export const WorkspaceRoutes = lazy(() =>
...errors(400),
},
}),
validator("param", z.object({ id: WorkspaceID.zod })),
validator("param", z.object({ id: Workspace.Info.shape.id })),
validator("json", Workspace.sessionRestore.schema.omit({ workspaceID: true })),
async (c) => {
const { id } = c.req.valid("param")
+44 -44
View File
@@ -3,58 +3,58 @@ import { Bonjour } from "bonjour-service"
const log = Log.create({ service: "mdns" })
let bonjour: Bonjour | undefined
let currentPort: number | undefined
export namespace MDNS {
let bonjour: Bonjour | undefined
let currentPort: number | undefined
export function publish(port: number, domain?: string) {
if (currentPort === port) return
if (bonjour) unpublish()
export function publish(port: number, domain?: string) {
if (currentPort === port) return
if (bonjour) unpublish()
try {
const host = domain ?? "opencode.local"
const name = `opencode-${port}`
bonjour = new Bonjour()
const service = bonjour.publish({
name,
type: "http",
host,
port,
txt: { path: "/" },
})
try {
const host = domain ?? "opencode.local"
const name = `opencode-${port}`
bonjour = new Bonjour()
const service = bonjour.publish({
name,
type: "http",
host,
port,
txt: { path: "/" },
})
service.on("up", () => {
log.info("mDNS service published", { name, port })
})
service.on("up", () => {
log.info("mDNS service published", { name, port })
})
service.on("error", (err) => {
log.error("mDNS service error", { error: err })
})
service.on("error", (err) => {
log.error("mDNS service error", { error: err })
})
currentPort = port
} catch (err) {
log.error("mDNS publish failed", { error: err })
currentPort = port
} catch (err) {
log.error("mDNS publish failed", { error: err })
if (bonjour) {
try {
bonjour.destroy()
} catch {}
}
bonjour = undefined
currentPort = undefined
}
}
export function unpublish() {
if (bonjour) {
try {
bonjour.unpublishAll()
bonjour.destroy()
} catch {}
} catch (err) {
log.error("mDNS unpublish failed", { error: err })
}
bonjour = undefined
currentPort = undefined
log.info("mDNS service unpublished")
}
bonjour = undefined
currentPort = undefined
}
}
export function unpublish() {
if (bonjour) {
try {
bonjour.unpublishAll()
bonjour.destroy()
} catch (err) {
log.error("mDNS unpublish failed", { error: err })
}
bonjour = undefined
currentPort = undefined
log.info("mDNS service unpublished")
}
}
export * as MDNS from "./mdns"
+73 -68
View File
@@ -101,78 +101,83 @@ const app = (upgrade: UpgradeWebSocket) =>
}),
)
const log = Log.Default.clone().tag("service", "server-proxy")
export namespace ServerProxy {
const log = Log.Default.clone().tag("service", "server-proxy")
export async function http(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) {
if (!Workspace.isSyncing(workspaceID)) {
return new Response(`broken sync connection for workspace: ${workspaceID}`, {
status: 503,
headers: {
"content-type": "text/plain; charset=utf-8",
},
})
}
export async function http(
url: string | URL,
extra: HeadersInit | undefined,
req: Request,
workspaceID: WorkspaceID,
) {
if (!Workspace.isSyncing(workspaceID)) {
return new Response(`broken sync connection for workspace: ${workspaceID}`, {
status: 503,
headers: {
"content-type": "text/plain; charset=utf-8",
},
})
}
return fetch(
new Request(url, {
method: req.method,
headers: headers(req, extra),
body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body,
redirect: "manual",
signal: req.signal,
}),
).then((res) => {
const sync = Fence.parse(res.headers)
const next = new Headers(res.headers)
next.delete("content-encoding")
next.delete("content-length")
const done = sync ? Fence.wait(workspaceID, sync, req.signal) : Promise.resolve()
return done.then(async () => {
console.log("proxy http response", {
return fetch(
new Request(url, {
method: req.method,
request: req.url,
url: String(url),
status: res.status,
statusText: res.statusText,
})
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers: next,
headers: headers(req, extra),
body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body,
redirect: "manual",
signal: req.signal,
}),
).then((res) => {
const sync = Fence.parse(res.headers)
const next = new Headers(res.headers)
next.delete("content-encoding")
next.delete("content-length")
const done = sync ? Fence.wait(workspaceID, sync, req.signal) : Promise.resolve()
return done.then(async () => {
console.log("proxy http response", {
method: req.method,
request: req.url,
url: String(url),
status: res.status,
statusText: res.statusText,
})
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers: next,
})
})
})
})
}
export function websocket(
upgrade: UpgradeWebSocket,
target: string | URL,
extra: HeadersInit | undefined,
req: Request,
env: unknown,
) {
const proxy = new URL(req.url)
proxy.pathname = "/__workspace_ws"
proxy.search = ""
const next = new Headers(req.headers)
next.set("x-opencode-proxy-url", socket(target))
for (const [key, value] of new Headers(extra).entries()) {
next.set(key, value)
}
log.info("proxy websocket", {
request: req.url,
target: String(target),
})
return app(upgrade).fetch(
new Request(proxy, {
method: req.method,
headers: next,
signal: req.signal,
}),
env as never,
)
}
export * as ServerProxy from "./proxy"
export function websocket(
upgrade: UpgradeWebSocket,
target: string | URL,
extra: HeadersInit | undefined,
req: Request,
env: unknown,
) {
const proxy = new URL(req.url)
proxy.pathname = "/__workspace_ws"
proxy.search = ""
const next = new Headers(req.headers)
next.set("x-opencode-proxy-url", socket(target))
for (const [key, value] of new Headers(extra).entries()) {
next.set(key, value)
}
log.info("proxy websocket", {
request: req.url,
target: String(target),
})
return app(upgrade).fetch(
new Request(proxy, {
method: req.method,
headers: next,
signal: req.signal,
}),
env as never,
)
}
}
+87 -87
View File
@@ -17,22 +17,37 @@ globalThis.AI_SDK_LOG_WARNINGS = false
initProjectors()
const log = Log.create({ service: "server" })
export namespace Server {
const log = Log.create({ service: "server" })
export type Listener = {
hostname: string
port: number
url: URL
stop: (close?: boolean) => Promise<void>
}
export type Listener = {
hostname: string
port: number
url: URL
stop: (close?: boolean) => Promise<void>
}
export const Default = lazy(() => create({}))
export const Default = lazy(() => create({}))
function create(opts: { cors?: string[] }) {
const app = new Hono()
const runtime = adapter.create(app)
function create(opts: { cors?: string[] }) {
const app = new Hono()
const runtime = adapter.create(app)
if (Flag.OPENCODE_WORKSPACE_ID) {
return {
app: app
.onError(ErrorMiddleware)
.use(AuthMiddleware)
.use(LoggerMiddleware)
.use(CompressionMiddleware)
.use(CorsMiddleware(opts))
.use(FenceMiddleware)
.route("/", ControlPlaneRoutes())
.route("/", InstanceRoutes(runtime.upgradeWebSocket)),
runtime,
}
}
if (Flag.OPENCODE_WORKSPACE_ID) {
return {
app: app
.onError(ErrorMiddleware)
@@ -40,88 +55,73 @@ function create(opts: { cors?: string[] }) {
.use(LoggerMiddleware)
.use(CompressionMiddleware)
.use(CorsMiddleware(opts))
.use(FenceMiddleware)
.route("/", ControlPlaneRoutes())
.route("/", InstanceRoutes(runtime.upgradeWebSocket)),
.route("/", InstanceRoutes(runtime.upgradeWebSocket))
.route("/", UIRoutes()),
runtime,
}
}
return {
app: app
.onError(ErrorMiddleware)
.use(AuthMiddleware)
.use(LoggerMiddleware)
.use(CompressionMiddleware)
.use(CorsMiddleware(opts))
.route("/", ControlPlaneRoutes())
.route("/", InstanceRoutes(runtime.upgradeWebSocket))
.route("/", UIRoutes()),
runtime,
}
}
export async function openapi() {
// Build a fresh app with all routes registered directly so
// hono-openapi can see describeRoute metadata (`.route()` wraps
// handlers when the sub-app has a custom errorHandler, which
// strips the metadata symbol).
const { app } = create({})
const result = await generateSpecs(app, {
documentation: {
info: {
title: "opencode",
version: "1.0.0",
description: "opencode api",
export async function openapi() {
// Build a fresh app with all routes registered directly so
// hono-openapi can see describeRoute metadata (`.route()` wraps
// handlers when the sub-app has a custom errorHandler, which
// strips the metadata symbol).
const { app } = create({})
const result = await generateSpecs(app, {
documentation: {
info: {
title: "opencode",
version: "1.0.0",
description: "opencode api",
},
openapi: "3.1.1",
},
openapi: "3.1.1",
},
})
return result
}
export let url: URL
export async function listen(opts: {
port: number
hostname: string
mdns?: boolean
mdnsDomain?: string
cors?: string[]
}): Promise<Listener> {
const built = create(opts)
const server = await built.runtime.listen(opts)
const next = new URL("http://localhost")
next.hostname = opts.hostname
next.port = String(server.port)
url = next
const mdns =
opts.mdns &&
server.port &&
opts.hostname !== "127.0.0.1" &&
opts.hostname !== "localhost" &&
opts.hostname !== "::1"
if (mdns) {
MDNS.publish(server.port, opts.mdnsDomain)
} else if (opts.mdns) {
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
})
return result
}
let closing: Promise<void> | undefined
return {
hostname: opts.hostname,
port: server.port,
url: next,
stop(close?: boolean) {
closing ??= (async () => {
if (mdns) MDNS.unpublish()
await server.stop(close)
})()
return closing
},
export let url: URL
export async function listen(opts: {
port: number
hostname: string
mdns?: boolean
mdnsDomain?: string
cors?: string[]
}): Promise<Listener> {
const built = create(opts)
const server = await built.runtime.listen(opts)
const next = new URL("http://localhost")
next.hostname = opts.hostname
next.port = String(server.port)
url = next
const mdns =
opts.mdns &&
server.port &&
opts.hostname !== "127.0.0.1" &&
opts.hostname !== "localhost" &&
opts.hostname !== "::1"
if (mdns) {
MDNS.publish(server.port, opts.mdnsDomain)
} else if (opts.mdns) {
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
}
let closing: Promise<void> | undefined
return {
hostname: opts.hostname,
port: server.port,
url: next,
stop(close?: boolean) {
closing ??= (async () => {
if (mdns) MDNS.unpublish()
await server.stop(close)
})()
return closing
},
}
}
}
export * as Server from "./server"
+334 -334
View File
@@ -17,172 +17,173 @@ import { Effect, Layer, Context } from "effect"
import { InstanceState } from "@/effect"
import { isOverflow as overflow } from "./overflow"
const log = Log.create({ service: "session.compaction" })
export namespace SessionCompaction {
const log = Log.create({ service: "session.compaction" })
export const Event = {
Compacted: BusEvent.define(
"session.compacted",
z.object({
sessionID: SessionID.zod,
}),
),
}
export const Event = {
Compacted: BusEvent.define(
"session.compacted",
z.object({
sessionID: SessionID.zod,
}),
),
}
export const PRUNE_MINIMUM = 20_000
export const PRUNE_PROTECT = 40_000
const PRUNE_PROTECTED_TOOLS = ["skill"]
export const PRUNE_MINIMUM = 20_000
export const PRUNE_PROTECT = 40_000
const PRUNE_PROTECTED_TOOLS = ["skill"]
export interface Interface {
readonly isOverflow: (input: {
tokens: MessageV2.Assistant["tokens"]
model: Provider.Model
}) => Effect.Effect<boolean>
readonly prune: (input: { sessionID: SessionID }) => Effect.Effect<void>
readonly process: (input: {
parentID: MessageID
messages: MessageV2.WithParts[]
sessionID: SessionID
auto: boolean
overflow?: boolean
}) => Effect.Effect<"continue" | "stop">
readonly create: (input: {
sessionID: SessionID
agent: string
model: { providerID: ProviderID; modelID: ModelID }
auto: boolean
overflow?: boolean
}) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionCompaction") {}
export const layer: Layer.Layer<
Service,
never,
| Bus.Service
| Config.Service
| Session.Service
| Agent.Service
| Plugin.Service
| SessionProcessor.Service
| Provider.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const config = yield* Config.Service
const session = yield* Session.Service
const agents = yield* Agent.Service
const plugin = yield* Plugin.Service
const processors = yield* SessionProcessor.Service
const provider = yield* Provider.Service
const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: {
export interface Interface {
readonly isOverflow: (input: {
tokens: MessageV2.Assistant["tokens"]
model: Provider.Model
}) {
return overflow({ cfg: yield* config.get(), tokens: input.tokens, model: input.model })
})
// goes backwards through parts until there are PRUNE_PROTECT tokens worth of tool
// calls, then erases output of older tool calls to free context space
const prune = Effect.fn("SessionCompaction.prune")(function* (input: { sessionID: SessionID }) {
const cfg = yield* config.get()
if (cfg.compaction?.prune === false) return
log.info("pruning")
const msgs = yield* session
.messages({ sessionID: input.sessionID })
.pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(undefined)))
if (!msgs) return
let total = 0
let pruned = 0
const toPrune: MessageV2.ToolPart[] = []
let turns = 0
loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
const msg = msgs[msgIndex]
if (msg.info.role === "user") turns++
if (turns < 2) continue
if (msg.info.role === "assistant" && msg.info.summary) break loop
for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
const part = msg.parts[partIndex]
if (part.type === "tool")
if (part.state.status === "completed") {
if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
if (part.state.time.compacted) break loop
const estimate = Token.estimate(part.state.output)
total += estimate
if (total > PRUNE_PROTECT) {
pruned += estimate
toPrune.push(part)
}
}
}
}
log.info("found", { pruned, total })
if (pruned > PRUNE_MINIMUM) {
for (const part of toPrune) {
if (part.state.status === "completed") {
part.state.time.compacted = Date.now()
yield* session.updatePart(part)
}
}
log.info("pruned", { count: toPrune.length })
}
})
const processCompaction = Effect.fn("SessionCompaction.process")(function* (input: {
}) => Effect.Effect<boolean>
readonly prune: (input: { sessionID: SessionID }) => Effect.Effect<void>
readonly process: (input: {
parentID: MessageID
messages: MessageV2.WithParts[]
sessionID: SessionID
auto: boolean
overflow?: boolean
}) {
const parent = input.messages.findLast((m) => m.info.id === input.parentID)
if (!parent || parent.info.role !== "user") {
throw new Error(`Compaction parent must be a user message: ${input.parentID}`)
}
const userMessage = parent.info
}) => Effect.Effect<"continue" | "stop">
readonly create: (input: {
sessionID: SessionID
agent: string
model: { providerID: ProviderID; modelID: ModelID }
auto: boolean
overflow?: boolean
}) => Effect.Effect<void>
}
let messages = input.messages
let replay:
| {
info: MessageV2.User
parts: MessageV2.Part[]
}
| undefined
if (input.overflow) {
const idx = input.messages.findIndex((m) => m.info.id === input.parentID)
for (let i = idx - 1; i >= 0; i--) {
const msg = input.messages[i]
if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) {
replay = { info: msg.info, parts: msg.parts }
messages = input.messages.slice(0, i)
break
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionCompaction") {}
export const layer: Layer.Layer<
Service,
never,
| Bus.Service
| Config.Service
| Session.Service
| Agent.Service
| Plugin.Service
| SessionProcessor.Service
| Provider.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const config = yield* Config.Service
const session = yield* Session.Service
const agents = yield* Agent.Service
const plugin = yield* Plugin.Service
const processors = yield* SessionProcessor.Service
const provider = yield* Provider.Service
const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: {
tokens: MessageV2.Assistant["tokens"]
model: Provider.Model
}) {
return overflow({ cfg: yield* config.get(), tokens: input.tokens, model: input.model })
})
// goes backwards through parts until there are PRUNE_PROTECT tokens worth of tool
// calls, then erases output of older tool calls to free context space
const prune = Effect.fn("SessionCompaction.prune")(function* (input: { sessionID: SessionID }) {
const cfg = yield* config.get()
if (cfg.compaction?.prune === false) return
log.info("pruning")
const msgs = yield* session
.messages({ sessionID: input.sessionID })
.pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(undefined)))
if (!msgs) return
let total = 0
let pruned = 0
const toPrune: MessageV2.ToolPart[] = []
let turns = 0
loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
const msg = msgs[msgIndex]
if (msg.info.role === "user") turns++
if (turns < 2) continue
if (msg.info.role === "assistant" && msg.info.summary) break loop
for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
const part = msg.parts[partIndex]
if (part.type === "tool")
if (part.state.status === "completed") {
if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
if (part.state.time.compacted) break loop
const estimate = Token.estimate(part.state.output)
total += estimate
if (total > PRUNE_PROTECT) {
pruned += estimate
toPrune.push(part)
}
}
}
}
const hasContent =
replay && messages.some((m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction"))
if (!hasContent) {
replay = undefined
messages = input.messages
}
}
const agent = yield* agents.get("compaction")
const model = agent.model
? yield* provider.getModel(agent.model.providerID, agent.model.modelID)
: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
// Allow plugins to inject context or replace compaction prompt.
const compacting = yield* plugin.trigger(
"experimental.session.compacting",
{ sessionID: input.sessionID },
{ context: [], prompt: undefined },
)
const defaultPrompt = `Provide a detailed prompt for continuing our conversation above.
log.info("found", { pruned, total })
if (pruned > PRUNE_MINIMUM) {
for (const part of toPrune) {
if (part.state.status === "completed") {
part.state.time.compacted = Date.now()
yield* session.updatePart(part)
}
}
log.info("pruned", { count: toPrune.length })
}
})
const processCompaction = Effect.fn("SessionCompaction.process")(function* (input: {
parentID: MessageID
messages: MessageV2.WithParts[]
sessionID: SessionID
auto: boolean
overflow?: boolean
}) {
const parent = input.messages.findLast((m) => m.info.id === input.parentID)
if (!parent || parent.info.role !== "user") {
throw new Error(`Compaction parent must be a user message: ${input.parentID}`)
}
const userMessage = parent.info
let messages = input.messages
let replay:
| {
info: MessageV2.User
parts: MessageV2.Part[]
}
| undefined
if (input.overflow) {
const idx = input.messages.findIndex((m) => m.info.id === input.parentID)
for (let i = idx - 1; i >= 0; i--) {
const msg = input.messages[i]
if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) {
replay = { info: msg.info, parts: msg.parts }
messages = input.messages.slice(0, i)
break
}
}
const hasContent =
replay && messages.some((m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction"))
if (!hasContent) {
replay = undefined
messages = input.messages
}
}
const agent = yield* agents.get("compaction")
const model = agent.model
? yield* provider.getModel(agent.model.providerID, agent.model.modelID)
: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
// Allow plugins to inject context or replace compaction prompt.
const compacting = yield* plugin.trigger(
"experimental.session.compacting",
{ sessionID: input.sessionID },
{ context: [], prompt: undefined },
)
const defaultPrompt = `Provide a detailed prompt for continuing our conversation above.
Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.
The summary that you construct will be used so that another agent can read it and continue the work.
Do not call any tools. Respond only with the summary text.
@@ -212,201 +213,200 @@ When constructing the summary, try to stick to this template:
[Construct a structured list of relevant files that have been read, edited, or created that pertain to the task at hand. If all the files in a directory are relevant, include the path to the directory.]
---`
const prompt = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
const msgs = structuredClone(messages)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true })
const ctx = yield* InstanceState.context
const msg: MessageV2.Assistant = {
id: MessageID.ascending(),
role: "assistant",
parentID: input.parentID,
sessionID: input.sessionID,
mode: "compaction",
agent: "compaction",
variant: userMessage.model.variant,
summary: true,
path: {
cwd: ctx.directory,
root: ctx.worktree,
},
cost: 0,
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: model.id,
providerID: model.providerID,
time: {
created: Date.now(),
},
}
yield* session.updateMessage(msg)
const processor = yield* processors.create({
assistantMessage: msg,
sessionID: input.sessionID,
model,
})
const result = yield* processor.process({
user: userMessage,
agent,
sessionID: input.sessionID,
tools: {},
system: [],
messages: [
...modelMessages,
{
role: "user",
content: [{ type: "text", text: prompt }],
const prompt = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
const msgs = structuredClone(messages)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true })
const ctx = yield* InstanceState.context
const msg: MessageV2.Assistant = {
id: MessageID.ascending(),
role: "assistant",
parentID: input.parentID,
sessionID: input.sessionID,
mode: "compaction",
agent: "compaction",
variant: userMessage.model.variant,
summary: true,
path: {
cwd: ctx.directory,
root: ctx.worktree,
},
],
model,
})
cost: 0,
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: model.id,
providerID: model.providerID,
time: {
created: Date.now(),
},
}
yield* session.updateMessage(msg)
const processor = yield* processors.create({
assistantMessage: msg,
sessionID: input.sessionID,
model,
})
const result = yield* processor.process({
user: userMessage,
agent,
sessionID: input.sessionID,
tools: {},
system: [],
messages: [
...modelMessages,
{
role: "user",
content: [{ type: "text", text: prompt }],
},
],
model,
})
if (result === "compact") {
processor.message.error = new MessageV2.ContextOverflowError({
message: replay
? "Conversation history too large to compact - exceeds model context limit"
: "Session too large to compact - context exceeds model limit even after stripping media",
}).toObject()
processor.message.finish = "error"
yield* session.updateMessage(processor.message)
return "stop"
}
if (result === "continue" && input.auto) {
if (replay) {
const original = replay.info
const replayMsg = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: input.sessionID,
time: { created: Date.now() },
agent: original.agent,
model: original.model,
format: original.format,
tools: original.tools,
system: original.system,
})
for (const part of replay.parts) {
if (part.type === "compaction") continue
const replayPart =
part.type === "file" && MessageV2.isMedia(part.mime)
? { type: "text" as const, text: `[Attached ${part.mime}: ${part.filename ?? "file"}]` }
: part
yield* session.updatePart({
...replayPart,
id: PartID.ascending(),
messageID: replayMsg.id,
sessionID: input.sessionID,
})
}
if (result === "compact") {
processor.message.error = new MessageV2.ContextOverflowError({
message: replay
? "Conversation history too large to compact - exceeds model context limit"
: "Session too large to compact - context exceeds model limit even after stripping media",
}).toObject()
processor.message.finish = "error"
yield* session.updateMessage(processor.message)
return "stop"
}
if (!replay) {
const info = yield* provider.getProvider(userMessage.model.providerID)
if (
(yield* plugin.trigger(
"experimental.compaction.autocontinue",
{
sessionID: input.sessionID,
agent: userMessage.agent,
model: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID),
provider: {
source: info.source,
info,
options: info.options,
},
message: userMessage,
overflow: input.overflow === true,
},
{ enabled: true },
)).enabled
) {
const continueMsg = yield* session.updateMessage({
if (result === "continue" && input.auto) {
if (replay) {
const original = replay.info
const replayMsg = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: input.sessionID,
time: { created: Date.now() },
agent: userMessage.agent,
model: userMessage.model,
})
const text =
(input.overflow
? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
: "") +
"Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
yield* session.updatePart({
id: PartID.ascending(),
messageID: continueMsg.id,
sessionID: input.sessionID,
type: "text",
// Internal marker for auto-compaction followups so provider plugins
// can distinguish them from manual post-compaction user prompts.
// This is not a stable plugin contract and may change or disappear.
metadata: { compaction_continue: true },
synthetic: true,
text,
time: {
start: Date.now(),
end: Date.now(),
},
agent: original.agent,
model: original.model,
format: original.format,
tools: original.tools,
system: original.system,
})
for (const part of replay.parts) {
if (part.type === "compaction") continue
const replayPart =
part.type === "file" && MessageV2.isMedia(part.mime)
? { type: "text" as const, text: `[Attached ${part.mime}: ${part.filename ?? "file"}]` }
: part
yield* session.updatePart({
...replayPart,
id: PartID.ascending(),
messageID: replayMsg.id,
sessionID: input.sessionID,
})
}
}
if (!replay) {
const info = yield* provider.getProvider(userMessage.model.providerID)
if (
(yield* plugin.trigger(
"experimental.compaction.autocontinue",
{
sessionID: input.sessionID,
agent: userMessage.agent,
model: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID),
provider: {
source: info.source,
info,
options: info.options,
},
message: userMessage,
overflow: input.overflow === true,
},
{ enabled: true },
)).enabled
) {
const continueMsg = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: input.sessionID,
time: { created: Date.now() },
agent: userMessage.agent,
model: userMessage.model,
})
const text =
(input.overflow
? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
: "") +
"Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
yield* session.updatePart({
id: PartID.ascending(),
messageID: continueMsg.id,
sessionID: input.sessionID,
type: "text",
// Internal marker for auto-compaction followups so provider plugins
// can distinguish them from manual post-compaction user prompts.
// This is not a stable plugin contract and may change or disappear.
metadata: { compaction_continue: true },
synthetic: true,
text,
time: {
start: Date.now(),
end: Date.now(),
},
})
}
}
}
}
if (processor.message.error) return "stop"
if (result === "continue") yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
return result
})
const create = Effect.fn("SessionCompaction.create")(function* (input: {
sessionID: SessionID
agent: string
model: { providerID: ProviderID; modelID: ModelID }
auto: boolean
overflow?: boolean
}) {
const msg = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
model: input.model,
sessionID: input.sessionID,
agent: input.agent,
time: { created: Date.now() },
if (processor.message.error) return "stop"
if (result === "continue") yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
return result
})
yield* session.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID: msg.sessionID,
type: "compaction",
auto: input.auto,
overflow: input.overflow,
const create = Effect.fn("SessionCompaction.create")(function* (input: {
sessionID: SessionID
agent: string
model: { providerID: ProviderID; modelID: ModelID }
auto: boolean
overflow?: boolean
}) {
const msg = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
model: input.model,
sessionID: input.sessionID,
agent: input.agent,
time: { created: Date.now() },
})
yield* session.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID: msg.sessionID,
type: "compaction",
auto: input.auto,
overflow: input.overflow,
})
})
})
return Service.of({
isOverflow,
prune,
process: processCompaction,
create,
})
}),
)
return Service.of({
isOverflow,
prune,
process: processCompaction,
create,
})
}),
)
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(Provider.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(SessionProcessor.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(Config.defaultLayer),
),
)
export * as SessionCompaction from "./compaction"
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(Provider.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(SessionProcessor.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(Config.defaultLayer),
),
)
}
+163 -163
View File
@@ -50,194 +50,194 @@ function extract(messages: MessageV2.WithParts[]) {
return paths
}
export interface Interface {
readonly clear: (messageID: MessageID) => Effect.Effect<void>
readonly systemPaths: () => Effect.Effect<Set<string>, AppFileSystem.Error>
readonly system: () => Effect.Effect<string[], AppFileSystem.Error>
readonly find: (dir: string) => Effect.Effect<string | undefined, AppFileSystem.Error>
readonly resolve: (
messages: MessageV2.WithParts[],
filepath: string,
messageID: MessageID,
) => Effect.Effect<{ filepath: string; content: string }[], AppFileSystem.Error>
}
export namespace Instruction {
export interface Interface {
readonly clear: (messageID: MessageID) => Effect.Effect<void>
readonly systemPaths: () => Effect.Effect<Set<string>, AppFileSystem.Error>
readonly system: () => Effect.Effect<string[], AppFileSystem.Error>
readonly find: (dir: string) => Effect.Effect<string | undefined, AppFileSystem.Error>
readonly resolve: (
messages: MessageV2.WithParts[],
filepath: string,
messageID: MessageID,
) => Effect.Effect<{ filepath: string; content: string }[], AppFileSystem.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Instruction") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Instruction") {}
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.Service | HttpClient.HttpClient> =
Layer.effect(
Service,
Effect.gen(function* () {
const cfg = yield* Config.Service
const fs = yield* AppFileSystem.Service
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.Service | HttpClient.HttpClient> =
Layer.effect(
Service,
Effect.gen(function* () {
const cfg = yield* Config.Service
const fs = yield* AppFileSystem.Service
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const state = yield* InstanceState.make(
Effect.fn("Instruction.state")(() =>
Effect.succeed({
// Track which instruction files have already been attached for a given assistant message.
claims: new Map<MessageID, Set<string>>(),
}),
),
)
const relative = Effect.fnUntraced(function* (instruction: string) {
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
return yield* fs
.globUp(instruction, Instance.directory, Instance.worktree)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
}
if (!Flag.OPENCODE_CONFIG_DIR) {
log.warn(
`Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
)
return []
}
return yield* fs
.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
})
const read = Effect.fnUntraced(function* (filepath: string) {
return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed("")))
})
const fetch = Effect.fnUntraced(function* (url: string) {
const res = yield* http.execute(HttpClientRequest.get(url)).pipe(
Effect.timeout(5000),
Effect.catch(() => Effect.succeed(null)),
const state = yield* InstanceState.make(
Effect.fn("Instruction.state")(() =>
Effect.succeed({
// Track which instruction files have already been attached for a given assistant message.
claims: new Map<MessageID, Set<string>>(),
}),
),
)
if (!res) return ""
const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0))))
return new TextDecoder().decode(body)
})
const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) {
const s = yield* InstanceState.get(state)
s.claims.delete(messageID)
})
const relative = Effect.fnUntraced(function* (instruction: string) {
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
return yield* fs
.globUp(instruction, Instance.directory, Instance.worktree)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
}
if (!Flag.OPENCODE_CONFIG_DIR) {
log.warn(
`Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
)
return []
}
return yield* fs
.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
})
const systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
const config = yield* cfg.get()
const paths = new Set<string>()
const read = Effect.fnUntraced(function* (filepath: string) {
return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed("")))
})
// The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of FILES) {
const matches = yield* fs.findUp(file, Instance.directory, Instance.worktree)
if (matches.length > 0) {
matches.forEach((item) => paths.add(path.resolve(item)))
const fetch = Effect.fnUntraced(function* (url: string) {
const res = yield* http.execute(HttpClientRequest.get(url)).pipe(
Effect.timeout(5000),
Effect.catch(() => Effect.succeed(null)),
)
if (!res) return ""
const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0))))
return new TextDecoder().decode(body)
})
const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) {
const s = yield* InstanceState.get(state)
s.claims.delete(messageID)
})
const systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
const config = yield* cfg.get()
const paths = new Set<string>()
// The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of FILES) {
const matches = yield* fs.findUp(file, Instance.directory, Instance.worktree)
if (matches.length > 0) {
matches.forEach((item) => paths.add(path.resolve(item)))
break
}
}
}
for (const file of globalFiles()) {
if (yield* fs.existsSafe(file)) {
paths.add(path.resolve(file))
break
}
}
}
for (const file of globalFiles()) {
if (yield* fs.existsSafe(file)) {
paths.add(path.resolve(file))
break
if (config.instructions) {
for (const raw of config.instructions) {
if (raw.startsWith("https://") || raw.startsWith("http://")) continue
const instruction = raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw
const matches = yield* (
path.isAbsolute(instruction)
? fs.glob(path.basename(instruction), {
cwd: path.dirname(instruction),
absolute: true,
include: "file",
})
: relative(instruction)
).pipe(Effect.catch(() => Effect.succeed([] as string[])))
matches.forEach((item) => paths.add(path.resolve(item)))
}
}
}
if (config.instructions) {
for (const raw of config.instructions) {
if (raw.startsWith("https://") || raw.startsWith("http://")) continue
const instruction = raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw
const matches = yield* (
path.isAbsolute(instruction)
? fs.glob(path.basename(instruction), {
cwd: path.dirname(instruction),
absolute: true,
include: "file",
})
: relative(instruction)
).pipe(Effect.catch(() => Effect.succeed([] as string[])))
matches.forEach((item) => paths.add(path.resolve(item)))
return paths
})
const system = Effect.fn("Instruction.system")(function* () {
const config = yield* cfg.get()
const paths = yield* systemPaths()
const urls = (config.instructions ?? []).filter(
(item) => item.startsWith("https://") || item.startsWith("http://"),
)
const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 })
const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 })
return [
...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])),
...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])),
]
})
const find = Effect.fn("Instruction.find")(function* (dir: string) {
for (const file of FILES) {
const filepath = path.resolve(path.join(dir, file))
if (yield* fs.existsSafe(filepath)) return filepath
}
}
})
return paths
})
const resolve = Effect.fn("Instruction.resolve")(function* (
messages: MessageV2.WithParts[],
filepath: string,
messageID: MessageID,
) {
const sys = yield* systemPaths()
const already = extract(messages)
const results: { filepath: string; content: string }[] = []
const s = yield* InstanceState.get(state)
const system = Effect.fn("Instruction.system")(function* () {
const config = yield* cfg.get()
const paths = yield* systemPaths()
const urls = (config.instructions ?? []).filter(
(item) => item.startsWith("https://") || item.startsWith("http://"),
)
const target = path.resolve(filepath)
const root = path.resolve(Instance.directory)
let current = path.dirname(target)
const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 })
const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 })
// Walk upward from the file being read and attach nearby instruction files once per message.
while (current.startsWith(root) && current !== root) {
const found = yield* find(current)
if (!found || found === target || sys.has(found) || already.has(found)) {
current = path.dirname(current)
continue
}
return [
...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])),
...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])),
]
})
let set = s.claims.get(messageID)
if (!set) {
set = new Set()
s.claims.set(messageID, set)
}
if (set.has(found)) {
current = path.dirname(current)
continue
}
const find = Effect.fn("Instruction.find")(function* (dir: string) {
for (const file of FILES) {
const filepath = path.resolve(path.join(dir, file))
if (yield* fs.existsSafe(filepath)) return filepath
}
})
set.add(found)
const content = yield* read(found)
if (content) {
results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` })
}
const resolve = Effect.fn("Instruction.resolve")(function* (
messages: MessageV2.WithParts[],
filepath: string,
messageID: MessageID,
) {
const sys = yield* systemPaths()
const already = extract(messages)
const results: { filepath: string; content: string }[] = []
const s = yield* InstanceState.get(state)
const target = path.resolve(filepath)
const root = path.resolve(Instance.directory)
let current = path.dirname(target)
// Walk upward from the file being read and attach nearby instruction files once per message.
while (current.startsWith(root) && current !== root) {
const found = yield* find(current)
if (!found || found === target || sys.has(found) || already.has(found)) {
current = path.dirname(current)
continue
}
let set = s.claims.get(messageID)
if (!set) {
set = new Set()
s.claims.set(messageID, set)
}
if (set.has(found)) {
current = path.dirname(current)
continue
}
return results
})
set.add(found)
const content = yield* read(found)
if (content) {
results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` })
}
return Service.of({ clear, systemPaths, system, find, resolve })
}),
)
current = path.dirname(current)
}
return results
})
return Service.of({ clear, systemPaths, system, find, resolve })
}),
export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer),
)
export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer),
)
export function loaded(messages: MessageV2.WithParts[]) {
return extract(messages)
export function loaded(messages: MessageV2.WithParts[]) {
return extract(messages)
}
}
export * as Instruction from "./instruction"
+392 -392
View File
@@ -25,429 +25,429 @@ import { EffectBridge } from "@/effect"
import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer"
const log = Log.create({ service: "llm" })
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
type Result = Awaited<ReturnType<typeof streamText>>
export namespace LLM {
const log = Log.create({ service: "llm" })
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
type Result = Awaited<ReturnType<typeof streamText>>
export type StreamInput = {
user: MessageV2.User
sessionID: string
parentSessionID?: string
model: Provider.Model
agent: Agent.Info
permission?: Permission.Ruleset
system: string[]
messages: ModelMessage[]
small?: boolean
tools: Record<string, Tool>
retries?: number
toolChoice?: "auto" | "required" | "none"
}
export type StreamInput = {
user: MessageV2.User
sessionID: string
parentSessionID?: string
model: Provider.Model
agent: Agent.Info
permission?: Permission.Ruleset
system: string[]
messages: ModelMessage[]
small?: boolean
tools: Record<string, Tool>
retries?: number
toolChoice?: "auto" | "required" | "none"
}
export type StreamRequest = StreamInput & {
abort: AbortSignal
}
export type StreamRequest = StreamInput & {
abort: AbortSignal
}
export type Event = Result["fullStream"] extends AsyncIterable<infer T> ? T : never
export type Event = Result["fullStream"] extends AsyncIterable<infer T> ? T : never
export interface Interface {
readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown>
}
export interface Interface {
readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
const live: Layer.Layer<
Service,
never,
Auth.Service | Config.Service | Provider.Service | Plugin.Service | Permission.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
const auth = yield* Auth.Service
const config = yield* Config.Service
const provider = yield* Provider.Service
const plugin = yield* Plugin.Service
const perm = yield* Permission.Service
const live: Layer.Layer<
Service,
never,
Auth.Service | Config.Service | Provider.Service | Plugin.Service | Permission.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
const auth = yield* Auth.Service
const config = yield* Config.Service
const provider = yield* Provider.Service
const plugin = yield* Plugin.Service
const perm = yield* Permission.Service
const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {
const l = log
.clone()
.tag("providerID", input.model.providerID)
.tag("modelID", input.model.id)
.tag("sessionID", input.sessionID)
.tag("small", (input.small ?? false).toString())
.tag("agent", input.agent.name)
.tag("mode", input.agent.mode)
l.info("stream", {
modelID: input.model.id,
providerID: input.model.providerID,
})
const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {
const l = log
.clone()
.tag("providerID", input.model.providerID)
.tag("modelID", input.model.id)
.tag("sessionID", input.sessionID)
.tag("small", (input.small ?? false).toString())
.tag("agent", input.agent.name)
.tag("mode", input.agent.mode)
l.info("stream", {
modelID: input.model.id,
providerID: input.model.providerID,
})
const [language, cfg, item, info] = yield* Effect.all(
[
provider.getLanguage(input.model),
config.get(),
provider.getProvider(input.model.providerID),
auth.get(input.model.providerID),
],
{ concurrency: "unbounded" },
)
const [language, cfg, item, info] = yield* Effect.all(
[
provider.getLanguage(input.model),
config.get(),
provider.getProvider(input.model.providerID),
auth.get(input.model.providerID),
],
{ concurrency: "unbounded" },
)
// TODO: move this to a proper hook
const isOpenaiOauth = item.id === "openai" && info?.type === "oauth"
// TODO: move this to a proper hook
const isOpenaiOauth = item.id === "openai" && info?.type === "oauth"
const system: string[] = []
system.push(
[
// use agent prompt otherwise provider prompt
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
// any custom prompt passed into this call
...input.system,
// any custom prompt from last user message
...(input.user.system ? [input.user.system] : []),
]
.filter((x) => x)
.join("\n"),
)
const system: string[] = []
system.push(
[
// use agent prompt otherwise provider prompt
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
// any custom prompt passed into this call
...input.system,
// any custom prompt from last user message
...(input.user.system ? [input.user.system] : []),
]
.filter((x) => x)
.join("\n"),
)
const header = system[0]
yield* plugin.trigger(
"experimental.chat.system.transform",
{ sessionID: input.sessionID, model: input.model },
{ system },
)
// rejoin to maintain 2-part structure for caching if header unchanged
if (system.length > 2 && system[0] === header) {
const rest = system.slice(1)
system.length = 0
system.push(header, rest.join("\n"))
}
const header = system[0]
yield* plugin.trigger(
"experimental.chat.system.transform",
{ sessionID: input.sessionID, model: input.model },
{ system },
)
// rejoin to maintain 2-part structure for caching if header unchanged
if (system.length > 2 && system[0] === header) {
const rest = system.slice(1)
system.length = 0
system.push(header, rest.join("\n"))
}
const variant =
!input.small && input.model.variants && input.user.model.variant
? input.model.variants[input.user.model.variant]
: {}
const base = input.small
? ProviderTransform.smallOptions(input.model)
: ProviderTransform.options({
model: input.model,
sessionID: input.sessionID,
providerOptions: item.options,
})
const options: Record<string, any> = pipe(
base,
mergeDeep(input.model.options),
mergeDeep(input.agent.options),
mergeDeep(variant),
)
if (isOpenaiOauth) {
options.instructions = system.join("\n")
}
const variant =
!input.small && input.model.variants && input.user.model.variant
? input.model.variants[input.user.model.variant]
: {}
const base = input.small
? ProviderTransform.smallOptions(input.model)
: ProviderTransform.options({
model: input.model,
sessionID: input.sessionID,
providerOptions: item.options,
})
const options: Record<string, any> = pipe(
base,
mergeDeep(input.model.options),
mergeDeep(input.agent.options),
mergeDeep(variant),
)
if (isOpenaiOauth) {
options.instructions = system.join("\n")
}
const isWorkflow = language instanceof GitLabWorkflowLanguageModel
const messages = isOpenaiOauth
? input.messages
: isWorkflow
const isWorkflow = language instanceof GitLabWorkflowLanguageModel
const messages = isOpenaiOauth
? input.messages
: [
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...input.messages,
]
: isWorkflow
? input.messages
: [
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...input.messages,
]
const params = yield* plugin.trigger(
"chat.params",
{
sessionID: input.sessionID,
agent: input.agent.name,
model: input.model,
provider: item,
message: input.user,
},
{
temperature: input.model.capabilities.temperature
? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
: undefined,
topP: input.agent.topP ?? ProviderTransform.topP(input.model),
topK: ProviderTransform.topK(input.model),
maxOutputTokens: ProviderTransform.maxOutputTokens(input.model),
options,
},
)
const params = yield* plugin.trigger(
"chat.params",
{
sessionID: input.sessionID,
agent: input.agent.name,
model: input.model,
provider: item,
message: input.user,
},
{
temperature: input.model.capabilities.temperature
? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
: undefined,
topP: input.agent.topP ?? ProviderTransform.topP(input.model),
topK: ProviderTransform.topK(input.model),
maxOutputTokens: ProviderTransform.maxOutputTokens(input.model),
options,
},
)
const { headers } = yield* plugin.trigger(
"chat.headers",
{
sessionID: input.sessionID,
agent: input.agent.name,
model: input.model,
provider: item,
message: input.user,
},
{
headers: {},
},
)
const { headers } = yield* plugin.trigger(
"chat.headers",
{
sessionID: input.sessionID,
agent: input.agent.name,
model: input.model,
provider: item,
message: input.user,
},
{
headers: {},
},
)
const tools = resolveTools(input)
const tools = resolveTools(input)
// LiteLLM and some Anthropic proxies require the tools parameter to be present
// when message history contains tool calls, even if no tools are being used.
// Add a dummy tool that is never called to satisfy this validation.
// This is enabled for:
// 1. Providers with "litellm" in their ID or API ID (auto-detected)
// 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways)
const isLiteLLMProxy =
item.options?.["litellmProxy"] === true ||
input.model.providerID.toLowerCase().includes("litellm") ||
input.model.api.id.toLowerCase().includes("litellm")
// LiteLLM and some Anthropic proxies require the tools parameter to be present
// when message history contains tool calls, even if no tools are being used.
// Add a dummy tool that is never called to satisfy this validation.
// This is enabled for:
// 1. Providers with "litellm" in their ID or API ID (auto-detected)
// 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways)
const isLiteLLMProxy =
item.options?.["litellmProxy"] === true ||
input.model.providerID.toLowerCase().includes("litellm") ||
input.model.api.id.toLowerCase().includes("litellm")
// LiteLLM/Bedrock rejects requests where the message history contains tool
// calls but no tools param is present. When there are no active tools (e.g.
// during compaction), inject a stub tool to satisfy the validation requirement.
// The stub description explicitly tells the model not to call it.
if (
(isLiteLLMProxy || input.model.providerID.includes("github-copilot")) &&
Object.keys(tools).length === 0 &&
hasToolCalls(input.messages)
) {
tools["_noop"] = tool({
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
inputSchema: jsonSchema({
type: "object",
properties: {
reason: { type: "string", description: "Unused" },
},
}),
execute: async () => ({ output: "", title: "", metadata: {} }),
})
}
// Wire up toolExecutor for DWS workflow models so that tool calls
// from the workflow service are executed via opencode's tool system
// and results sent back over the WebSocket.
if (language instanceof GitLabWorkflowLanguageModel) {
const workflowModel = language as GitLabWorkflowLanguageModel & {
sessionID?: string
sessionPreapprovedTools?: string[]
approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }>
}
workflowModel.sessionID = input.sessionID
workflowModel.systemPrompt = system.join("\n")
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
const t = tools[toolName]
if (!t || !t.execute) {
return { result: "", error: `Unknown tool: ${toolName}` }
}
try {
const result = await t.execute!(JSON.parse(argsJson), {
toolCallId: _requestID,
messages: input.messages,
abortSignal: input.abort,
})
const output = typeof result === "string" ? result : (result?.output ?? JSON.stringify(result))
return {
result: output,
metadata: typeof result === "object" ? result?.metadata : undefined,
title: typeof result === "object" ? result?.title : undefined,
}
} catch (e: any) {
return { result: "", error: e.message ?? String(e) }
}
}
const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? [])
workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => {
const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission))
return !match || match.action !== "ask"
})
const bridge = yield* EffectBridge.make()
const approvedToolsForSession = new Set<string>()
workflowModel.approvalHandler = Instance.bind(async (approvalTools) => {
const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[]
// Auto-approve tools that were already approved in this session
// (prevents infinite approval loops for server-side MCP tools)
if (uniqueNames.every((name) => approvedToolsForSession.has(name))) {
return { approved: true }
}
const id = PermissionID.ascending()
let unsub: (() => void) | undefined
try {
unsub = Bus.subscribe(Permission.Event.Replied, (evt) => {
if (evt.properties.requestID === id) void evt.properties.reply
})
const toolPatterns = approvalTools.map((t: { name: string; args: string }) => {
try {
const parsed = JSON.parse(t.args) as Record<string, unknown>
const title = (parsed?.title ?? parsed?.name ?? "") as string
return title ? `${t.name}: ${title}` : t.name
} catch {
return t.name
}
})
const uniquePatterns = [...new Set(toolPatterns)] as string[]
await bridge.promise(
perm.ask({
id,
sessionID: SessionID.make(input.sessionID),
permission: "workflow_tool_approval",
patterns: uniquePatterns,
metadata: { tools: approvalTools },
always: uniquePatterns,
ruleset: [],
}),
)
for (const name of uniqueNames) approvedToolsForSession.add(name)
workflowModel.sessionPreapprovedTools = [...(workflowModel.sessionPreapprovedTools ?? []), ...uniqueNames]
return { approved: true }
} catch {
return { approved: false }
} finally {
unsub?.()
}
})
}
const tracer = cfg.experimental?.openTelemetry
? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer))
: undefined
return streamText({
onError(error) {
l.error("stream error", {
error,
// LiteLLM/Bedrock rejects requests where the message history contains tool
// calls but no tools param is present. When there are no active tools (e.g.
// during compaction), inject a stub tool to satisfy the validation requirement.
// The stub description explicitly tells the model not to call it.
if (
(isLiteLLMProxy || input.model.providerID.includes("github-copilot")) &&
Object.keys(tools).length === 0 &&
hasToolCalls(input.messages)
) {
tools["_noop"] = tool({
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
inputSchema: jsonSchema({
type: "object",
properties: {
reason: { type: "string", description: "Unused" },
},
}),
execute: async () => ({ output: "", title: "", metadata: {} }),
})
},
async experimental_repairToolCall(failed) {
const lower = failed.toolCall.toolName.toLowerCase()
if (lower !== failed.toolCall.toolName && tools[lower]) {
l.info("repairing tool call", {
tool: failed.toolCall.toolName,
repaired: lower,
}
// Wire up toolExecutor for DWS workflow models so that tool calls
// from the workflow service are executed via opencode's tool system
// and results sent back over the WebSocket.
if (language instanceof GitLabWorkflowLanguageModel) {
const workflowModel = language as GitLabWorkflowLanguageModel & {
sessionID?: string
sessionPreapprovedTools?: string[]
approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }>
}
workflowModel.sessionID = input.sessionID
workflowModel.systemPrompt = system.join("\n")
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
const t = tools[toolName]
if (!t || !t.execute) {
return { result: "", error: `Unknown tool: ${toolName}` }
}
try {
const result = await t.execute!(JSON.parse(argsJson), {
toolCallId: _requestID,
messages: input.messages,
abortSignal: input.abort,
})
const output = typeof result === "string" ? result : (result?.output ?? JSON.stringify(result))
return {
result: output,
metadata: typeof result === "object" ? result?.metadata : undefined,
title: typeof result === "object" ? result?.title : undefined,
}
} catch (e: any) {
return { result: "", error: e.message ?? String(e) }
}
}
const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? [])
workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => {
const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission))
return !match || match.action !== "ask"
})
const bridge = yield* EffectBridge.make()
const approvedToolsForSession = new Set<string>()
workflowModel.approvalHandler = Instance.bind(async (approvalTools) => {
const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[]
// Auto-approve tools that were already approved in this session
// (prevents infinite approval loops for server-side MCP tools)
if (uniqueNames.every((name) => approvedToolsForSession.has(name))) {
return { approved: true }
}
const id = PermissionID.ascending()
let unsub: (() => void) | undefined
try {
unsub = Bus.subscribe(Permission.Event.Replied, (evt) => {
if (evt.properties.requestID === id) void evt.properties.reply
})
const toolPatterns = approvalTools.map((t: { name: string; args: string }) => {
try {
const parsed = JSON.parse(t.args) as Record<string, unknown>
const title = (parsed?.title ?? parsed?.name ?? "") as string
return title ? `${t.name}: ${title}` : t.name
} catch {
return t.name
}
})
const uniquePatterns = [...new Set(toolPatterns)] as string[]
await bridge.promise(
perm.ask({
id,
sessionID: SessionID.make(input.sessionID),
permission: "workflow_tool_approval",
patterns: uniquePatterns,
metadata: { tools: approvalTools },
always: uniquePatterns,
ruleset: [],
}),
)
for (const name of uniqueNames) approvedToolsForSession.add(name)
workflowModel.sessionPreapprovedTools = [...(workflowModel.sessionPreapprovedTools ?? []), ...uniqueNames]
return { approved: true }
} catch {
return { approved: false }
} finally {
unsub?.()
}
})
}
const tracer = cfg.experimental?.openTelemetry
? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer))
: undefined
return streamText({
onError(error) {
l.error("stream error", {
error,
})
},
async experimental_repairToolCall(failed) {
const lower = failed.toolCall.toolName.toLowerCase()
if (lower !== failed.toolCall.toolName && tools[lower]) {
l.info("repairing tool call", {
tool: failed.toolCall.toolName,
repaired: lower,
})
return {
...failed.toolCall,
toolName: lower,
}
}
return {
...failed.toolCall,
toolName: lower,
}
}
return {
...failed.toolCall,
input: JSON.stringify({
tool: failed.toolCall.toolName,
error: failed.error.message,
}),
toolName: "invalid",
}
},
temperature: params.temperature,
topP: params.topP,
topK: params.topK,
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
tools,
toolChoice: input.toolChoice,
maxOutputTokens: params.maxOutputTokens,
abortSignal: input.abort,
headers: {
...(input.model.providerID.startsWith("opencode")
? {
"x-opencode-project": Instance.project.id,
"x-opencode-session": input.sessionID,
"x-opencode-request": input.user.id,
"x-opencode-client": Flag.OPENCODE_CLIENT,
}
: {
"x-session-affinity": input.sessionID,
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
"User-Agent": `opencode/${InstallationVersion}`,
input: JSON.stringify({
tool: failed.toolCall.toolName,
error: failed.error.message,
}),
...input.model.headers,
...headers,
},
maxRetries: input.retries ?? 0,
messages,
model: wrapLanguageModel({
model: language,
middleware: [
{
specificationVersion: "v3" as const,
async transformParams(args) {
if (args.type === "stream") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
}
return args.params
},
},
],
}),
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
functionId: "session.llm",
tracer,
metadata: {
userId: cfg.username ?? "unknown",
sessionId: input.sessionID,
toolName: "invalid",
}
},
},
})
})
const stream: Interface["stream"] = (input) =>
Stream.scoped(
Stream.unwrap(
Effect.gen(function* () {
const ctrl = yield* Effect.acquireRelease(
Effect.sync(() => new AbortController()),
(ctrl) => Effect.sync(() => ctrl.abort()),
)
const result = yield* run({ ...input, abort: ctrl.signal })
return Stream.fromAsyncIterable(result.fullStream, (e) => (e instanceof Error ? e : new Error(String(e))))
temperature: params.temperature,
topP: params.topP,
topK: params.topK,
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
tools,
toolChoice: input.toolChoice,
maxOutputTokens: params.maxOutputTokens,
abortSignal: input.abort,
headers: {
...(input.model.providerID.startsWith("opencode")
? {
"x-opencode-project": Instance.project.id,
"x-opencode-session": input.sessionID,
"x-opencode-request": input.user.id,
"x-opencode-client": Flag.OPENCODE_CLIENT,
}
: {
"x-session-affinity": input.sessionID,
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
"User-Agent": `opencode/${InstallationVersion}`,
}),
...input.model.headers,
...headers,
},
maxRetries: input.retries ?? 0,
messages,
model: wrapLanguageModel({
model: language,
middleware: [
{
specificationVersion: "v3" as const,
async transformParams(args) {
if (args.type === "stream") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
}
return args.params
},
},
],
}),
),
)
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
functionId: "session.llm",
tracer,
metadata: {
userId: cfg.username ?? "unknown",
sessionId: input.sessionID,
},
},
})
})
return Service.of({ stream })
}),
)
const stream: Interface["stream"] = (input) =>
Stream.scoped(
Stream.unwrap(
Effect.gen(function* () {
const ctrl = yield* Effect.acquireRelease(
Effect.sync(() => new AbortController()),
(ctrl) => Effect.sync(() => ctrl.abort()),
)
export const layer = live.pipe(Layer.provide(Permission.defaultLayer))
const result = yield* run({ ...input, abort: ctrl.signal })
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Plugin.defaultLayer),
),
)
return Stream.fromAsyncIterable(result.fullStream, (e) => (e instanceof Error ? e : new Error(String(e))))
}),
),
)
function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
const disabled = Permission.disabled(
Object.keys(input.tools),
Permission.merge(input.agent.permission, input.permission ?? []),
return Service.of({ stream })
}),
)
return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
}
// Check if messages contain any tool-call content
// Used to determine if a dummy tool should be added for LiteLLM proxy compatibility
export function hasToolCalls(messages: ModelMessage[]): boolean {
for (const msg of messages) {
if (!Array.isArray(msg.content)) continue
for (const part of msg.content) {
if (part.type === "tool-call" || part.type === "tool-result") return true
}
export const layer = live.pipe(Layer.provide(Permission.defaultLayer))
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Plugin.defaultLayer),
),
)
function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
const disabled = Permission.disabled(
Object.keys(input.tools),
Permission.merge(input.agent.permission, input.permission ?? []),
)
return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
}
return false
}
export * as LLM from "./llm"
// Check if messages contain any tool-call content
// Used to determine if a dummy tool should be added for LiteLLM proxy compatibility
export function hasToolCalls(messages: ModelMessage[]): boolean {
for (const msg of messages) {
if (!Array.isArray(msg.content)) continue
for (const part of msg.content) {
if (part.type === "tool-call" || part.type === "tool-result") return true
}
}
return false
}
}
File diff suppressed because it is too large Load Diff
+171 -171
View File
@@ -3,189 +3,189 @@ import { SessionID } from "./schema"
import { ModelID, ProviderID } from "../provider/schema"
import { NamedError } from "@opencode-ai/shared/util/error"
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
export const AuthError = NamedError.create(
"ProviderAuthError",
z.object({
providerID: z.string(),
message: z.string(),
}),
)
export namespace Message {
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
export const AuthError = NamedError.create(
"ProviderAuthError",
z.object({
providerID: z.string(),
message: z.string(),
}),
)
export const ToolCall = z
.object({
state: z.literal("call"),
step: z.number().optional(),
toolCallId: z.string(),
toolName: z.string(),
args: z.custom<Required<unknown>>(),
})
.meta({
ref: "ToolCall",
})
export type ToolCall = z.infer<typeof ToolCall>
export const ToolCall = z
.object({
state: z.literal("call"),
step: z.number().optional(),
toolCallId: z.string(),
toolName: z.string(),
args: z.custom<Required<unknown>>(),
})
.meta({
ref: "ToolCall",
})
export type ToolCall = z.infer<typeof ToolCall>
export const ToolPartialCall = z
.object({
state: z.literal("partial-call"),
step: z.number().optional(),
toolCallId: z.string(),
toolName: z.string(),
args: z.custom<Required<unknown>>(),
})
.meta({
ref: "ToolPartialCall",
})
export type ToolPartialCall = z.infer<typeof ToolPartialCall>
export const ToolPartialCall = z
.object({
state: z.literal("partial-call"),
step: z.number().optional(),
toolCallId: z.string(),
toolName: z.string(),
args: z.custom<Required<unknown>>(),
})
.meta({
ref: "ToolPartialCall",
})
export type ToolPartialCall = z.infer<typeof ToolPartialCall>
export const ToolResult = z
.object({
state: z.literal("result"),
step: z.number().optional(),
toolCallId: z.string(),
toolName: z.string(),
args: z.custom<Required<unknown>>(),
result: z.string(),
})
.meta({
ref: "ToolResult",
})
export type ToolResult = z.infer<typeof ToolResult>
export const ToolResult = z
.object({
state: z.literal("result"),
step: z.number().optional(),
toolCallId: z.string(),
toolName: z.string(),
args: z.custom<Required<unknown>>(),
result: z.string(),
})
.meta({
ref: "ToolResult",
})
export type ToolResult = z.infer<typeof ToolResult>
export const ToolInvocation = z.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult]).meta({
ref: "ToolInvocation",
})
export type ToolInvocation = z.infer<typeof ToolInvocation>
export const ToolInvocation = z.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult]).meta({
ref: "ToolInvocation",
})
export type ToolInvocation = z.infer<typeof ToolInvocation>
export const TextPart = z
.object({
type: z.literal("text"),
text: z.string(),
})
.meta({
ref: "TextPart",
})
export type TextPart = z.infer<typeof TextPart>
export const TextPart = z
.object({
type: z.literal("text"),
text: z.string(),
})
.meta({
ref: "TextPart",
})
export type TextPart = z.infer<typeof TextPart>
export const ReasoningPart = z
.object({
type: z.literal("reasoning"),
text: z.string(),
providerMetadata: z.record(z.string(), z.any()).optional(),
})
.meta({
ref: "ReasoningPart",
})
export type ReasoningPart = z.infer<typeof ReasoningPart>
export const ReasoningPart = z
.object({
type: z.literal("reasoning"),
text: z.string(),
providerMetadata: z.record(z.string(), z.any()).optional(),
})
.meta({
ref: "ReasoningPart",
})
export type ReasoningPart = z.infer<typeof ReasoningPart>
export const ToolInvocationPart = z
.object({
type: z.literal("tool-invocation"),
toolInvocation: ToolInvocation,
})
.meta({
ref: "ToolInvocationPart",
})
export type ToolInvocationPart = z.infer<typeof ToolInvocationPart>
export const ToolInvocationPart = z
.object({
type: z.literal("tool-invocation"),
toolInvocation: ToolInvocation,
})
.meta({
ref: "ToolInvocationPart",
})
export type ToolInvocationPart = z.infer<typeof ToolInvocationPart>
export const SourceUrlPart = z
.object({
type: z.literal("source-url"),
sourceId: z.string(),
url: z.string(),
title: z.string().optional(),
providerMetadata: z.record(z.string(), z.any()).optional(),
})
.meta({
ref: "SourceUrlPart",
})
export type SourceUrlPart = z.infer<typeof SourceUrlPart>
export const SourceUrlPart = z
.object({
type: z.literal("source-url"),
sourceId: z.string(),
url: z.string(),
title: z.string().optional(),
providerMetadata: z.record(z.string(), z.any()).optional(),
})
.meta({
ref: "SourceUrlPart",
})
export type SourceUrlPart = z.infer<typeof SourceUrlPart>
export const FilePart = z
.object({
type: z.literal("file"),
mediaType: z.string(),
filename: z.string().optional(),
url: z.string(),
})
.meta({
ref: "FilePart",
})
export type FilePart = z.infer<typeof FilePart>
export const FilePart = z
.object({
type: z.literal("file"),
mediaType: z.string(),
filename: z.string().optional(),
url: z.string(),
})
.meta({
ref: "FilePart",
})
export type FilePart = z.infer<typeof FilePart>
export const StepStartPart = z
.object({
type: z.literal("step-start"),
})
.meta({
ref: "StepStartPart",
})
export type StepStartPart = z.infer<typeof StepStartPart>
export const StepStartPart = z
.object({
type: z.literal("step-start"),
})
.meta({
ref: "StepStartPart",
})
export type StepStartPart = z.infer<typeof StepStartPart>
export const MessagePart = z
.discriminatedUnion("type", [TextPart, ReasoningPart, ToolInvocationPart, SourceUrlPart, FilePart, StepStartPart])
.meta({
ref: "MessagePart",
})
export type MessagePart = z.infer<typeof MessagePart>
export const MessagePart = z
.discriminatedUnion("type", [TextPart, ReasoningPart, ToolInvocationPart, SourceUrlPart, FilePart, StepStartPart])
.meta({
ref: "MessagePart",
})
export type MessagePart = z.infer<typeof MessagePart>
export const Info = z
.object({
id: z.string(),
role: z.enum(["user", "assistant"]),
parts: z.array(MessagePart),
metadata: z
.object({
time: z.object({
created: z.number(),
completed: z.number().optional(),
}),
error: z
.discriminatedUnion("name", [AuthError.Schema, NamedError.Unknown.Schema, OutputLengthError.Schema])
.optional(),
sessionID: SessionID.zod,
tool: z.record(
z.string(),
z
export const Info = z
.object({
id: z.string(),
role: z.enum(["user", "assistant"]),
parts: z.array(MessagePart),
metadata: z
.object({
time: z.object({
created: z.number(),
completed: z.number().optional(),
}),
error: z
.discriminatedUnion("name", [AuthError.Schema, NamedError.Unknown.Schema, OutputLengthError.Schema])
.optional(),
sessionID: SessionID.zod,
tool: z.record(
z.string(),
z
.object({
title: z.string(),
snapshot: z.string().optional(),
time: z.object({
start: z.number(),
end: z.number(),
}),
})
.catchall(z.any()),
),
assistant: z
.object({
title: z.string(),
snapshot: z.string().optional(),
time: z.object({
start: z.number(),
end: z.number(),
system: z.string().array(),
modelID: ModelID.zod,
providerID: ProviderID.zod,
path: z.object({
cwd: z.string(),
root: z.string(),
}),
cost: z.number(),
summary: z.boolean().optional(),
tokens: z.object({
input: z.number(),
output: z.number(),
reasoning: z.number(),
cache: z.object({
read: z.number(),
write: z.number(),
}),
}),
})
.catchall(z.any()),
),
assistant: z
.object({
system: z.string().array(),
modelID: ModelID.zod,
providerID: ProviderID.zod,
path: z.object({
cwd: z.string(),
root: z.string(),
}),
cost: z.number(),
summary: z.boolean().optional(),
tokens: z.object({
input: z.number(),
output: z.number(),
reasoning: z.number(),
cache: z.object({
read: z.number(),
write: z.number(),
}),
}),
})
.optional(),
snapshot: z.string().optional(),
})
.meta({ ref: "MessageMetadata" }),
})
.meta({
ref: "Message",
})
export type Info = z.infer<typeof Info>
export * as Message from "./message"
.optional(),
snapshot: z.string().optional(),
})
.meta({ ref: "MessageMetadata" }),
})
.meta({
ref: "Message",
})
export type Info = z.infer<typeof Info>
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+104 -104
View File
@@ -3,123 +3,123 @@ import { Cause, Clock, Duration, Effect, Schedule } from "effect"
import { MessageV2 } from "./message-v2"
import { iife } from "@/util/iife"
export type Err = ReturnType<NamedError["toObject"]>
export namespace SessionRetry {
export type Err = ReturnType<NamedError["toObject"]>
// This exported message is shared with the TUI upsell detector. Matching on a
// literal error string kind of sucks, but it is the simplest for now.
export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go https://opencode.ai/go"
// This exported message is shared with the TUI upsell detector. Matching on a
// literal error string kind of sucks, but it is the simplest for now.
export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go https://opencode.ai/go"
export const RETRY_INITIAL_DELAY = 2000
export const RETRY_BACKOFF_FACTOR = 2
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout
export const RETRY_INITIAL_DELAY = 2000
export const RETRY_BACKOFF_FACTOR = 2
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout
function cap(ms: number) {
return Math.min(ms, RETRY_MAX_DELAY)
}
function cap(ms: number) {
return Math.min(ms, RETRY_MAX_DELAY)
}
export function delay(attempt: number, error?: MessageV2.APIError) {
if (error) {
const headers = error.data.responseHeaders
if (headers) {
const retryAfterMs = headers["retry-after-ms"]
if (retryAfterMs) {
const parsedMs = Number.parseFloat(retryAfterMs)
if (!Number.isNaN(parsedMs)) {
return cap(parsedMs)
export function delay(attempt: number, error?: MessageV2.APIError) {
if (error) {
const headers = error.data.responseHeaders
if (headers) {
const retryAfterMs = headers["retry-after-ms"]
if (retryAfterMs) {
const parsedMs = Number.parseFloat(retryAfterMs)
if (!Number.isNaN(parsedMs)) {
return cap(parsedMs)
}
}
}
const retryAfter = headers["retry-after"]
if (retryAfter) {
const parsedSeconds = Number.parseFloat(retryAfter)
if (!Number.isNaN(parsedSeconds)) {
// convert seconds to milliseconds
return cap(Math.ceil(parsedSeconds * 1000))
const retryAfter = headers["retry-after"]
if (retryAfter) {
const parsedSeconds = Number.parseFloat(retryAfter)
if (!Number.isNaN(parsedSeconds)) {
// convert seconds to milliseconds
return cap(Math.ceil(parsedSeconds * 1000))
}
// Try parsing as HTTP date format
const parsed = Date.parse(retryAfter) - Date.now()
if (!Number.isNaN(parsed) && parsed > 0) {
return cap(Math.ceil(parsed))
}
}
// Try parsing as HTTP date format
const parsed = Date.parse(retryAfter) - Date.now()
if (!Number.isNaN(parsed) && parsed > 0) {
return cap(Math.ceil(parsed))
return cap(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1))
}
}
return cap(Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS))
}
export function retryable(error: Err) {
// context overflow errors should not be retried
if (MessageV2.ContextOverflowError.isInstance(error)) return undefined
if (MessageV2.APIError.isInstance(error)) {
const status = error.data.statusCode
// 5xx errors are transient server failures and should always be retried,
// even when the provider SDK doesn't explicitly mark them as retryable.
if (!error.data.isRetryable && !(status !== undefined && status >= 500)) return undefined
if (error.data.responseBody?.includes("FreeUsageLimitError")) return GO_UPSELL_MESSAGE
return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message
}
// Check for rate limit patterns in plain text error messages
const msg = error.data?.message
if (typeof msg === "string") {
const lower = msg.toLowerCase()
if (
lower.includes("rate increased too quickly") ||
lower.includes("rate limit") ||
lower.includes("too many requests")
) {
return msg
}
}
const json = iife(() => {
try {
if (typeof error.data?.message === "string") {
const parsed = JSON.parse(error.data.message)
return parsed
}
return JSON.parse(error.data.message)
} catch {
return undefined
}
})
if (!json || typeof json !== "object") return undefined
const code = typeof json.code === "string" ? json.code : ""
return cap(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1))
if (json.type === "error" && json.error?.type === "too_many_requests") {
return "Too Many Requests"
}
}
return cap(Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS))
}
export function retryable(error: Err) {
// context overflow errors should not be retried
if (MessageV2.ContextOverflowError.isInstance(error)) return undefined
if (MessageV2.APIError.isInstance(error)) {
const status = error.data.statusCode
// 5xx errors are transient server failures and should always be retried,
// even when the provider SDK doesn't explicitly mark them as retryable.
if (!error.data.isRetryable && !(status !== undefined && status >= 500)) return undefined
if (error.data.responseBody?.includes("FreeUsageLimitError")) return GO_UPSELL_MESSAGE
return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message
}
// Check for rate limit patterns in plain text error messages
const msg = error.data?.message
if (typeof msg === "string") {
const lower = msg.toLowerCase()
if (
lower.includes("rate increased too quickly") ||
lower.includes("rate limit") ||
lower.includes("too many requests")
) {
return msg
if (code.includes("exhausted") || code.includes("unavailable")) {
return "Provider is overloaded"
}
}
const json = iife(() => {
try {
if (typeof error.data?.message === "string") {
const parsed = JSON.parse(error.data.message)
return parsed
}
return JSON.parse(error.data.message)
} catch {
return undefined
if (json.type === "error" && typeof json.error?.code === "string" && json.error.code.includes("rate_limit")) {
return "Rate Limited"
}
})
if (!json || typeof json !== "object") return undefined
const code = typeof json.code === "string" ? json.code : ""
return undefined
}
if (json.type === "error" && json.error?.type === "too_many_requests") {
return "Too Many Requests"
export function policy(opts: {
parse: (error: unknown) => Err
set: (input: { attempt: number; message: string; next: number }) => Effect.Effect<void>
}) {
return Schedule.fromStepWithMetadata(
Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
const error = opts.parse(meta.input)
const message = retryable(error)
if (!message) return Cause.done(meta.attempt)
return Effect.gen(function* () {
const wait = delay(meta.attempt, MessageV2.APIError.isInstance(error) ? error : undefined)
const now = yield* Clock.currentTimeMillis
yield* opts.set({ attempt: meta.attempt, message, next: now + wait })
return [meta.attempt, Duration.millis(wait)] as [number, Duration.Duration]
})
}),
)
}
if (code.includes("exhausted") || code.includes("unavailable")) {
return "Provider is overloaded"
}
if (json.type === "error" && typeof json.error?.code === "string" && json.error.code.includes("rate_limit")) {
return "Rate Limited"
}
return undefined
}
export function policy(opts: {
parse: (error: unknown) => Err
set: (input: { attempt: number; message: string; next: number }) => Effect.Effect<void>
}) {
return Schedule.fromStepWithMetadata(
Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
const error = opts.parse(meta.input)
const message = retryable(error)
if (!message) return Cause.done(meta.attempt)
return Effect.gen(function* () {
const wait = delay(meta.attempt, MessageV2.APIError.isInstance(error) ? error : undefined)
const now = yield* Clock.currentTimeMillis
yield* opts.set({ attempt: meta.attempt, message, next: now + wait })
return [meta.attempt, Duration.millis(wait)] as [number, Duration.Duration]
})
}),
)
}
export * as SessionRetry from "./retry"
+132 -132
View File
@@ -11,151 +11,151 @@ import { SessionID, MessageID, PartID } from "./schema"
import { SessionRunState } from "./run-state"
import { SessionSummary } from "./summary"
const log = Log.create({ service: "session.revert" })
export namespace SessionRevert {
const log = Log.create({ service: "session.revert" })
export const RevertInput = z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod,
partID: PartID.zod.optional(),
})
export type RevertInput = z.infer<typeof RevertInput>
export const RevertInput = z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod,
partID: PartID.zod.optional(),
})
export type RevertInput = z.infer<typeof RevertInput>
export interface Interface {
readonly revert: (input: RevertInput) => Effect.Effect<Session.Info>
readonly unrevert: (input: { sessionID: SessionID }) => Effect.Effect<Session.Info>
readonly cleanup: (session: Session.Info) => Effect.Effect<void>
}
export interface Interface {
readonly revert: (input: RevertInput) => Effect.Effect<Session.Info>
readonly unrevert: (input: { sessionID: SessionID }) => Effect.Effect<Session.Info>
readonly cleanup: (session: Session.Info) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRevert") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRevert") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const sessions = yield* Session.Service
const snap = yield* Snapshot.Service
const storage = yield* Storage.Service
const bus = yield* Bus.Service
const summary = yield* SessionSummary.Service
const state = yield* SessionRunState.Service
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const sessions = yield* Session.Service
const snap = yield* Snapshot.Service
const storage = yield* Storage.Service
const bus = yield* Bus.Service
const summary = yield* SessionSummary.Service
const state = yield* SessionRunState.Service
const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
yield* state.assertNotBusy(input.sessionID)
const all = yield* sessions.messages({ sessionID: input.sessionID })
let lastUser: MessageV2.User | undefined
const session = yield* sessions.get(input.sessionID)
const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
yield* state.assertNotBusy(input.sessionID)
const all = yield* sessions.messages({ sessionID: input.sessionID })
let lastUser: MessageV2.User | undefined
const session = yield* sessions.get(input.sessionID)
let rev: Session.Info["revert"]
const patches: Snapshot.Patch[] = []
for (const msg of all) {
if (msg.info.role === "user") lastUser = msg.info
const remaining = []
for (const part of msg.parts) {
if (rev) {
if (part.type === "patch") patches.push(part)
let rev: Session.Info["revert"]
const patches: Snapshot.Patch[] = []
for (const msg of all) {
if (msg.info.role === "user") lastUser = msg.info
const remaining = []
for (const part of msg.parts) {
if (rev) {
if (part.type === "patch") patches.push(part)
continue
}
if (!rev) {
if ((msg.info.id === input.messageID && !input.partID) || part.id === input.partID) {
const partID = remaining.some((item) => ["text", "tool"].includes(item.type)) ? input.partID : undefined
rev = {
messageID: !partID && lastUser ? lastUser.id : msg.info.id,
partID,
}
}
remaining.push(part)
}
}
}
if (!rev) return session
rev.snapshot = session.revert?.snapshot ?? (yield* snap.track())
if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot)
yield* snap.revert(patches)
if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot as string)
const range = all.filter((msg) => msg.info.id >= rev!.messageID)
const diffs = yield* summary.computeDiff({ messages: range })
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
yield* sessions.setRevert({
sessionID: input.sessionID,
revert: rev,
summary: {
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
files: diffs.length,
},
})
return yield* sessions.get(input.sessionID)
})
const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
log.info("unreverting", input)
yield* state.assertNotBusy(input.sessionID)
const session = yield* sessions.get(input.sessionID)
if (!session.revert) return session
if (session.revert.snapshot) yield* snap.restore(session.revert!.snapshot!)
yield* sessions.clearRevert(input.sessionID)
return yield* sessions.get(input.sessionID)
})
const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) {
if (!session.revert) return
const sessionID = session.id
const msgs = yield* sessions.messages({ sessionID })
const messageID = session.revert.messageID
const remove = [] as MessageV2.WithParts[]
let target: MessageV2.WithParts | undefined
for (const msg of msgs) {
if (msg.info.id < messageID) continue
if (msg.info.id > messageID) {
remove.push(msg)
continue
}
if (!rev) {
if ((msg.info.id === input.messageID && !input.partID) || part.id === input.partID) {
const partID = remaining.some((item) => ["text", "tool"].includes(item.type)) ? input.partID : undefined
rev = {
messageID: !partID && lastUser ? lastUser.id : msg.info.id,
partID,
}
}
remaining.push(part)
if (session.revert.partID) {
target = msg
continue
}
}
}
if (!rev) return session
rev.snapshot = session.revert?.snapshot ?? (yield* snap.track())
if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot)
yield* snap.revert(patches)
if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot as string)
const range = all.filter((msg) => msg.info.id >= rev!.messageID)
const diffs = yield* summary.computeDiff({ messages: range })
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
yield* sessions.setRevert({
sessionID: input.sessionID,
revert: rev,
summary: {
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
files: diffs.length,
},
})
return yield* sessions.get(input.sessionID)
})
const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
log.info("unreverting", input)
yield* state.assertNotBusy(input.sessionID)
const session = yield* sessions.get(input.sessionID)
if (!session.revert) return session
if (session.revert.snapshot) yield* snap.restore(session.revert!.snapshot!)
yield* sessions.clearRevert(input.sessionID)
return yield* sessions.get(input.sessionID)
})
const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) {
if (!session.revert) return
const sessionID = session.id
const msgs = yield* sessions.messages({ sessionID })
const messageID = session.revert.messageID
const remove = [] as MessageV2.WithParts[]
let target: MessageV2.WithParts | undefined
for (const msg of msgs) {
if (msg.info.id < messageID) continue
if (msg.info.id > messageID) {
remove.push(msg)
continue
}
if (session.revert.partID) {
target = msg
continue
for (const msg of remove) {
SyncEvent.run(MessageV2.Event.Removed, {
sessionID,
messageID: msg.info.id,
})
}
remove.push(msg)
}
for (const msg of remove) {
SyncEvent.run(MessageV2.Event.Removed, {
sessionID,
messageID: msg.info.id,
})
}
if (session.revert.partID && target) {
const partID = session.revert.partID
const idx = target.parts.findIndex((part) => part.id === partID)
if (idx >= 0) {
const removeParts = target.parts.slice(idx)
target.parts = target.parts.slice(0, idx)
for (const part of removeParts) {
SyncEvent.run(MessageV2.Event.PartRemoved, {
sessionID,
messageID: target.info.id,
partID: part.id,
})
if (session.revert.partID && target) {
const partID = session.revert.partID
const idx = target.parts.findIndex((part) => part.id === partID)
if (idx >= 0) {
const removeParts = target.parts.slice(idx)
target.parts = target.parts.slice(0, idx)
for (const part of removeParts) {
SyncEvent.run(MessageV2.Event.PartRemoved, {
sessionID,
messageID: target.info.id,
partID: part.id,
})
}
}
}
}
yield* sessions.clearRevert(sessionID)
})
yield* sessions.clearRevert(sessionID)
})
return Service.of({ revert, unrevert, cleanup })
}),
)
return Service.of({ revert, unrevert, cleanup })
}),
)
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(SessionRunState.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(Snapshot.defaultLayer),
Layer.provide(Storage.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(SessionSummary.defaultLayer),
),
)
export * as SessionRevert from "./revert"
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(SessionRunState.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(Snapshot.defaultLayer),
Layer.provide(Storage.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(SessionSummary.defaultLayer),
),
)
}
+90 -90
View File
@@ -6,103 +6,103 @@ import { MessageV2 } from "./message-v2"
import { SessionID } from "./schema"
import { SessionStatus } from "./status"
export interface Interface {
readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect<void>
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
readonly ensureRunning: (
sessionID: SessionID,
onInterrupt: Effect.Effect<MessageV2.WithParts>,
work: Effect.Effect<MessageV2.WithParts>,
) => Effect.Effect<MessageV2.WithParts>
readonly startShell: (
sessionID: SessionID,
onInterrupt: Effect.Effect<MessageV2.WithParts>,
work: Effect.Effect<MessageV2.WithParts>,
) => Effect.Effect<MessageV2.WithParts>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRunState") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const status = yield* SessionStatus.Service
const state = yield* InstanceState.make(
Effect.fn("SessionRunState.state")(function* () {
const scope = yield* Scope.Scope
const runners = new Map<SessionID, Runner.Runner<MessageV2.WithParts>>()
yield* Effect.addFinalizer(
Effect.fnUntraced(function* () {
yield* Effect.forEach(runners.values(), (runner) => runner.cancel, {
concurrency: "unbounded",
discard: true,
})
runners.clear()
}),
)
return { runners, scope }
}),
)
const runner = Effect.fn("SessionRunState.runner")(function* (
export namespace SessionRunState {
export interface Interface {
readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect<void>
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
readonly ensureRunning: (
sessionID: SessionID,
onInterrupt: Effect.Effect<MessageV2.WithParts>,
) {
const data = yield* InstanceState.get(state)
const existing = data.runners.get(sessionID)
if (existing) return existing
const next = Runner.make<MessageV2.WithParts>(data.scope, {
onIdle: Effect.gen(function* () {
data.runners.delete(sessionID)
yield* status.set(sessionID, { type: "idle" })
work: Effect.Effect<MessageV2.WithParts>,
) => Effect.Effect<MessageV2.WithParts>
readonly startShell: (
sessionID: SessionID,
onInterrupt: Effect.Effect<MessageV2.WithParts>,
work: Effect.Effect<MessageV2.WithParts>,
) => Effect.Effect<MessageV2.WithParts>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRunState") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const status = yield* SessionStatus.Service
const state = yield* InstanceState.make(
Effect.fn("SessionRunState.state")(function* () {
const scope = yield* Scope.Scope
const runners = new Map<SessionID, Runner.Runner<MessageV2.WithParts>>()
yield* Effect.addFinalizer(
Effect.fnUntraced(function* () {
yield* Effect.forEach(runners.values(), (runner) => runner.cancel, {
concurrency: "unbounded",
discard: true,
})
runners.clear()
}),
)
return { runners, scope }
}),
onBusy: status.set(sessionID, { type: "busy" }),
onInterrupt,
busy: () => {
throw new Session.BusyError(sessionID)
},
)
const runner = Effect.fn("SessionRunState.runner")(function* (
sessionID: SessionID,
onInterrupt: Effect.Effect<MessageV2.WithParts>,
) {
const data = yield* InstanceState.get(state)
const existing = data.runners.get(sessionID)
if (existing) return existing
const next = Runner.make<MessageV2.WithParts>(data.scope, {
onIdle: Effect.gen(function* () {
data.runners.delete(sessionID)
yield* status.set(sessionID, { type: "idle" })
}),
onBusy: status.set(sessionID, { type: "busy" }),
onInterrupt,
busy: () => {
throw new Session.BusyError(sessionID)
},
})
data.runners.set(sessionID, next)
return next
})
data.runners.set(sessionID, next)
return next
})
const assertNotBusy = Effect.fn("SessionRunState.assertNotBusy")(function* (sessionID: SessionID) {
const data = yield* InstanceState.get(state)
const existing = data.runners.get(sessionID)
if (existing?.busy) throw new Session.BusyError(sessionID)
})
const assertNotBusy = Effect.fn("SessionRunState.assertNotBusy")(function* (sessionID: SessionID) {
const data = yield* InstanceState.get(state)
const existing = data.runners.get(sessionID)
if (existing?.busy) throw new Session.BusyError(sessionID)
})
const cancel = Effect.fn("SessionRunState.cancel")(function* (sessionID: SessionID) {
const data = yield* InstanceState.get(state)
const existing = data.runners.get(sessionID)
if (!existing || !existing.busy) {
yield* status.set(sessionID, { type: "idle" })
return
}
yield* existing.cancel
})
const cancel = Effect.fn("SessionRunState.cancel")(function* (sessionID: SessionID) {
const data = yield* InstanceState.get(state)
const existing = data.runners.get(sessionID)
if (!existing || !existing.busy) {
yield* status.set(sessionID, { type: "idle" })
return
}
yield* existing.cancel
})
const ensureRunning = Effect.fn("SessionRunState.ensureRunning")(function* (
sessionID: SessionID,
onInterrupt: Effect.Effect<MessageV2.WithParts>,
work: Effect.Effect<MessageV2.WithParts>,
) {
return yield* (yield* runner(sessionID, onInterrupt)).ensureRunning(work)
})
const ensureRunning = Effect.fn("SessionRunState.ensureRunning")(function* (
sessionID: SessionID,
onInterrupt: Effect.Effect<MessageV2.WithParts>,
work: Effect.Effect<MessageV2.WithParts>,
) {
return yield* (yield* runner(sessionID, onInterrupt)).ensureRunning(work)
})
const startShell = Effect.fn("SessionRunState.startShell")(function* (
sessionID: SessionID,
onInterrupt: Effect.Effect<MessageV2.WithParts>,
work: Effect.Effect<MessageV2.WithParts>,
) {
return yield* (yield* runner(sessionID, onInterrupt)).startShell(work)
})
const startShell = Effect.fn("SessionRunState.startShell")(function* (
sessionID: SessionID,
onInterrupt: Effect.Effect<MessageV2.WithParts>,
work: Effect.Effect<MessageV2.WithParts>,
) {
return yield* (yield* runner(sessionID, onInterrupt)).startShell(work)
})
return Service.of({ assertNotBusy, cancel, ensureRunning, startShell })
}),
)
return Service.of({ assertNotBusy, cancel, ensureRunning, startShell })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(SessionStatus.defaultLayer))
export * as SessionRunState from "./run-state"
export const defaultLayer = layer.pipe(Layer.provide(SessionStatus.defaultLayer))
}
+10 -12
View File
@@ -272,18 +272,16 @@ export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsa
input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0,
)
const cacheWriteInputTokens = safe(
Number(
input.usage.inputTokenDetails?.cacheWriteTokens ??
input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// google-vertex-anthropic returns metadata under "vertex" key
// (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ??
// @ts-expect-error
input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
// @ts-expect-error
input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ??
0,
),
(input.usage.inputTokenDetails?.cacheWriteTokens ??
input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// google-vertex-anthropic returns metadata under "vertex" key
// (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ??
// @ts-expect-error
input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
// @ts-expect-error
input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ??
0) as number,
)
// AI SDK v6 normalized inputTokens to include cached tokens across all providers
+78 -78
View File
@@ -5,84 +5,84 @@ import { SessionID } from "./schema"
import { Effect, Layer, Context } from "effect"
import z from "zod"
export const Info = z
.union([
z.object({
type: z.literal("idle"),
}),
z.object({
type: z.literal("retry"),
attempt: z.number(),
message: z.string(),
next: z.number(),
}),
z.object({
type: z.literal("busy"),
}),
])
.meta({
ref: "SessionStatus",
})
export type Info = z.infer<typeof Info>
export namespace SessionStatus {
export const Info = z
.union([
z.object({
type: z.literal("idle"),
}),
z.object({
type: z.literal("retry"),
attempt: z.number(),
message: z.string(),
next: z.number(),
}),
z.object({
type: z.literal("busy"),
}),
])
.meta({
ref: "SessionStatus",
})
export type Info = z.infer<typeof Info>
export const Event = {
Status: BusEvent.define(
"session.status",
z.object({
sessionID: SessionID.zod,
status: Info,
export const Event = {
Status: BusEvent.define(
"session.status",
z.object({
sessionID: SessionID.zod,
status: Info,
}),
),
// deprecated
Idle: BusEvent.define(
"session.idle",
z.object({
sessionID: SessionID.zod,
}),
),
}
export interface Interface {
readonly get: (sessionID: SessionID) => Effect.Effect<Info>
readonly list: () => Effect.Effect<Map<SessionID, Info>>
readonly set: (sessionID: SessionID, status: Info) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionStatus") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const state = yield* InstanceState.make(
Effect.fn("SessionStatus.state")(() => Effect.succeed(new Map<SessionID, Info>())),
)
const get = Effect.fn("SessionStatus.get")(function* (sessionID: SessionID) {
const data = yield* InstanceState.get(state)
return data.get(sessionID) ?? { type: "idle" as const }
})
const list = Effect.fn("SessionStatus.list")(function* () {
return new Map(yield* InstanceState.get(state))
})
const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info) {
const data = yield* InstanceState.get(state)
yield* bus.publish(Event.Status, { sessionID, status })
if (status.type === "idle") {
yield* bus.publish(Event.Idle, { sessionID })
data.delete(sessionID)
return
}
data.set(sessionID, status)
})
return Service.of({ get, list, set })
}),
),
// deprecated
Idle: BusEvent.define(
"session.idle",
z.object({
sessionID: SessionID.zod,
}),
),
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
}
export interface Interface {
readonly get: (sessionID: SessionID) => Effect.Effect<Info>
readonly list: () => Effect.Effect<Map<SessionID, Info>>
readonly set: (sessionID: SessionID, status: Info) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionStatus") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const state = yield* InstanceState.make(
Effect.fn("SessionStatus.state")(() => Effect.succeed(new Map<SessionID, Info>())),
)
const get = Effect.fn("SessionStatus.get")(function* (sessionID: SessionID) {
const data = yield* InstanceState.get(state)
return data.get(sessionID) ?? { type: "idle" as const }
})
const list = Effect.fn("SessionStatus.list")(function* () {
return new Map(yield* InstanceState.get(state))
})
const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info) {
const data = yield* InstanceState.get(state)
yield* bus.publish(Event.Status, { sessionID, status })
if (status.type === "idle") {
yield* bus.publish(Event.Idle, { sessionID })
data.delete(sessionID)
return
}
data.set(sessionID, status)
})
return Service.of({ get, list, set })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
export * as SessionStatus from "./status"
+135 -133
View File
@@ -7,157 +7,159 @@ import * as Session from "./session"
import { MessageV2 } from "./message-v2"
import { SessionID, MessageID } from "./schema"
function unquoteGitPath(input: string) {
if (!input.startsWith('"')) return input
if (!input.endsWith('"')) return input
const body = input.slice(1, -1)
const bytes: number[] = []
export namespace SessionSummary {
function unquoteGitPath(input: string) {
if (!input.startsWith('"')) return input
if (!input.endsWith('"')) return input
const body = input.slice(1, -1)
const bytes: number[] = []
for (let i = 0; i < body.length; i++) {
const char = body[i]!
if (char !== "\\") {
bytes.push(char.charCodeAt(0))
continue
}
const next = body[i + 1]
if (!next) {
bytes.push("\\".charCodeAt(0))
continue
}
if (next >= "0" && next <= "7") {
const chunk = body.slice(i + 1, i + 4)
const match = chunk.match(/^[0-7]{1,3}/)
if (!match) {
bytes.push(next.charCodeAt(0))
i++
for (let i = 0; i < body.length; i++) {
const char = body[i]!
if (char !== "\\") {
bytes.push(char.charCodeAt(0))
continue
}
bytes.push(parseInt(match[0], 8))
i += match[0].length
continue
const next = body[i + 1]
if (!next) {
bytes.push("\\".charCodeAt(0))
continue
}
if (next >= "0" && next <= "7") {
const chunk = body.slice(i + 1, i + 4)
const match = chunk.match(/^[0-7]{1,3}/)
if (!match) {
bytes.push(next.charCodeAt(0))
i++
continue
}
bytes.push(parseInt(match[0], 8))
i += match[0].length
continue
}
const escaped =
next === "n"
? "\n"
: next === "r"
? "\r"
: next === "t"
? "\t"
: next === "b"
? "\b"
: next === "f"
? "\f"
: next === "v"
? "\v"
: next === "\\" || next === '"'
? next
: undefined
bytes.push((escaped ?? next).charCodeAt(0))
i++
}
const escaped =
next === "n"
? "\n"
: next === "r"
? "\r"
: next === "t"
? "\t"
: next === "b"
? "\b"
: next === "f"
? "\f"
: next === "v"
? "\v"
: next === "\\" || next === '"'
? next
: undefined
bytes.push((escaped ?? next).charCodeAt(0))
i++
return Buffer.from(bytes).toString()
}
return Buffer.from(bytes).toString()
}
export interface Interface {
readonly summarize: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect<void>
readonly diff: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Snapshot.FileDiff[]>
readonly computeDiff: (input: { messages: MessageV2.WithParts[] }) => Effect.Effect<Snapshot.FileDiff[]>
}
export interface Interface {
readonly summarize: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect<void>
readonly diff: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Snapshot.FileDiff[]>
readonly computeDiff: (input: { messages: MessageV2.WithParts[] }) => Effect.Effect<Snapshot.FileDiff[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionSummary") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionSummary") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const sessions = yield* Session.Service
const snapshot = yield* Snapshot.Service
const storage = yield* Storage.Service
const bus = yield* Bus.Service
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const sessions = yield* Session.Service
const snapshot = yield* Snapshot.Service
const storage = yield* Storage.Service
const bus = yield* Bus.Service
const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: { messages: MessageV2.WithParts[] }) {
let from: string | undefined
let to: string | undefined
for (const item of input.messages) {
if (!from) {
for (const part of item.parts) {
if (part.type === "step-start" && part.snapshot) {
from = part.snapshot
break
const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: {
messages: MessageV2.WithParts[]
}) {
let from: string | undefined
let to: string | undefined
for (const item of input.messages) {
if (!from) {
for (const part of item.parts) {
if (part.type === "step-start" && part.snapshot) {
from = part.snapshot
break
}
}
}
for (const part of item.parts) {
if (part.type === "step-finish" && part.snapshot) to = part.snapshot
}
}
for (const part of item.parts) {
if (part.type === "step-finish" && part.snapshot) to = part.snapshot
}
}
if (from && to) return yield* snapshot.diffFull(from, to)
return []
})
const summarize = Effect.fn("SessionSummary.summarize")(function* (input: {
sessionID: SessionID
messageID: MessageID
}) {
const all = yield* sessions.messages({ sessionID: input.sessionID })
if (!all.length) return
const diffs = yield* computeDiff({ messages: all })
yield* sessions.setSummary({
sessionID: input.sessionID,
summary: {
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
files: diffs.length,
},
if (from && to) return yield* snapshot.diffFull(from, to)
return []
})
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
const messages = all.filter(
(m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID),
)
const target = messages.find((m) => m.info.id === input.messageID)
if (!target || target.info.role !== "user") return
const msgDiffs = yield* computeDiff({ messages })
target.info.summary = { ...target.info.summary, diffs: msgDiffs }
yield* sessions.updateMessage(target.info)
})
const summarize = Effect.fn("SessionSummary.summarize")(function* (input: {
sessionID: SessionID
messageID: MessageID
}) {
const all = yield* sessions.messages({ sessionID: input.sessionID })
if (!all.length) return
const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) {
const diffs = yield* storage
.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID])
.pipe(Effect.catch(() => Effect.succeed([] as Snapshot.FileDiff[])))
const next = diffs.map((item) => {
const file = unquoteGitPath(item.file)
if (file === item.file) return item
return { ...item, file }
const diffs = yield* computeDiff({ messages: all })
yield* sessions.setSummary({
sessionID: input.sessionID,
summary: {
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
files: diffs.length,
},
})
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
const messages = all.filter(
(m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID),
)
const target = messages.find((m) => m.info.id === input.messageID)
if (!target || target.info.role !== "user") return
const msgDiffs = yield* computeDiff({ messages })
target.info.summary = { ...target.info.summary, diffs: msgDiffs }
yield* sessions.updateMessage(target.info)
})
const changed = next.some((item, i) => item.file !== diffs[i]?.file)
if (changed) yield* storage.write(["session_diff", input.sessionID], next).pipe(Effect.ignore)
return next
})
return Service.of({ summarize, diff, computeDiff })
}),
)
const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) {
const diffs = yield* storage
.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID])
.pipe(Effect.catch(() => Effect.succeed([] as Snapshot.FileDiff[])))
const next = diffs.map((item) => {
const file = unquoteGitPath(item.file)
if (file === item.file) return item
return { ...item, file }
})
const changed = next.some((item, i) => item.file !== diffs[i]?.file)
if (changed) yield* storage.write(["session_diff", input.sessionID], next).pipe(Effect.ignore)
return next
})
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(Session.defaultLayer),
Layer.provide(Snapshot.defaultLayer),
Layer.provide(Storage.defaultLayer),
Layer.provide(Bus.layer),
),
)
return Service.of({ summarize, diff, computeDiff })
}),
)
export const DiffInput = z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod.optional(),
})
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(Session.defaultLayer),
Layer.provide(Snapshot.defaultLayer),
Layer.provide(Storage.defaultLayer),
Layer.provide(Bus.layer),
),
)
export * as SessionSummary from "./summary"
export const DiffInput = z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod.optional(),
})
}
+63 -63
View File
@@ -16,69 +16,69 @@ import type { Agent } from "@/agent/agent"
import { Permission } from "@/permission"
import { Skill } from "@/skill"
export function provider(model: Provider.Model) {
if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3"))
return [PROMPT_BEAST]
if (model.api.id.includes("gpt")) {
if (model.api.id.includes("codex")) {
return [PROMPT_CODEX]
export namespace SystemPrompt {
export function provider(model: Provider.Model) {
if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3"))
return [PROMPT_BEAST]
if (model.api.id.includes("gpt")) {
if (model.api.id.includes("codex")) {
return [PROMPT_CODEX]
}
return [PROMPT_GPT]
}
return [PROMPT_GPT]
if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI]
if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC]
if (model.api.id.toLowerCase().includes("trinity")) return [PROMPT_TRINITY]
if (model.api.id.toLowerCase().includes("kimi")) return [PROMPT_KIMI]
return [PROMPT_DEFAULT]
}
if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI]
if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC]
if (model.api.id.toLowerCase().includes("trinity")) return [PROMPT_TRINITY]
if (model.api.id.toLowerCase().includes("kimi")) return [PROMPT_KIMI]
return [PROMPT_DEFAULT]
export interface Interface {
readonly environment: (model: Provider.Model) => string[]
readonly skills: (agent: Agent.Info) => Effect.Effect<string | undefined>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SystemPrompt") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const skill = yield* Skill.Service
return Service.of({
environment(model) {
const project = Instance.project
return [
[
`You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
`Here is some useful information about the environment you are running in:`,
`<env>`,
` Working directory: ${Instance.directory}`,
` Workspace root folder: ${Instance.worktree}`,
` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
` Today's date: ${new Date().toDateString()}`,
`</env>`,
].join("\n"),
]
},
skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) {
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
const list = yield* skill.available(agent)
return [
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
// the agents seem to ingest the information about skills a bit better if we present a more verbose
// version of them here and a less verbose version in tool description, rather than vice versa.
Skill.fmt(list, { verbose: true }),
].join("\n")
}),
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer))
}
export interface Interface {
readonly environment: (model: Provider.Model) => string[]
readonly skills: (agent: Agent.Info) => Effect.Effect<string | undefined>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SystemPrompt") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const skill = yield* Skill.Service
return Service.of({
environment(model) {
const project = Instance.project
return [
[
`You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
`Here is some useful information about the environment you are running in:`,
`<env>`,
` Working directory: ${Instance.directory}`,
` Workspace root folder: ${Instance.worktree}`,
` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
` Today's date: ${new Date().toDateString()}`,
`</env>`,
].join("\n"),
]
},
skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) {
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
const list = yield* skill.available(agent)
return [
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
// the agents seem to ingest the information about skills a bit better if we present a more verbose
// version of them here and a less verbose version in tool description, rather than vice versa.
Skill.fmt(list, { verbose: true }),
].join("\n")
}),
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer))
export * as SystemPrompt from "./system"
+74 -69
View File
@@ -6,75 +6,80 @@ import z from "zod"
import { Database, eq, asc } from "../storage"
import { TodoTable } from "./session.sql"
export const Info = z
.object({
content: z.string().describe("Brief description of the task"),
status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"),
priority: z.string().describe("Priority level of the task: high, medium, low"),
})
.meta({ ref: "Todo" })
export type Info = z.infer<typeof Info>
export namespace Todo {
export const Info = z
.object({
content: z.string().describe("Brief description of the task"),
status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"),
priority: z.string().describe("Priority level of the task: high, medium, low"),
})
.meta({ ref: "Todo" })
export type Info = z.infer<typeof Info>
export const Event = {
Updated: BusEvent.define(
"todo.updated",
z.object({
sessionID: SessionID.zod,
todos: z.array(Info),
export const Event = {
Updated: BusEvent.define(
"todo.updated",
z.object({
sessionID: SessionID.zod,
todos: z.array(Info),
}),
),
}
export interface Interface {
readonly update: (input: { sessionID: SessionID; todos: Info[] }) => Effect.Effect<void>
readonly get: (sessionID: SessionID) => Effect.Effect<Info[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTodo") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const update = Effect.fn("Todo.update")(function* (input: { sessionID: SessionID; todos: Info[] }) {
yield* Effect.sync(() =>
Database.transaction((db) => {
db.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run()
if (input.todos.length === 0) return
db.insert(TodoTable)
.values(
input.todos.map((todo, position) => ({
session_id: input.sessionID,
content: todo.content,
status: todo.status,
priority: todo.priority,
position,
})),
)
.run()
}),
)
yield* bus.publish(Event.Updated, input)
})
const get = Effect.fn("Todo.get")(function* (sessionID: SessionID) {
const rows = yield* Effect.sync(() =>
Database.use((db) =>
db
.select()
.from(TodoTable)
.where(eq(TodoTable.session_id, sessionID))
.orderBy(asc(TodoTable.position))
.all(),
),
)
return rows.map((row) => ({
content: row.content,
status: row.status,
priority: row.priority,
}))
})
return Service.of({ update, get })
}),
),
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
}
export interface Interface {
readonly update: (input: { sessionID: SessionID; todos: Info[] }) => Effect.Effect<void>
readonly get: (sessionID: SessionID) => Effect.Effect<Info[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTodo") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const update = Effect.fn("Todo.update")(function* (input: { sessionID: SessionID; todos: Info[] }) {
yield* Effect.sync(() =>
Database.transaction((db) => {
db.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run()
if (input.todos.length === 0) return
db.insert(TodoTable)
.values(
input.todos.map((todo, position) => ({
session_id: input.sessionID,
content: todo.content,
status: todo.status,
priority: todo.priority,
position,
})),
)
.run()
}),
)
yield* bus.publish(Event.Updated, input)
})
const get = Effect.fn("Todo.get")(function* (sessionID: SessionID) {
const rows = yield* Effect.sync(() =>
Database.use((db) =>
db.select().from(TodoTable).where(eq(TodoTable.session_id, sessionID)).orderBy(asc(TodoTable.position)).all(),
),
)
return rows.map((row) => ({
content: row.content,
status: row.status,
priority: row.priority,
}))
})
return Service.of({ update, get })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
export * as Todo from "./todo"

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