mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-04 09:16:20 -04:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db077a5488 | |||
| 6219fe1482 | |||
| 7b3bb9a761 | |||
| dc38f22bd8 | |||
| 220e3e9a2b | |||
| f135c0b5ee | |||
| ebe6ea580d | |||
| ee708040f6 | |||
| 61c4815a37 | |||
| 01bb54a94d | |||
| f592c3846b | |||
| c026e25088 | |||
| 8ba73bed23 | |||
| 4f8986aa48 | |||
| 9c87a144e8 | |||
| 5b9fa32255 | |||
| f13778215a | |||
| 326471a25c | |||
| 6405e3a7b1 | |||
| 8afb625bab | |||
| c59df636cc | |||
| 94878d76f8 | |||
| 5022895e2b | |||
| 54046e0b98 | |||
| d2cb1613ac | |||
| 266fb93422 | |||
| 51d8219c46 | |||
| d6af5a686c | |||
| 39342b0e75 | |||
| 54078c4cae | |||
| c0bfccc15e | |||
| 53dc7b1649 | |||
| 635970b0a1 | |||
| 059b32c212 | |||
| 2704ad9110 | |||
| 06d247c709 | |||
| 974fa1b8b1 | |||
| fb02744460 | |||
| 79732ab175 | |||
| f6dbb2f3e0 | |||
| fdd5b77bfd | |||
| cde105e7a8 | |||
| 1291e82bb4 | |||
| 19d15d9ff7 | |||
| 4e27804160 | |||
| bae80af1b4 | |||
| f9aa3d77cd | |||
| 5d47ea0918 | |||
| c03fa36257 | |||
| 1089fa0415 | |||
| 715786bbf9 | |||
| 218eca7c2b | |||
| 30fc791480 | |||
| e2d161dfdd | |||
| 23d48a7cf1 |
@@ -14,6 +14,7 @@
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||
import {
|
||||
batch,
|
||||
onCleanup,
|
||||
@@ -324,6 +324,7 @@ 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()
|
||||
@@ -518,26 +519,6 @@ 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<{
|
||||
@@ -571,68 +552,6 @@ 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()
|
||||
@@ -663,21 +582,52 @@ 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 reviewDiffs = createMemo(() => {
|
||||
if (store.changes === "git") return list(vcs.diff.git)
|
||||
if (store.changes === "branch") return list(vcs.diff.branch)
|
||||
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 ?? []) : []
|
||||
return turnDiffs()
|
||||
})
|
||||
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
|
||||
}
|
||||
const reviewCount = () => reviewDiffs().length
|
||||
const hasReview = () => reviewCount() > 0
|
||||
const reviewReady = () => {
|
||||
if (store.changes === "git" || store.changes === "branch") return !vcsQuery.isPending
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (store.newSessionWorktree === "create") return "create"
|
||||
@@ -897,27 +847,6 @@ 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 =
|
||||
@@ -1051,13 +980,6 @@ 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
|
||||
@@ -1066,22 +988,12 @@ 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
|
||||
void loadVcs(mode, true)
|
||||
refreshVcs()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
|
||||
@@ -23,6 +23,10 @@ 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.
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/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}`)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/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$/, "")}*`,
|
||||
)
|
||||
@@ -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 | HttpClient.HttpClient> = Layer.effect(
|
||||
export const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient.HttpClient> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const repo = yield* AccountRepo
|
||||
const repo = yield* AccountRepo.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const httpRead = withTransientReadRetry(http)
|
||||
const httpOk = HttpClient.filterStatusOk(http)
|
||||
@@ -452,3 +452,5 @@ export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpCli
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
||||
|
||||
export * as Account from "./account"
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
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"
|
||||
@@ -13,154 +13,154 @@ type DbTransactionCallback<A> = Parameters<typeof Database.transaction<A>>[0]
|
||||
|
||||
const ACCOUNT_STATE_ID = 1
|
||||
|
||||
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 interface Interface {
|
||||
readonly active: () => Effect.Effect<Option.Option<Info>, AccountRepoError>
|
||||
readonly list: () => Effect.Effect<Info[], AccountRepoError>
|
||||
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
|
||||
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
|
||||
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
|
||||
readonly persistToken: (input: {
|
||||
accountID: AccountID
|
||||
accessToken: AccessToken
|
||||
refreshToken: RefreshToken
|
||||
expiry: Option.Option<number>
|
||||
}) => Effect.Effect<void, AccountRepoError>
|
||||
readonly persistAccount: (input: {
|
||||
id: AccountID
|
||||
email: string
|
||||
url: string
|
||||
accessToken: AccessToken
|
||||
refreshToken: RefreshToken
|
||||
expiry: number
|
||||
orgID: Option.Option<OrgID>
|
||||
}) => Effect.Effect<void, AccountRepoError>
|
||||
}
|
||||
|
||||
export class 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 class Service extends Context.Service<Service, Interface>()("@opencode/AccountRepo") {}
|
||||
|
||||
const query = <A>(f: DbTransactionCallback<A>) =>
|
||||
Effect.try({
|
||||
try: () => Database.use(f),
|
||||
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
|
||||
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 },
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
const tx = <A>(f: DbTransactionCallback<A>) =>
|
||||
Effect.try({
|
||||
try: () => Database.transaction(f),
|
||||
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
|
||||
})
|
||||
const active = Effect.fn("AccountRepo.active")(() =>
|
||||
query((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))),
|
||||
)
|
||||
|
||||
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 list = Effect.fn("AccountRepo.list")(() =>
|
||||
query((db) =>
|
||||
db
|
||||
.select()
|
||||
.from(AccountTable)
|
||||
.all()
|
||||
.map((row: AccountRow) => decode({ ...row, 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 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 active = Effect.fn("AccountRepo.active")(() =>
|
||||
query((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))),
|
||||
)
|
||||
const use = Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
|
||||
query((db) => state(db, accountID, orgID)).pipe(Effect.asVoid),
|
||||
)
|
||||
|
||||
const list = Effect.fn("AccountRepo.list")(() =>
|
||||
query((db) =>
|
||||
db
|
||||
.select()
|
||||
.from(AccountTable)
|
||||
.all()
|
||||
.map((row: AccountRow) => decode({ ...row, active_org_id: null })),
|
||||
),
|
||||
)
|
||||
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 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 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 use = Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
|
||||
query((db) => state(db, accountID, orgID)).pipe(Effect.asVoid),
|
||||
)
|
||||
const persistAccount = Effect.fn("AccountRepo.persistAccount")((input) =>
|
||||
tx((db) => {
|
||||
const url = normalizeServerUrl(input.url)
|
||||
|
||||
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,
|
||||
db.insert(AccountTable)
|
||||
.values({
|
||||
id: input.id,
|
||||
email: input.email,
|
||||
url,
|
||||
access_token: input.accessToken,
|
||||
refresh_token: input.refreshToken,
|
||||
token_expiry: input.expiry,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: AccountTable.id,
|
||||
set: {
|
||||
email: input.email,
|
||||
url,
|
||||
access_token: input.accessToken,
|
||||
refresh_token: input.refreshToken,
|
||||
token_expiry: input.expiry,
|
||||
})
|
||||
.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),
|
||||
)
|
||||
},
|
||||
})
|
||||
.run()
|
||||
void state(db, input.id, input.orgID)
|
||||
}).pipe(Effect.asVoid),
|
||||
)
|
||||
|
||||
return AccountRepo.of({
|
||||
active,
|
||||
list,
|
||||
remove,
|
||||
use,
|
||||
getRow,
|
||||
persistToken,
|
||||
persistAccount,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
return Service.of({
|
||||
active,
|
||||
list,
|
||||
remove,
|
||||
use,
|
||||
getRow,
|
||||
persistToken,
|
||||
persistAccount,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export * as AccountRepo from "./repo"
|
||||
|
||||
+1511
-1515
File diff suppressed because it is too large
Load Diff
@@ -24,389 +24,388 @@ import { InstanceState } from "@/effect"
|
||||
import * as Option from "effect/Option"
|
||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||
|
||||
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,
|
||||
})
|
||||
.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<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
|
||||
}>
|
||||
}
|
||||
|
||||
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 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 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<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",
|
||||
},
|
||||
})
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
export * as Agent from "./agent"
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
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))
|
||||
@@ -1,2 +1,97 @@
|
||||
export * as Auth from "./auth"
|
||||
export { OAUTH_DUMMY_KEY } from "./auth"
|
||||
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 "."
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import z from "zod"
|
||||
import type { ZodType } from "zod"
|
||||
|
||||
export namespace BusEvent {
|
||||
export type Definition = ReturnType<typeof define>
|
||||
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,
|
||||
}
|
||||
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 define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
|
||||
const result = {
|
||||
type,
|
||||
properties,
|
||||
}
|
||||
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 * as BusEvent from "./bus-event"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { cmd } from "./cmd"
|
||||
import { Duration, Effect, Match, Option } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
|
||||
import { type AccountError } from "@/account/schema"
|
||||
import { Account } from "@/account/account"
|
||||
import { AccountID, OrgID, PollExpired, type PollResult, type AccountError } from "@/account/schema"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import * as Prompt from "../effect/prompt"
|
||||
import open from "open"
|
||||
|
||||
@@ -113,7 +113,11 @@ export function DialogSessionList() {
|
||||
const today = new Date().toDateString()
|
||||
return sessions()
|
||||
.filter((x) => x.parentID === undefined)
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
.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
|
||||
})
|
||||
.map((x) => {
|
||||
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ const TuiLegacy = z
|
||||
interface MigrateInput {
|
||||
cwd: string
|
||||
directories: string[]
|
||||
custom?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * as TuiConfig from "./tui"
|
||||
|
||||
import z from "zod"
|
||||
import { mergeDeep, unique } from "remeda"
|
||||
import { Context, Effect, Fiber, Layer } from "effect"
|
||||
@@ -16,198 +18,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"
|
||||
|
||||
export namespace TuiConfig {
|
||||
const log = Log.create({ service: "tui.config" })
|
||||
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 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
|
||||
function normalize(raw: Record<string, unknown>) {
|
||||
const data = { ...raw }
|
||||
if (!("tui" in data)) return data
|
||||
if (!isRecord(data.tui)) {
|
||||
delete data.tui
|
||||
return {
|
||||
...tui,
|
||||
...data,
|
||||
}
|
||||
return 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 }) {
|
||||
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 acc: Acc = {
|
||||
result: {},
|
||||
}
|
||||
|
||||
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
||||
await mergeFile(acc, file, ctx)
|
||||
}
|
||||
|
||||
if (custom) {
|
||||
await mergeFile(acc, custom, ctx)
|
||||
log.debug("loaded custom tui config", { path: custom })
|
||||
}
|
||||
|
||||
for (const file of projectFiles) {
|
||||
await mergeFile(acc, file, ctx)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
return {
|
||||
config: acc.result,
|
||||
dirs: acc.result.plugin?.length ? dirs : [],
|
||||
}
|
||||
}
|
||||
|
||||
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 {}
|
||||
})
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
return {
|
||||
config: acc.result,
|
||||
dirs: acc.result.plugin?.length ? dirs : [],
|
||||
}
|
||||
}
|
||||
|
||||
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 ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" })
|
||||
.then((expanded) => ConfigParse.jsonc(expanded, configFilepath))
|
||||
.then((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 ConfigParse.schema(Info, normalize(data), configFilepath)
|
||||
})
|
||||
.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(filePath)
|
||||
Filesystem.readJson<Record<string, any>>(filePath)
|
||||
.then((x) => {
|
||||
setStore(x)
|
||||
})
|
||||
|
||||
@@ -918,113 +918,113 @@ async function installPluginBySpec(
|
||||
}
|
||||
}
|
||||
|
||||
export namespace TuiPluginRuntime {
|
||||
let dir = ""
|
||||
let loaded: Promise<void> | undefined
|
||||
let runtime: RuntimeState | undefined
|
||||
export const Slot = View
|
||||
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}`)
|
||||
}
|
||||
return loaded
|
||||
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}`)
|
||||
}
|
||||
|
||||
dir = cwd
|
||||
loaded = load(input)
|
||||
return loaded
|
||||
}
|
||||
|
||||
export function list() {
|
||||
if (!runtime) return []
|
||||
return listPluginStatus(runtime)
|
||||
}
|
||||
dir = cwd
|
||||
loaded = load(input)
|
||||
return loaded
|
||||
}
|
||||
|
||||
export async function activatePlugin(id: string) {
|
||||
return activatePluginById(runtime, id, true)
|
||||
}
|
||||
export function list() {
|
||||
if (!runtime) return []
|
||||
return listPluginStatus(runtime)
|
||||
}
|
||||
|
||||
export async function deactivatePlugin(id: string) {
|
||||
return deactivatePluginById(runtime, id, true)
|
||||
}
|
||||
export async function activatePlugin(id: string) {
|
||||
return activatePluginById(runtime, id, true)
|
||||
}
|
||||
|
||||
export async function addPlugin(spec: string) {
|
||||
return addPluginBySpec(runtime, spec)
|
||||
}
|
||||
export async function deactivatePlugin(id: string) {
|
||||
return deactivatePluginById(runtime, id, true)
|
||||
}
|
||||
|
||||
export async function installPlugin(spec: string, options?: { global?: boolean }) {
|
||||
return installPluginBySpec(runtime, spec, options?.global)
|
||||
}
|
||||
export async function addPlugin(spec: string) {
|
||||
return addPluginBySpec(runtime, spec)
|
||||
}
|
||||
|
||||
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 async function installPlugin(spec: string, options?: { global?: boolean }) {
|
||||
return installPluginBySpec(runtime, spec, options?.global)
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
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,
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
export * as TuiPluginRuntime from "./runtime"
|
||||
|
||||
@@ -54,7 +54,6 @@ 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"
|
||||
@@ -88,6 +87,7 @@ 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,31 +991,7 @@ export function Session() {
|
||||
const revertInfo = createMemo(() => session()?.revert)
|
||||
const revertMessageID = createMemo(() => revertInfo()?.messageID)
|
||||
|
||||
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 revertDiffFiles = createMemo(() => getRevertDiffFiles(revertInfo()?.diff ?? ""))
|
||||
|
||||
const revertRevertedMessages = createMemo(() => {
|
||||
const messageID = revertMessageID()
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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 []
|
||||
}
|
||||
}
|
||||
@@ -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 = data?.suggestions as string[] | undefined
|
||||
const suggestions: string[] = Array.isArray(data?.suggestions) ? data.suggestions : []
|
||||
return [
|
||||
`Model not found: ${data?.providerID}/${data?.modelID}`,
|
||||
...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
|
||||
...(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 = data?.issues as Array<{ message: string; path: string[] }> | undefined
|
||||
const issues: Array<{ message: string; path: string[] }> = Array.isArray(data?.issues) ? data.issues : []
|
||||
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")
|
||||
}
|
||||
|
||||
|
||||
@@ -8,52 +8,52 @@ const log = Log.create({ service: "heap" })
|
||||
const MINUTE = 60_000
|
||||
const LIMIT = 2 * 1024 * 1024 * 1024
|
||||
|
||||
export namespace Heap {
|
||||
let timer: Timer | undefined
|
||||
let lock = false
|
||||
let armed = true
|
||||
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,
|
||||
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,
|
||||
})
|
||||
})
|
||||
|
||||
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?.()
|
||||
lock = false
|
||||
}
|
||||
|
||||
timer = setInterval(() => {
|
||||
void run()
|
||||
}, MINUTE)
|
||||
timer.unref?.()
|
||||
}
|
||||
|
||||
export * as Heap from "./heap"
|
||||
|
||||
+113
-113
@@ -3,131 +3,131 @@ import { EOL } from "os"
|
||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||
import { logo as glyphs } from "./logo"
|
||||
|
||||
export namespace UI {
|
||||
const wordmark = [
|
||||
`⠀ ▄ `,
|
||||
`█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█`,
|
||||
`█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀`,
|
||||
`▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`,
|
||||
]
|
||||
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)
|
||||
}
|
||||
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) => {
|
||||
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(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(row)
|
||||
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())
|
||||
})
|
||||
})
|
||||
const result: string[] = []
|
||||
const reset = "\x1b[0m"
|
||||
const left = {
|
||||
fg: "\x1b[90m",
|
||||
shadow: "\x1b[38;5;235m",
|
||||
bg: "\x1b[48;5;235m",
|
||||
}
|
||||
|
||||
export function error(message: string) {
|
||||
if (message.startsWith("Error: ")) {
|
||||
message = message.slice("Error: ".length)
|
||||
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)
|
||||
}
|
||||
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
|
||||
}
|
||||
|
||||
export function markdown(text: string): string {
|
||||
return text
|
||||
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 function error(message: string) {
|
||||
if (message.startsWith("Error: ")) {
|
||||
message = message.slice("Error: ".length)
|
||||
}
|
||||
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
|
||||
}
|
||||
|
||||
export function markdown(text: string): string {
|
||||
return text
|
||||
}
|
||||
|
||||
export * as UI from "./ui"
|
||||
|
||||
@@ -12,14 +12,12 @@ 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"
|
||||
import { Account } from "@/account/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"
|
||||
@@ -38,6 +36,9 @@ 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" })
|
||||
|
||||
@@ -187,56 +188,8 @@ export const Info = z
|
||||
)
|
||||
.optional()
|
||||
.describe("MCP (Model Context Protocol) server configurations"),
|
||||
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.",
|
||||
},
|
||||
),
|
||||
formatter: ConfigFormatter.Info.optional(),
|
||||
lsp: ConfigLSP.Info.optional(),
|
||||
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(),
|
||||
@@ -375,24 +328,16 @@ export const layer = Layer.effect(
|
||||
text: string,
|
||||
options: { path: string } | { dir: string; source: string },
|
||||
) {
|
||||
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,
|
||||
}),
|
||||
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
|
||||
|
||||
yield* Effect.promise(() => resolveLoadedPlugins(data, options.path))
|
||||
if (!data.$schema) {
|
||||
data.$schema = "https://opencode.ai/config.json"
|
||||
@@ -773,17 +718,16 @@ 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.parse(Info, before, file)
|
||||
const merged = mergeDeep(writable(existing), input)
|
||||
const existing = ConfigParse.schema(Info, ConfigParse.jsonc(before, file), file)
|
||||
const merged = mergeDeep(writable(existing), writable(config))
|
||||
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
|
||||
next = merged
|
||||
} else {
|
||||
const updated = patchJsonc(before, input)
|
||||
next = ConfigParse.parse(Info, updated, file)
|
||||
const updated = patchJsonc(before, writable(config))
|
||||
next = ConfigParse.schema(Info, ConfigParse.jsonc(updated, file), file)
|
||||
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
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,6 +2,8 @@ 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"
|
||||
|
||||
@@ -106,7 +106,12 @@ 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().default("ctrl+-,super+z").describe("Undo 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_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"),
|
||||
input_word_forward: z
|
||||
.string()
|
||||
@@ -144,7 +149,12 @@ 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").describe("Suspend terminal"),
|
||||
terminal_suspend: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("ctrl+z")
|
||||
.transform((v) => (process.platform === "win32" ? "none" : v))
|
||||
.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"),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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>
|
||||
@@ -1,70 +1,68 @@
|
||||
import z from "zod"
|
||||
|
||||
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 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 const Info = z.discriminatedUnion("type", [Local, Remote])
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export * as ConfigMCP from "./mcp"
|
||||
|
||||
@@ -1,80 +1,44 @@
|
||||
export * as ConfigParse from "./parse"
|
||||
|
||||
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
|
||||
import { type ParseError as JsoncParseError, parse as parseJsoncImpl, 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 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 {
|
||||
export function jsonc(text: string, filepath: string): unknown {
|
||||
const errors: JsoncParseError[] = []
|
||||
const data = parseJsonc(text, errors, { allowTrailingComma: true })
|
||||
const data = parseJsoncImpl(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(text, errors)}\n--- End ---`,
|
||||
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${issues}\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: filepath,
|
||||
path: source,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,120 +1,120 @@
|
||||
import z from "zod"
|
||||
|
||||
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(),
|
||||
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(),
|
||||
}),
|
||||
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()
|
||||
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()
|
||||
|
||||
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 type Info = z.infer<typeof Info>
|
||||
|
||||
export * as ConfigProvider from "./provider"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import z from "zod"
|
||||
|
||||
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 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 type Info = z.infer<typeof Info>
|
||||
|
||||
export * as ConfigSkills from "./skills"
|
||||
|
||||
@@ -16,6 +16,11 @@ type ParseSource =
|
||||
dir: string
|
||||
}
|
||||
|
||||
type SubstituteInput = ParseSource & {
|
||||
text: string
|
||||
missing?: "error" | "empty"
|
||||
}
|
||||
|
||||
function source(input: ParseSource) {
|
||||
return input.type === "path" ? input.path : input.source
|
||||
}
|
||||
@@ -25,8 +30,9 @@ function dir(input: ParseSource) {
|
||||
}
|
||||
|
||||
/** Apply {env:VAR} and {file:path} substitutions to config text. */
|
||||
export async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
|
||||
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
||||
export async function substitute(input: SubstituteInput) {
|
||||
const missing = input.missing ?? "error"
|
||||
let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
||||
return process.env[varName] || ""
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
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 type WorkspaceAdaptorEntry = {
|
||||
type: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
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>
|
||||
|
||||
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: WorkspaceInfo.shape.name,
|
||||
branch: WorkspaceInfo.shape.branch.unwrap(),
|
||||
directory: WorkspaceInfo.shape.directory.unwrap(),
|
||||
name: z.string(),
|
||||
branch: z.string(),
|
||||
directory: z.string(),
|
||||
})
|
||||
|
||||
export const WorktreeAdaptor: WorkspaceAdaptor = {
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
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 = 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,
|
||||
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 type WorkspaceInfo = z.infer<typeof WorkspaceInfo>
|
||||
.annotate({ identifier: "Workspace" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type WorkspaceInfo = Schema.Schema.Type<typeof WorkspaceInfo>
|
||||
|
||||
export type Target =
|
||||
| {
|
||||
|
||||
@@ -25,174 +25,241 @@ 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 namespace Workspace {
|
||||
export const Info = WorkspaceInfo.meta({
|
||||
ref: "Workspace",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
export const Info = WorkspaceInfo
|
||||
export type Info = WorkspaceInfo
|
||||
|
||||
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>
|
||||
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>
|
||||
|
||||
const Restore = z.object({
|
||||
workspaceID: WorkspaceID.zod,
|
||||
sessionID: SessionID.zod,
|
||||
total: z.number().int().min(0),
|
||||
step: z.number().int().min(0),
|
||||
})
|
||||
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),
|
||||
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,
|
||||
}
|
||||
|
||||
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: Info.shape.type,
|
||||
branch: Info.shape.branch,
|
||||
projectID: ProjectID.zod,
|
||||
extra: Info.shape.extra,
|
||||
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()
|
||||
})
|
||||
|
||||
export const create = fn(CreateInput, async (input) => {
|
||||
const id = WorkspaceID.ascending(input.id)
|
||||
const adaptor = await getAdaptor(input.projectID, input.type)
|
||||
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)
|
||||
|
||||
const config = await adaptor.configure({ ...input, id, name: Slug.create(), directory: null })
|
||||
startSync(info)
|
||||
|
||||
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,
|
||||
}
|
||||
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
|
||||
},
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
return info
|
||||
})
|
||||
|
||||
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)
|
||||
const SessionRestoreInput = z.object({
|
||||
workspaceID: WorkspaceID.zod,
|
||||
sessionID: SessionID.zod,
|
||||
})
|
||||
|
||||
startSync(info)
|
||||
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}`)
|
||||
|
||||
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 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,
|
||||
},
|
||||
})
|
||||
|
||||
return info
|
||||
})
|
||||
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 SessionRestoreInput = z.object({
|
||||
workspaceID: WorkspaceID.zod,
|
||||
sessionID: SessionID.zod,
|
||||
})
|
||||
const all = rows
|
||||
|
||||
export const sessionRestore = fn(SessionRestoreInput, async (input) => {
|
||||
log.info("session restore requested", {
|
||||
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,
|
||||
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,
|
||||
})
|
||||
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: {
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
workspace: input.workspaceID,
|
||||
payload: {
|
||||
type: Event.Restore.type,
|
||||
properties: {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
total,
|
||||
step: 0,
|
||||
},
|
||||
})
|
||||
|
||||
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", {
|
||||
},
|
||||
})
|
||||
for (const [i, events] of sets.entries()) {
|
||||
log.info("session restore batch starting", {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
workspaceType: space.type,
|
||||
directory: space.directory,
|
||||
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,
|
||||
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,
|
||||
@@ -202,329 +269,263 @@ export namespace Workspace {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
total,
|
||||
step: 0,
|
||||
step: i + 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
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,
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
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,
|
||||
})
|
||||
.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
|
||||
}
|
||||
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,
|
||||
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)}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function status(): ConnectionStatus[] {
|
||||
return [...connections.values()]
|
||||
}
|
||||
const log = Log.create({ service: "workspace-sync" })
|
||||
|
||||
function synced(state: Record<string, number>) {
|
||||
const ids = Object.keys(state)
|
||||
if (ids.length === 0) return true
|
||||
function route(url: string | URL, path: string) {
|
||||
const next = new URL(url)
|
||||
next.pathname = `${next.pathname.replace(/\/$/, "")}${path}`
|
||||
next.search = ""
|
||||
next.hash = ""
|
||||
return next
|
||||
}
|
||||
|
||||
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
|
||||
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") {
|
||||
void Filesystem.exists(target.directory).then((exists) => {
|
||||
setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist")
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
if (aborts.has(space.id)) return true
|
||||
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")
|
||||
|
||||
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)
|
||||
// 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") {
|
||||
void Filesystem.exists(target.directory).then((exists) => {
|
||||
setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist")
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
export * as Workspace from "./workspace"
|
||||
|
||||
@@ -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"
|
||||
import { Account } from "@/account/account"
|
||||
import { Config } from "@/config"
|
||||
import { Git } from "@/git"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
|
||||
@@ -1,81 +1,81 @@
|
||||
import { Glob } from "@opencode-ai/shared/util/glob"
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
export * as FileIgnore from "./ignore"
|
||||
|
||||
@@ -37,23 +37,23 @@ const DARWIN_ROOT = ["/.DocumentRevisions-V100", "/.Spotlight-V100", "/.Trashes"
|
||||
|
||||
const WIN32_HOME = ["AppData", "Downloads", "Desktop", "Documents", "Pictures", "Music", "Videos", "OneDrive"]
|
||||
|
||||
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 []
|
||||
}
|
||||
/** 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 []
|
||||
}
|
||||
|
||||
export * as Protected from "./protected"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+104
-104
@@ -5,109 +5,109 @@ import { Flag } from "@/flag/flag"
|
||||
import type { SessionID } from "@/session/schema"
|
||||
import { Log } from "../util"
|
||||
|
||||
export namespace FileTime {
|
||||
const log = Log.create({ service: "file.time" })
|
||||
const log = Log.create({ service: "file.time" })
|
||||
|
||||
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 next = new Map<string, Stamp>()
|
||||
reads.set(sessionID, next)
|
||||
return next
|
||||
}
|
||||
|
||||
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 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
|
||||
|
||||
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 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))
|
||||
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 next = new Map<string, Stamp>()
|
||||
reads.set(sessionID, next)
|
||||
return next
|
||||
}
|
||||
|
||||
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 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
|
||||
|
||||
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 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))
|
||||
|
||||
export * as FileTime from "./time"
|
||||
|
||||
@@ -10,153 +10,99 @@ function falsy(key: string) {
|
||||
return value === "false" || value === "0"
|
||||
}
|
||||
|
||||
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"]
|
||||
|
||||
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
|
||||
export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
|
||||
export const OPENCODE_EXPERIMENTAL_FILEWATCHER = Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe(
|
||||
Config.withDefault(false),
|
||||
)
|
||||
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),
|
||||
)
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
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
|
||||
}
|
||||
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() {
|
||||
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"),
|
||||
|
||||
// Experimental
|
||||
OPENCODE_EXPERIMENTAL,
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe(
|
||||
Config.withDefault(false),
|
||||
),
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER").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"),
|
||||
|
||||
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"),
|
||||
|
||||
// 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() {
|
||||
return truthy("OPENCODE_DISABLE_PROJECT_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() {
|
||||
get OPENCODE_TUI_CONFIG() {
|
||||
return process.env["OPENCODE_TUI_CONFIG"]
|
||||
},
|
||||
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() {
|
||||
get OPENCODE_CONFIG_DIR() {
|
||||
return process.env["OPENCODE_CONFIG_DIR"]
|
||||
},
|
||||
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() {
|
||||
get OPENCODE_PURE() {
|
||||
return truthy("OPENCODE_PURE")
|
||||
},
|
||||
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() {
|
||||
get OPENCODE_PLUGIN_META_FILE() {
|
||||
return process.env["OPENCODE_PLUGIN_META_FILE"]
|
||||
},
|
||||
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() {
|
||||
get OPENCODE_CLIENT() {
|
||||
return process.env["OPENCODE_CLIENT"] ?? "cli"
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -41,39 +41,6 @@ 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) {
|
||||
@@ -149,6 +116,48 @@ 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 {
|
||||
|
||||
@@ -1,86 +1,86 @@
|
||||
import z from "zod"
|
||||
import { randomBytes } from "crypto"
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
export * as Identifier from "./id"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
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"
|
||||
@@ -167,7 +167,7 @@ export const layer = Layer.effect(
|
||||
|
||||
const servers: Record<string, LSPServer.Info> = {}
|
||||
|
||||
if (cfg.lsp === false) {
|
||||
if (!cfg.lsp) {
|
||||
log.info("all LSPs are disabled")
|
||||
} else {
|
||||
for (const server of Object.values(LSPServer)) {
|
||||
@@ -176,25 +176,27 @@ export const layer = Layer.effect(
|
||||
|
||||
filterExperimentalServers(servers)
|
||||
|
||||
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 },
|
||||
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,
|
||||
}),
|
||||
initialization: item.initialization,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,12 +442,11 @@ export const layer = Layer.effect(
|
||||
const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) {
|
||||
const results = yield* runAll((client) =>
|
||||
client.connection
|
||||
.sendRequest("workspace/symbol", { query })
|
||||
.then((result: any) => result.filter((x: Symbol) => kinds.includes(x.kind)))
|
||||
.then((result: any) => result.slice(0, 10))
|
||||
.catch(() => []),
|
||||
.sendRequest<Symbol[]>("workspace/symbol", { query })
|
||||
.then((result) => result.filter((x) => kinds.includes(x.kind)).slice(0, 10))
|
||||
.catch(() => [] as Symbol[]),
|
||||
)
|
||||
return results.flat() as Symbol[]
|
||||
return results.flat()
|
||||
})
|
||||
|
||||
const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) {
|
||||
@@ -506,30 +507,4 @@ export const layer = Layer.effect(
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
||||
|
||||
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>`
|
||||
}
|
||||
}
|
||||
export * as Diagnostic from "./diagnostic"
|
||||
|
||||
+133
-133
@@ -4,141 +4,141 @@ import { Global } from "../global"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
|
||||
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 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 class Service extends Context.Service<Service, Interface>()("@opencode/McpAuth") {}
|
||||
|
||||
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 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)
|
||||
}
|
||||
})
|
||||
|
||||
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 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))
|
||||
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 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 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)
|
||||
}
|
||||
})
|
||||
|
||||
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 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))
|
||||
|
||||
export * as McpAuth from "./auth"
|
||||
|
||||
@@ -56,177 +56,177 @@ interface PendingAuth {
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
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>()
|
||||
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 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"))
|
||||
}
|
||||
}, CALLBACK_TIMEOUT_MS)
|
||||
|
||||
pendingAuths.set(oauthState, { resolve, reject, timeout })
|
||||
})
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
}, CALLBACK_TIMEOUT_MS)
|
||||
|
||||
pendingAuths.set(oauthState, { resolve, reject, timeout })
|
||||
})
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
export * as McpOAuthCallback from "./oauth-callback"
|
||||
|
||||
@@ -124,8 +124,17 @@ export async function install(dir: string) {
|
||||
return
|
||||
}
|
||||
|
||||
const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({}))
|
||||
const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({}))
|
||||
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 declared = new Set([
|
||||
...Object.keys(pkg.dependencies || {}),
|
||||
|
||||
@@ -1 +1,680 @@
|
||||
export * as Patch from "./patch"
|
||||
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 "."
|
||||
|
||||
@@ -1,678 +0,0 @@
|
||||
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 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(),
|
||||
}),
|
||||
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]
|
||||
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/"))
|
||||
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")
|
||||
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",
|
||||
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,
|
||||
},
|
||||
// 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,
|
||||
output: {
|
||||
text: true,
|
||||
audio: false,
|
||||
image: false,
|
||||
video: false,
|
||||
pdf: false,
|
||||
},
|
||||
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))
|
||||
|
||||
// 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
|
||||
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))
|
||||
|
||||
// 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"
|
||||
|
||||
@@ -8,181 +8,181 @@ import { Flock } from "@opencode-ai/shared/util/flock"
|
||||
|
||||
import { parsePluginSpecifier, pluginSource } from "./shared"
|
||||
|
||||
export namespace PluginMeta {
|
||||
type Source = "file" | "npm"
|
||||
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 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 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,
|
||||
requested: parsePluginSpecifier(spec).version,
|
||||
version: await npmVersion(target),
|
||||
modified: file ? await modifiedAt(file) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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))
|
||||
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("|")
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
export * as PluginMeta from "./meta"
|
||||
|
||||
@@ -547,12 +547,14 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
},
|
||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||
if (modelID.startsWith("duo-workflow-")) {
|
||||
const workflowRef = options?.workflowRef as string | undefined
|
||||
const workflowRef = typeof options?.workflowRef === "string" ? options.workflowRef : 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: options?.workflowDefinition as string | undefined,
|
||||
workflowDefinition,
|
||||
})
|
||||
if (workflowRef) {
|
||||
model.selectedModelRef = workflowRef
|
||||
|
||||
@@ -8,222 +8,222 @@ import { Log } from "@/util"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { QuestionID } from "./schema"
|
||||
|
||||
export namespace Question {
|
||||
const log = Log.create({ service: "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)
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
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 PendingEntry {
|
||||
info: Request
|
||||
deferred: Deferred.Deferred<ReadonlyArray<Answer>, RejectedError>
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
interface State {
|
||||
pending: Map<QuestionID, PendingEntry>
|
||||
}
|
||||
|
||||
export class Prompt extends Schema.Class<Prompt>("QuestionPrompt")(base) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
// Service
|
||||
|
||||
export class Tool extends Schema.Class<Tool>("QuestionTool")({
|
||||
messageID: MessageID,
|
||||
callID: Schema.String,
|
||||
}) {
|
||||
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 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 class Service extends Context.Service<Service, Interface>()("@opencode/Question") {}
|
||||
|
||||
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 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 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)
|
||||
}
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
for (const item of state.pending.values()) {
|
||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||
}
|
||||
state.pending.clear()
|
||||
}),
|
||||
)
|
||||
|
||||
class Replied extends Schema.Class<Replied>("QuestionReplied")({
|
||||
sessionID: SessionID,
|
||||
requestID: QuestionID,
|
||||
answers: Schema.Array(Answer),
|
||||
}) {}
|
||||
return state
|
||||
}),
|
||||
)
|
||||
|
||||
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: {
|
||||
const ask = Effect.fn("Question.ask")(function* (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>>
|
||||
}
|
||||
}) {
|
||||
const pending = (yield* InstanceState.get(state)).pending
|
||||
const id = QuestionID.ascending()
|
||||
log.info("asking", { id, questions: input.questions.length })
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Question") {}
|
||||
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 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
|
||||
return yield* Effect.ensuring(
|
||||
Deferred.await(deferred),
|
||||
Effect.sync(() => {
|
||||
pending.delete(id)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
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)
|
||||
}),
|
||||
)
|
||||
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)
|
||||
})
|
||||
|
||||
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)
|
||||
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())
|
||||
})
|
||||
|
||||
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())
|
||||
})
|
||||
const list = Effect.fn("Question.list")(function* () {
|
||||
const pending = (yield* InstanceState.get(state)).pending
|
||||
return Array.from(pending.values(), (x) => x.info)
|
||||
})
|
||||
|
||||
const list = Effect.fn("Question.list")(function* () {
|
||||
const pending = (yield* InstanceState.get(state)).pending
|
||||
return Array.from(pending.values(), (x) => x.info)
|
||||
})
|
||||
return Service.of({ ask, reply, reject, list })
|
||||
}),
|
||||
)
|
||||
|
||||
return Service.of({ ask, reply, reject, list })
|
||||
}),
|
||||
)
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
}
|
||||
export * as Question from "."
|
||||
|
||||
@@ -10,7 +10,8 @@ import { MCP } from "../../mcp"
|
||||
import { Session } from "../../session"
|
||||
import { Config } from "../../config"
|
||||
import { ConsoleState } from "../../config/console-state"
|
||||
import { Account, AccountID, OrgID } from "../../account"
|
||||
import { Account } from "../../account/account"
|
||||
import { AccountID, OrgID } from "../../account/schema"
|
||||
import { AppRuntime } from "../../effect/app-runtime"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
|
||||
@@ -13,6 +13,7 @@ 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),
|
||||
@@ -25,106 +26,108 @@ const Headers = Schema.Struct({
|
||||
"x-opencode-directory": Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
export namespace ExperimentalHttpApiServer {
|
||||
function decode(input: string) {
|
||||
try {
|
||||
return decodeURIComponent(input)
|
||||
} catch {
|
||||
return input
|
||||
}
|
||||
function decode(input: string) {
|
||||
try {
|
||||
return decodeURIComponent(input)
|
||||
} catch {
|
||||
return input
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}) {}
|
||||
|
||||
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))
|
||||
})
|
||||
}),
|
||||
).layer
|
||||
|
||||
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 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 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)),
|
||||
).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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}) {}
|
||||
|
||||
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))
|
||||
})
|
||||
}),
|
||||
).layer
|
||||
|
||||
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 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 QuestionSecured = QuestionApi.middleware(Authorization)
|
||||
const PermissionSecured = PermissionApi.middleware(Authorization)
|
||||
const ProviderSecured = ProviderApi.middleware(Authorization)
|
||||
const WorkspaceSecured = WorkspaceApi.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 webHandler = lazy(() =>
|
||||
HttpRouter.toWebHandler(routes, {
|
||||
memoMap,
|
||||
}),
|
||||
)
|
||||
|
||||
export * as ExperimentalHttpApiServer from "./server"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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),
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import { Hono } from "hono"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import { Effect } from "effect"
|
||||
import { Context, Effect } from "effect"
|
||||
import z from "zod"
|
||||
import { Format } from "../../format"
|
||||
import { TuiRoutes } from "./tui"
|
||||
@@ -41,12 +41,16 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
||||
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
|
||||
const handler = ExperimentalHttpApiServer.webHandler().handler
|
||||
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))
|
||||
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))
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
@@ -2,8 +2,10 @@ 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"
|
||||
@@ -24,15 +26,7 @@ export const WorkspaceRoutes = lazy(() =>
|
||||
description: "Workspace adaptors",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
}),
|
||||
),
|
||||
),
|
||||
schema: resolver(WorkspaceAdaptorEntry.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -53,7 +47,7 @@ export const WorkspaceRoutes = lazy(() =>
|
||||
description: "Workspace created",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Workspace.Info),
|
||||
schema: resolver(Workspace.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -86,7 +80,7 @@ export const WorkspaceRoutes = lazy(() =>
|
||||
description: "Workspaces",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(Workspace.Info)),
|
||||
schema: resolver(Workspace.Info.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -107,7 +101,7 @@ export const WorkspaceRoutes = lazy(() =>
|
||||
description: "Workspace status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(Workspace.ConnectionStatus)),
|
||||
schema: resolver(Workspace.ConnectionStatus.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -129,7 +123,7 @@ export const WorkspaceRoutes = lazy(() =>
|
||||
description: "Workspace removed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Workspace.Info.optional()),
|
||||
schema: resolver(Workspace.Info.zod.optional()),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -139,7 +133,7 @@ export const WorkspaceRoutes = lazy(() =>
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: Workspace.Info.shape.id,
|
||||
id: WorkspaceID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
@@ -169,7 +163,7 @@ export const WorkspaceRoutes = lazy(() =>
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ id: Workspace.Info.shape.id })),
|
||||
validator("param", z.object({ id: WorkspaceID.zod })),
|
||||
validator("json", Workspace.sessionRestore.schema.omit({ workspaceID: true })),
|
||||
async (c) => {
|
||||
const { id } = c.req.valid("param")
|
||||
|
||||
@@ -3,58 +3,58 @@ import { Bonjour } from "bonjour-service"
|
||||
|
||||
const log = Log.create({ service: "mdns" })
|
||||
|
||||
export namespace MDNS {
|
||||
let bonjour: Bonjour | undefined
|
||||
let currentPort: number | undefined
|
||||
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 })
|
||||
if (bonjour) {
|
||||
try {
|
||||
bonjour.destroy()
|
||||
} catch {}
|
||||
}
|
||||
bonjour = undefined
|
||||
currentPort = undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function unpublish() {
|
||||
currentPort = port
|
||||
} catch (err) {
|
||||
log.error("mDNS publish failed", { error: err })
|
||||
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")
|
||||
} catch {}
|
||||
}
|
||||
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"
|
||||
|
||||
@@ -101,83 +101,78 @@ const app = (upgrade: UpgradeWebSocket) =>
|
||||
}),
|
||||
)
|
||||
|
||||
export namespace ServerProxy {
|
||||
const log = Log.Default.clone().tag("service", "server-proxy")
|
||||
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",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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", {
|
||||
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 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 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, {
|
||||
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", {
|
||||
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,
|
||||
signal: req.signal,
|
||||
}),
|
||||
env as never,
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
@@ -17,37 +17,22 @@ globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
|
||||
initProjectors()
|
||||
|
||||
export namespace Server {
|
||||
const log = Log.create({ service: "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)
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
function create(opts: { cors?: string[] }) {
|
||||
const app = new Hono()
|
||||
const runtime = adapter.create(app)
|
||||
|
||||
if (Flag.OPENCODE_WORKSPACE_ID) {
|
||||
return {
|
||||
app: app
|
||||
.onError(ErrorMiddleware)
|
||||
@@ -55,73 +40,88 @@ export namespace Server {
|
||||
.use(LoggerMiddleware)
|
||||
.use(CompressionMiddleware)
|
||||
.use(CorsMiddleware(opts))
|
||||
.use(FenceMiddleware)
|
||||
.route("/", ControlPlaneRoutes())
|
||||
.route("/", InstanceRoutes(runtime.upgradeWebSocket))
|
||||
.route("/", UIRoutes()),
|
||||
.route("/", InstanceRoutes(runtime.upgradeWebSocket)),
|
||||
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",
|
||||
},
|
||||
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")
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
}
|
||||
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",
|
||||
},
|
||||
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")
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
@@ -17,173 +17,172 @@ import { Effect, Layer, Context } from "effect"
|
||||
import { InstanceState } from "@/effect"
|
||||
import { isOverflow as overflow } from "./overflow"
|
||||
|
||||
export namespace SessionCompaction {
|
||||
const log = Log.create({ service: "session.compaction" })
|
||||
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: {
|
||||
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: {
|
||||
tokens: MessageV2.Assistant["tokens"]
|
||||
model: Provider.Model
|
||||
}) => Effect.Effect<boolean>
|
||||
readonly prune: (input: { sessionID: SessionID }) => Effect.Effect<void>
|
||||
readonly process: (input: {
|
||||
}) {
|
||||
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: {
|
||||
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>
|
||||
}
|
||||
}) {
|
||||
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
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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 hasContent =
|
||||
replay && messages.some((m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction"))
|
||||
if (!hasContent) {
|
||||
replay = undefined
|
||||
messages = input.messages
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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.
|
||||
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.
|
||||
@@ -213,200 +212,201 @@ 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,
|
||||
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 }],
|
||||
},
|
||||
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,
|
||||
})
|
||||
],
|
||||
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 === "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 === "continue" && input.auto) {
|
||||
if (replay) {
|
||||
const original = replay.info
|
||||
const replayMsg = yield* session.updateMessage({
|
||||
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: original.agent,
|
||||
model: original.model,
|
||||
format: original.format,
|
||||
tools: original.tools,
|
||||
system: original.system,
|
||||
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(),
|
||||
},
|
||||
})
|
||||
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
|
||||
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() },
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
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 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"
|
||||
|
||||
@@ -50,194 +50,194 @@ function extract(messages: MessageV2.WithParts[]) {
|
||||
return paths
|
||||
}
|
||||
|
||||
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 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 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 []
|
||||
}
|
||||
const relative = Effect.fnUntraced(function* (instruction: string) {
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
return yield* fs
|
||||
.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR)
|
||||
.globUp(instruction, Instance.directory, Instance.worktree)
|
||||
.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)),
|
||||
}
|
||||
if (!Flag.OPENCODE_CONFIG_DIR) {
|
||||
log.warn(
|
||||
`Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
|
||||
)
|
||||
if (!res) return ""
|
||||
const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0))))
|
||||
return new TextDecoder().decode(body)
|
||||
})
|
||||
return []
|
||||
}
|
||||
return yield* fs
|
||||
.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR)
|
||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
})
|
||||
|
||||
const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
s.claims.delete(messageID)
|
||||
})
|
||||
const read = Effect.fnUntraced(function* (filepath: string) {
|
||||
return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed("")))
|
||||
})
|
||||
|
||||
const systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
|
||||
const config = yield* cfg.get()
|
||||
const paths = new Set<string>()
|
||||
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)
|
||||
})
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
s.claims.delete(messageID)
|
||||
})
|
||||
|
||||
for (const file of globalFiles()) {
|
||||
if (yield* fs.existsSafe(file)) {
|
||||
paths.add(path.resolve(file))
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)))
|
||||
}
|
||||
for (const file of globalFiles()) {
|
||||
if (yield* fs.existsSafe(file)) {
|
||||
paths.add(path.resolve(file))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
return paths
|
||||
})
|
||||
|
||||
const target = path.resolve(filepath)
|
||||
const root = path.resolve(Instance.directory)
|
||||
let current = path.dirname(target)
|
||||
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://"),
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 })
|
||||
const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 })
|
||||
|
||||
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 [
|
||||
...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]}`] : [])),
|
||||
]
|
||||
})
|
||||
|
||||
set.add(found)
|
||||
const content = yield* read(found)
|
||||
if (content) {
|
||||
results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` })
|
||||
}
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return results
|
||||
})
|
||||
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 Service.of({ clear, systemPaths, system, find, resolve })
|
||||
}),
|
||||
)
|
||||
set.add(found)
|
||||
const content = yield* read(found)
|
||||
if (content) {
|
||||
results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` })
|
||||
}
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
current = path.dirname(current)
|
||||
}
|
||||
|
||||
return results
|
||||
})
|
||||
|
||||
return Service.of({ clear, systemPaths, system, find, resolve })
|
||||
}),
|
||||
)
|
||||
|
||||
export function loaded(messages: MessageV2.WithParts[]) {
|
||||
return extract(messages)
|
||||
}
|
||||
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 * as Instruction from "./instruction"
|
||||
|
||||
@@ -25,429 +25,429 @@ import { EffectBridge } from "@/effect"
|
||||
import * as Option from "effect/Option"
|
||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||
|
||||
export namespace LLM {
|
||||
const log = Log.create({ service: "llm" })
|
||||
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
|
||||
type Result = Awaited<ReturnType<typeof streamText>>
|
||||
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 [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"
|
||||
|
||||
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 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
|
||||
? 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 { 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)
|
||||
|
||||
// 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,
|
||||
})
|
||||
},
|
||||
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,
|
||||
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.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,
|
||||
},
|
||||
},
|
||||
})
|
||||
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 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 [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 result = yield* run({ ...input, abort: ctrl.signal })
|
||||
// TODO: move this to a proper hook
|
||||
const isOpenaiOauth = item.id === "openai" && info?.type === "oauth"
|
||||
|
||||
return Stream.fromAsyncIterable(result.fullStream, (e) => (e instanceof Error ? e : new Error(String(e))))
|
||||
}),
|
||||
),
|
||||
)
|
||||
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"),
|
||||
)
|
||||
|
||||
return Service.of({ stream })
|
||||
}),
|
||||
)
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// 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
|
||||
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"))
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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
|
||||
? 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 { 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)
|
||||
|
||||
// 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,
|
||||
})
|
||||
},
|
||||
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,
|
||||
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.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,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
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))))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({ stream })
|
||||
}),
|
||||
)
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
export * as LLM from "./llm"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,189 +3,189 @@ import { SessionID } from "./schema"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||
|
||||
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 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 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 ToolInvocation = z.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult]).meta({
|
||||
ref: "ToolInvocation",
|
||||
export const ToolCall = z
|
||||
.object({
|
||||
state: z.literal("call"),
|
||||
step: z.number().optional(),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
args: z.custom<Required<unknown>>(),
|
||||
})
|
||||
export type ToolInvocation = z.infer<typeof ToolInvocation>
|
||||
.meta({
|
||||
ref: "ToolCall",
|
||||
})
|
||||
export type ToolCall = z.infer<typeof ToolCall>
|
||||
|
||||
export const TextPart = z
|
||||
.object({
|
||||
type: z.literal("text"),
|
||||
text: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "TextPart",
|
||||
})
|
||||
export type TextPart = z.infer<typeof TextPart>
|
||||
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 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 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 ToolInvocationPart = z
|
||||
.object({
|
||||
type: z.literal("tool-invocation"),
|
||||
toolInvocation: ToolInvocation,
|
||||
})
|
||||
.meta({
|
||||
ref: "ToolInvocationPart",
|
||||
})
|
||||
export type ToolInvocationPart = z.infer<typeof ToolInvocationPart>
|
||||
export const ToolInvocation = z.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult]).meta({
|
||||
ref: "ToolInvocation",
|
||||
})
|
||||
export type ToolInvocation = z.infer<typeof ToolInvocation>
|
||||
|
||||
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 TextPart = z
|
||||
.object({
|
||||
type: z.literal("text"),
|
||||
text: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "TextPart",
|
||||
})
|
||||
export type TextPart = z.infer<typeof TextPart>
|
||||
|
||||
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 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 StepStartPart = z
|
||||
.object({
|
||||
type: z.literal("step-start"),
|
||||
})
|
||||
.meta({
|
||||
ref: "StepStartPart",
|
||||
})
|
||||
export type StepStartPart = z.infer<typeof StepStartPart>
|
||||
export const ToolInvocationPart = z
|
||||
.object({
|
||||
type: z.literal("tool-invocation"),
|
||||
toolInvocation: ToolInvocation,
|
||||
})
|
||||
.meta({
|
||||
ref: "ToolInvocationPart",
|
||||
})
|
||||
export type ToolInvocationPart = z.infer<typeof ToolInvocationPart>
|
||||
|
||||
export const MessagePart = z
|
||||
.discriminatedUnion("type", [TextPart, ReasoningPart, ToolInvocationPart, SourceUrlPart, FilePart, StepStartPart])
|
||||
.meta({
|
||||
ref: "MessagePart",
|
||||
})
|
||||
export type MessagePart = z.infer<typeof MessagePart>
|
||||
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 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
|
||||
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 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
|
||||
.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(),
|
||||
}),
|
||||
title: z.string(),
|
||||
snapshot: z.string().optional(),
|
||||
time: z.object({
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
snapshot: z.string().optional(),
|
||||
})
|
||||
.meta({ ref: "MessageMetadata" }),
|
||||
})
|
||||
.meta({
|
||||
ref: "Message",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
}
|
||||
.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"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1581
-1587
File diff suppressed because it is too large
Load Diff
@@ -3,123 +3,123 @@ import { Cause, Clock, Duration, Effect, Schedule } from "effect"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { iife } from "@/util/iife"
|
||||
|
||||
export namespace SessionRetry {
|
||||
export type Err = ReturnType<NamedError["toObject"]>
|
||||
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)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
// 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 : ""
|
||||
|
||||
if (json.type === "error" && json.error?.type === "too_many_requests") {
|
||||
return "Too Many Requests"
|
||||
}
|
||||
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]
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
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 : ""
|
||||
|
||||
if (json.type === "error" && json.error?.type === "too_many_requests") {
|
||||
return "Too Many Requests"
|
||||
}
|
||||
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"
|
||||
|
||||
@@ -11,151 +11,151 @@ import { SessionID, MessageID, PartID } from "./schema"
|
||||
import { SessionRunState } from "./run-state"
|
||||
import { SessionSummary } from "./summary"
|
||||
|
||||
export namespace SessionRevert {
|
||||
const log = Log.create({ service: "session.revert" })
|
||||
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 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
|
||||
|
||||
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)
|
||||
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 (session.revert.partID) {
|
||||
target = msg
|
||||
continue
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
yield* sessions.clearRevert(sessionID)
|
||||
})
|
||||
|
||||
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 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 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)
|
||||
|
||||
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 (session.revert.partID) {
|
||||
target = msg
|
||||
continue
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
yield* sessions.clearRevert(sessionID)
|
||||
})
|
||||
|
||||
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"
|
||||
|
||||
@@ -6,103 +6,103 @@ import { MessageV2 } from "./message-v2"
|
||||
import { SessionID } from "./schema"
|
||||
import { SessionStatus } from "./status"
|
||||
|
||||
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>,
|
||||
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* (
|
||||
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
|
||||
})
|
||||
|
||||
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 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)
|
||||
})
|
||||
|
||||
return Service.of({ assertNotBusy, cancel, ensureRunning, startShell })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(SessionStatus.defaultLayer))
|
||||
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* (
|
||||
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
|
||||
})
|
||||
|
||||
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 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)
|
||||
})
|
||||
|
||||
return Service.of({ assertNotBusy, cancel, ensureRunning, startShell })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(SessionStatus.defaultLayer))
|
||||
|
||||
export * as SessionRunState from "./run-state"
|
||||
|
||||
@@ -272,16 +272,18 @@ export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsa
|
||||
input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0,
|
||||
)
|
||||
const cacheWriteInputTokens = safe(
|
||||
(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,
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
// AI SDK v6 normalized inputTokens to include cached tokens across all providers
|
||||
|
||||
@@ -5,84 +5,84 @@ import { SessionID } from "./schema"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
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,
|
||||
}),
|
||||
),
|
||||
// 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 })
|
||||
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 defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
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 })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
|
||||
export * as SessionStatus from "./status"
|
||||
|
||||
@@ -7,159 +7,157 @@ import * as Session from "./session"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { SessionID, MessageID } from "./schema"
|
||||
|
||||
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[] = []
|
||||
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++
|
||||
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++
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const char = body[i]!
|
||||
if (char !== "\\") {
|
||||
bytes.push(char.charCodeAt(0))
|
||||
continue
|
||||
}
|
||||
|
||||
return Buffer.from(bytes).toString()
|
||||
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++
|
||||
}
|
||||
|
||||
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[]>
|
||||
}
|
||||
return Buffer.from(bytes).toString()
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionSummary") {}
|
||||
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 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 class Service extends Context.Service<Service, Interface>()("@opencode/SessionSummary") {}
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
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 []
|
||||
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,
|
||||
},
|
||||
})
|
||||
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
|
||||
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
|
||||
|
||||
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 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 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 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
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
return Service.of({ summarize, diff, computeDiff })
|
||||
}),
|
||||
)
|
||||
|
||||
return Service.of({ summarize, diff, computeDiff })
|
||||
}),
|
||||
)
|
||||
export const defaultLayer = Layer.suspend(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Snapshot.defaultLayer),
|
||||
Layer.provide(Storage.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
),
|
||||
)
|
||||
|
||||
export const defaultLayer = Layer.suspend(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Snapshot.defaultLayer),
|
||||
Layer.provide(Storage.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
),
|
||||
)
|
||||
export const DiffInput = z.object({
|
||||
sessionID: SessionID.zod,
|
||||
messageID: MessageID.zod.optional(),
|
||||
})
|
||||
|
||||
export const DiffInput = z.object({
|
||||
sessionID: SessionID.zod,
|
||||
messageID: MessageID.zod.optional(),
|
||||
})
|
||||
}
|
||||
export * as SessionSummary from "./summary"
|
||||
|
||||
@@ -16,69 +16,69 @@ import type { Agent } from "@/agent/agent"
|
||||
import { Permission } from "@/permission"
|
||||
import { Skill } from "@/skill"
|
||||
|
||||
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]
|
||||
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]
|
||||
}
|
||||
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]
|
||||
return [PROMPT_GPT]
|
||||
}
|
||||
|
||||
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))
|
||||
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 * as SystemPrompt from "./system"
|
||||
|
||||
@@ -6,80 +6,75 @@ import z from "zod"
|
||||
import { Database, eq, asc } from "../storage"
|
||||
import { TodoTable } from "./session.sql"
|
||||
|
||||
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 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 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 Event = {
|
||||
Updated: BusEvent.define(
|
||||
"todo.updated",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
todos: z.array(Info),
|
||||
}),
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type * as SDK from "@opencode-ai/sdk/v2"
|
||||
import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Account } from "@/account"
|
||||
import { Account } from "@/account/account"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceState } from "@/effect"
|
||||
import { Provider } from "@/provider"
|
||||
|
||||
@@ -8,103 +8,103 @@ import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
const SIGKILL_TIMEOUT_MS = 200
|
||||
|
||||
export namespace Shell {
|
||||
const BLACKLIST = new Set(["fish", "nu"])
|
||||
const LOGIN = new Set(["bash", "dash", "fish", "ksh", "sh", "zsh"])
|
||||
const POSIX = new Set(["bash", "dash", "ksh", "sh", "zsh"])
|
||||
const BLACKLIST = new Set(["fish", "nu"])
|
||||
const LOGIN = new Set(["bash", "dash", "fish", "ksh", "sh", "zsh"])
|
||||
const POSIX = new Set(["bash", "dash", "ksh", "sh", "zsh"])
|
||||
|
||||
export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
|
||||
const pid = proc.pid
|
||||
if (!pid || opts?.exited?.()) return
|
||||
export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
|
||||
const pid = proc.pid
|
||||
if (!pid || opts?.exited?.()) return
|
||||
|
||||
if (process.platform === "win32") {
|
||||
await new Promise<void>((resolve) => {
|
||||
const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
})
|
||||
killer.once("exit", () => resolve())
|
||||
killer.once("error", () => resolve())
|
||||
if (process.platform === "win32") {
|
||||
await new Promise<void>((resolve) => {
|
||||
const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
killer.once("exit", () => resolve())
|
||||
killer.once("error", () => resolve())
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-pid, "SIGTERM")
|
||||
await sleep(SIGKILL_TIMEOUT_MS)
|
||||
if (!opts?.exited?.()) {
|
||||
process.kill(-pid, "SIGKILL")
|
||||
}
|
||||
} catch (_e) {
|
||||
proc.kill("SIGTERM")
|
||||
await sleep(SIGKILL_TIMEOUT_MS)
|
||||
if (!opts?.exited?.()) {
|
||||
proc.kill("SIGKILL")
|
||||
}
|
||||
try {
|
||||
process.kill(-pid, "SIGTERM")
|
||||
await sleep(SIGKILL_TIMEOUT_MS)
|
||||
if (!opts?.exited?.()) {
|
||||
process.kill(-pid, "SIGKILL")
|
||||
}
|
||||
} catch (_e) {
|
||||
proc.kill("SIGTERM")
|
||||
await sleep(SIGKILL_TIMEOUT_MS)
|
||||
if (!opts?.exited?.()) {
|
||||
proc.kill("SIGKILL")
|
||||
}
|
||||
}
|
||||
|
||||
function full(file: string) {
|
||||
if (process.platform !== "win32") return file
|
||||
const shell = Filesystem.windowsPath(file)
|
||||
if (path.win32.dirname(shell) !== ".") {
|
||||
if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell
|
||||
return shell
|
||||
}
|
||||
return which(shell) || shell
|
||||
}
|
||||
|
||||
function pick() {
|
||||
const pwsh = which("pwsh.exe")
|
||||
if (pwsh) return pwsh
|
||||
const powershell = which("powershell.exe")
|
||||
if (powershell) return powershell
|
||||
}
|
||||
|
||||
function select(file: string | undefined, opts?: { acceptable?: boolean }) {
|
||||
if (file && (!opts?.acceptable || !BLACKLIST.has(name(file)))) return full(file)
|
||||
if (process.platform === "win32") {
|
||||
const shell = pick()
|
||||
if (shell) return shell
|
||||
}
|
||||
return fallback()
|
||||
}
|
||||
|
||||
export function gitbash() {
|
||||
if (process.platform !== "win32") return
|
||||
if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH
|
||||
const git = which("git")
|
||||
if (!git) return
|
||||
const file = path.join(git, "..", "..", "bin", "bash.exe")
|
||||
if (Filesystem.stat(file)?.size) return file
|
||||
}
|
||||
|
||||
function fallback() {
|
||||
if (process.platform === "win32") {
|
||||
const file = gitbash()
|
||||
if (file) return file
|
||||
return process.env.COMSPEC || "cmd.exe"
|
||||
}
|
||||
if (process.platform === "darwin") return "/bin/zsh"
|
||||
const bash = which("bash")
|
||||
if (bash) return bash
|
||||
return "/bin/sh"
|
||||
}
|
||||
|
||||
export function name(file: string) {
|
||||
if (process.platform === "win32") return path.win32.parse(Filesystem.windowsPath(file)).name.toLowerCase()
|
||||
return path.basename(file).toLowerCase()
|
||||
}
|
||||
|
||||
export function login(file: string) {
|
||||
return LOGIN.has(name(file))
|
||||
}
|
||||
|
||||
export function posix(file: string) {
|
||||
return POSIX.has(name(file))
|
||||
}
|
||||
|
||||
export const preferred = lazy(() => select(process.env.SHELL))
|
||||
|
||||
export const acceptable = lazy(() => select(process.env.SHELL, { acceptable: true }))
|
||||
}
|
||||
|
||||
function full(file: string) {
|
||||
if (process.platform !== "win32") return file
|
||||
const shell = Filesystem.windowsPath(file)
|
||||
if (path.win32.dirname(shell) !== ".") {
|
||||
if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell
|
||||
return shell
|
||||
}
|
||||
return which(shell) || shell
|
||||
}
|
||||
|
||||
function pick() {
|
||||
const pwsh = which("pwsh.exe")
|
||||
if (pwsh) return pwsh
|
||||
const powershell = which("powershell.exe")
|
||||
if (powershell) return powershell
|
||||
}
|
||||
|
||||
function select(file: string | undefined, opts?: { acceptable?: boolean }) {
|
||||
if (file && (!opts?.acceptable || !BLACKLIST.has(name(file)))) return full(file)
|
||||
if (process.platform === "win32") {
|
||||
const shell = pick()
|
||||
if (shell) return shell
|
||||
}
|
||||
return fallback()
|
||||
}
|
||||
|
||||
export function gitbash() {
|
||||
if (process.platform !== "win32") return
|
||||
if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH
|
||||
const git = which("git")
|
||||
if (!git) return
|
||||
const file = path.join(git, "..", "..", "bin", "bash.exe")
|
||||
if (Filesystem.stat(file)?.size) return file
|
||||
}
|
||||
|
||||
function fallback() {
|
||||
if (process.platform === "win32") {
|
||||
const file = gitbash()
|
||||
if (file) return file
|
||||
return process.env.COMSPEC || "cmd.exe"
|
||||
}
|
||||
if (process.platform === "darwin") return "/bin/zsh"
|
||||
const bash = which("bash")
|
||||
if (bash) return bash
|
||||
return "/bin/sh"
|
||||
}
|
||||
|
||||
export function name(file: string) {
|
||||
if (process.platform === "win32") return path.win32.parse(Filesystem.windowsPath(file)).name.toLowerCase()
|
||||
return path.basename(file).toLowerCase()
|
||||
}
|
||||
|
||||
export function login(file: string) {
|
||||
return LOGIN.has(name(file))
|
||||
}
|
||||
|
||||
export function posix(file: string) {
|
||||
return POSIX.has(name(file))
|
||||
}
|
||||
|
||||
export const preferred = lazy(() => select(process.env.SHELL))
|
||||
|
||||
export const acceptable = lazy(() => select(process.env.SHELL, { acceptable: true }))
|
||||
|
||||
export * as Shell from "./shell"
|
||||
|
||||
@@ -54,6 +54,16 @@ type State = {
|
||||
dirs: Set<string>
|
||||
}
|
||||
|
||||
type DiscoveryState = {
|
||||
matches: string[]
|
||||
dirs: string[]
|
||||
}
|
||||
|
||||
type ScanState = {
|
||||
matches: Set<string>
|
||||
dirs: Set<string>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||
readonly all: () => Effect.Effect<Info[]>
|
||||
@@ -102,8 +112,7 @@ const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.I
|
||||
})
|
||||
|
||||
const scan = Effect.fnUntraced(function* (
|
||||
state: State,
|
||||
bus: Bus.Interface,
|
||||
state: ScanState,
|
||||
root: string,
|
||||
pattern: string,
|
||||
opts?: { dot?: boolean; scope?: string },
|
||||
@@ -126,26 +135,26 @@ const scan = Effect.fnUntraced(function* (
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Effect.forEach(matches, (match) => add(state, match, bus), {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
})
|
||||
for (const match of matches) {
|
||||
state.matches.add(match)
|
||||
state.dirs.add(path.dirname(match))
|
||||
}
|
||||
})
|
||||
|
||||
const loadSkills = Effect.fnUntraced(function* (
|
||||
state: State,
|
||||
const discoverSkills = Effect.fnUntraced(function* (
|
||||
config: Config.Interface,
|
||||
discovery: Discovery.Interface,
|
||||
bus: Bus.Interface,
|
||||
fsys: AppFileSystem.Interface,
|
||||
directory: string,
|
||||
worktree: string,
|
||||
) {
|
||||
const state: ScanState = { matches: new Set(), dirs: new Set() }
|
||||
|
||||
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
||||
for (const dir of EXTERNAL_DIRS) {
|
||||
const root = path.join(Global.Path.home, dir)
|
||||
if (!(yield* fsys.isDir(root))) continue
|
||||
yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
||||
yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
||||
}
|
||||
|
||||
const upDirs = yield* fsys
|
||||
@@ -153,13 +162,13 @@ const loadSkills = Effect.fnUntraced(function* (
|
||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
|
||||
for (const root of upDirs) {
|
||||
yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
|
||||
yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
|
||||
}
|
||||
}
|
||||
|
||||
const configDirs = yield* config.directories()
|
||||
for (const dir of configDirs) {
|
||||
yield* scan(state, bus, dir, OPENCODE_SKILL_PATTERN)
|
||||
yield* scan(state, dir, OPENCODE_SKILL_PATTERN)
|
||||
}
|
||||
|
||||
const cfg = yield* config.get()
|
||||
@@ -171,17 +180,28 @@ const loadSkills = Effect.fnUntraced(function* (
|
||||
continue
|
||||
}
|
||||
|
||||
yield* scan(state, bus, dir, SKILL_PATTERN)
|
||||
yield* scan(state, dir, SKILL_PATTERN)
|
||||
}
|
||||
|
||||
for (const url of cfg.skills?.urls ?? []) {
|
||||
const pulledDirs = yield* discovery.pull(url)
|
||||
for (const dir of pulledDirs) {
|
||||
state.dirs.add(dir)
|
||||
yield* scan(state, bus, dir, SKILL_PATTERN)
|
||||
yield* scan(state, dir, SKILL_PATTERN)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matches: Array.from(state.matches),
|
||||
dirs: Array.from(state.dirs),
|
||||
}
|
||||
})
|
||||
|
||||
const loadSkills = Effect.fnUntraced(function* (state: State, discovered: DiscoveryState, bus: Bus.Interface) {
|
||||
yield* Effect.forEach(discovered.matches, (match) => add(state, match, bus), {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
})
|
||||
|
||||
log.info("init", { count: Object.keys(state.skills).length })
|
||||
})
|
||||
|
||||
@@ -194,10 +214,15 @@ export const layer = Layer.effect(
|
||||
const config = yield* Config.Service
|
||||
const bus = yield* Bus.Service
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const discovered = yield* InstanceState.make(
|
||||
Effect.fn("Skill.discovery")(function* (ctx) {
|
||||
return yield* discoverSkills(config, discovery, fsys, ctx.directory, ctx.worktree)
|
||||
}),
|
||||
)
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("Skill.state")(function* (ctx) {
|
||||
const s: State = { skills: {}, dirs: new Set() }
|
||||
yield* loadSkills(s, config, discovery, bus, fsys, ctx.directory, ctx.worktree)
|
||||
yield* loadSkills(s, yield* InstanceState.get(discovered), bus)
|
||||
return s
|
||||
}),
|
||||
)
|
||||
@@ -213,8 +238,7 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const dirs = Effect.fn("Skill.dirs")(function* () {
|
||||
const s = yield* InstanceState.get(state)
|
||||
return Array.from(s.dirs)
|
||||
return (yield* InstanceState.get(discovered)).dirs
|
||||
})
|
||||
|
||||
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
|
||||
|
||||
@@ -135,14 +135,17 @@ export const layer: Layer.Layer<
|
||||
worktree: ctx.worktree,
|
||||
}
|
||||
const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))
|
||||
const output = typeof result === "string" ? result : result.output
|
||||
const metadata = typeof result === "string" ? {} : (result.metadata ?? {})
|
||||
const info = yield* agent.get(toolCtx.agent)
|
||||
const out = yield* truncate.output(result, {}, info)
|
||||
const out = yield* truncate.output(output, {}, info)
|
||||
return {
|
||||
title: "",
|
||||
output: out.truncated ? out.content : result,
|
||||
output: out.truncated ? out.content : output,
|
||||
metadata: {
|
||||
...metadata,
|
||||
truncated: out.truncated,
|
||||
outputPath: out.truncated ? out.outputPath : undefined,
|
||||
...(out.truncated && { outputPath: out.outputPath }),
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -19,7 +19,7 @@ export type Context<M extends Metadata = Metadata> = {
|
||||
agent: string
|
||||
abort: AbortSignal
|
||||
callID?: string
|
||||
extra?: { [key: string]: any }
|
||||
extra?: { [key: string]: unknown }
|
||||
messages: MessageV2.WithParts[]
|
||||
metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>
|
||||
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
|
||||
|
||||
@@ -39,7 +39,7 @@ export async function readText(p: string): Promise<string> {
|
||||
return readFile(p, "utf-8")
|
||||
}
|
||||
|
||||
export async function readJson<T = any>(p: string): Promise<T> {
|
||||
export async function readJson<T = unknown>(p: string): Promise<T> {
|
||||
return JSON.parse(await readFile(p, "utf-8"))
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export namespace SessionCommon {}
|
||||
@@ -2,317 +2,317 @@ import { Schema } from "effect"
|
||||
import { SessionEvent } from "./session-event"
|
||||
import { produce } from "immer"
|
||||
|
||||
export namespace SessionEntry {
|
||||
export const ID = SessionEvent.ID
|
||||
export type ID = Schema.Schema.Type<typeof ID>
|
||||
export const ID = SessionEvent.ID
|
||||
export type ID = Schema.Schema.Type<typeof ID>
|
||||
|
||||
const Base = {
|
||||
id: SessionEvent.ID,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
}),
|
||||
}
|
||||
const Base = {
|
||||
id: SessionEvent.ID,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
}),
|
||||
}
|
||||
|
||||
export class User extends Schema.Class<User>("Session.Entry.User")({
|
||||
...Base,
|
||||
text: SessionEvent.Prompt.fields.text,
|
||||
files: SessionEvent.Prompt.fields.files,
|
||||
agents: SessionEvent.Prompt.fields.agents,
|
||||
type: Schema.Literal("user"),
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
}),
|
||||
}) {
|
||||
static fromEvent(event: SessionEvent.Prompt) {
|
||||
return new User({
|
||||
id: event.id,
|
||||
type: "user",
|
||||
metadata: event.metadata,
|
||||
text: event.text,
|
||||
files: event.files,
|
||||
agents: event.agents,
|
||||
time: { created: event.timestamp },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Synthetic extends Schema.Class<Synthetic>("Session.Entry.Synthetic")({
|
||||
...SessionEvent.Synthetic.fields,
|
||||
...Base,
|
||||
type: Schema.Literal("synthetic"),
|
||||
}) {}
|
||||
|
||||
export class ToolStatePending extends Schema.Class<ToolStatePending>("Session.Entry.ToolState.Pending")({
|
||||
status: Schema.Literal("pending"),
|
||||
input: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class ToolStateRunning extends Schema.Class<ToolStateRunning>("Session.Entry.ToolState.Running")({
|
||||
status: Schema.Literal("running"),
|
||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||
title: Schema.String.pipe(Schema.optional),
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class ToolStateCompleted extends Schema.Class<ToolStateCompleted>("Session.Entry.ToolState.Completed")({
|
||||
status: Schema.Literal("completed"),
|
||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||
output: Schema.String,
|
||||
title: Schema.String,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown),
|
||||
attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class ToolStateError extends Schema.Class<ToolStateError>("Session.Entry.ToolState.Error")({
|
||||
status: Schema.Literal("error"),
|
||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||
error: Schema.String,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError])
|
||||
export type ToolState = Schema.Schema.Type<typeof ToolState>
|
||||
|
||||
export class AssistantTool extends Schema.Class<AssistantTool>("Session.Entry.Assistant.Tool")({
|
||||
type: Schema.Literal("tool"),
|
||||
callID: Schema.String,
|
||||
name: Schema.String,
|
||||
state: ToolState,
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
ran: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
completed: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
pruned: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class AssistantText extends Schema.Class<AssistantText>("Session.Entry.Assistant.Text")({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class AssistantReasoning extends Schema.Class<AssistantReasoning>("Session.Entry.Assistant.Reasoning")({
|
||||
type: Schema.Literal("reasoning"),
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool])
|
||||
export type AssistantContent = Schema.Schema.Type<typeof AssistantContent>
|
||||
|
||||
export class Assistant extends Schema.Class<Assistant>("Session.Entry.Assistant")({
|
||||
...Base,
|
||||
type: Schema.Literal("assistant"),
|
||||
content: AssistantContent.pipe(Schema.Array),
|
||||
cost: Schema.Number.pipe(Schema.optional),
|
||||
tokens: Schema.Struct({
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
reasoning: Schema.Number,
|
||||
cache: Schema.Struct({
|
||||
read: Schema.Number,
|
||||
write: Schema.Number,
|
||||
}),
|
||||
}).pipe(Schema.optional),
|
||||
error: Schema.String.pipe(Schema.optional),
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
completed: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class Compaction extends Schema.Class<Compaction>("Session.Entry.Compaction")({
|
||||
...SessionEvent.Compacted.fields,
|
||||
type: Schema.Literal("compaction"),
|
||||
...Base,
|
||||
}) {}
|
||||
|
||||
export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction])
|
||||
|
||||
export type Entry = Schema.Schema.Type<typeof Entry>
|
||||
|
||||
export type Type = Entry["type"]
|
||||
|
||||
export type History = {
|
||||
entries: Entry[]
|
||||
pending: Entry[]
|
||||
}
|
||||
|
||||
export function step(old: History, event: SessionEvent.Event): History {
|
||||
return produce(old, (draft) => {
|
||||
const lastAssistant = draft.entries.findLast((x) => x.type === "assistant")
|
||||
const pendingAssistant = lastAssistant && !lastAssistant.time.completed ? lastAssistant : undefined
|
||||
|
||||
switch (event.type) {
|
||||
case "prompt": {
|
||||
if (pendingAssistant) {
|
||||
// @ts-expect-error
|
||||
draft.pending.push(User.fromEvent(event))
|
||||
break
|
||||
}
|
||||
// @ts-expect-error
|
||||
draft.entries.push(User.fromEvent(event))
|
||||
break
|
||||
}
|
||||
case "step.started": {
|
||||
if (pendingAssistant) pendingAssistant.time.completed = event.timestamp
|
||||
draft.entries.push({
|
||||
id: event.id,
|
||||
type: "assistant",
|
||||
time: {
|
||||
created: event.timestamp,
|
||||
},
|
||||
content: [],
|
||||
})
|
||||
break
|
||||
}
|
||||
case "text.started": {
|
||||
if (!pendingAssistant) break
|
||||
pendingAssistant.content.push({
|
||||
type: "text",
|
||||
text: "",
|
||||
})
|
||||
break
|
||||
}
|
||||
case "text.delta": {
|
||||
if (!pendingAssistant) break
|
||||
const match = pendingAssistant.content.findLast((x) => x.type === "text")
|
||||
if (match) match.text += event.delta
|
||||
break
|
||||
}
|
||||
case "text.ended": {
|
||||
break
|
||||
}
|
||||
case "tool.input.started": {
|
||||
if (!pendingAssistant) break
|
||||
pendingAssistant.content.push({
|
||||
type: "tool",
|
||||
callID: event.callID,
|
||||
name: event.name,
|
||||
time: {
|
||||
created: event.timestamp,
|
||||
},
|
||||
state: {
|
||||
status: "pending",
|
||||
input: "",
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
case "tool.input.delta": {
|
||||
if (!pendingAssistant) break
|
||||
const match = pendingAssistant.content.findLast((x) => x.type === "tool")
|
||||
// oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string)
|
||||
if (match) match.state.input += event.delta
|
||||
break
|
||||
}
|
||||
case "tool.input.ended": {
|
||||
break
|
||||
}
|
||||
case "tool.called": {
|
||||
if (!pendingAssistant) break
|
||||
const match = pendingAssistant.content.findLast((x) => x.type === "tool")
|
||||
if (match) {
|
||||
match.time.ran = event.timestamp
|
||||
match.state = {
|
||||
status: "running",
|
||||
input: event.input,
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case "tool.success": {
|
||||
if (!pendingAssistant) break
|
||||
const match = pendingAssistant.content.findLast((x) => x.type === "tool")
|
||||
if (match && match.state.status === "running") {
|
||||
match.state = {
|
||||
status: "completed",
|
||||
input: match.state.input,
|
||||
output: event.output ?? "",
|
||||
title: event.title,
|
||||
metadata: event.metadata ?? {},
|
||||
// @ts-expect-error
|
||||
attachments: event.attachments ?? [],
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case "tool.error": {
|
||||
if (!pendingAssistant) break
|
||||
const match = pendingAssistant.content.findLast((x) => x.type === "tool")
|
||||
if (match && match.state.status === "running") {
|
||||
match.state = {
|
||||
status: "error",
|
||||
error: event.error,
|
||||
input: match.state.input,
|
||||
metadata: event.metadata ?? {},
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case "reasoning.started": {
|
||||
if (!pendingAssistant) break
|
||||
pendingAssistant.content.push({
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
})
|
||||
break
|
||||
}
|
||||
case "reasoning.delta": {
|
||||
if (!pendingAssistant) break
|
||||
const match = pendingAssistant.content.findLast((x) => x.type === "reasoning")
|
||||
if (match) match.text += event.delta
|
||||
break
|
||||
}
|
||||
case "reasoning.ended": {
|
||||
if (!pendingAssistant) break
|
||||
const match = pendingAssistant.content.findLast((x) => x.type === "reasoning")
|
||||
if (match) match.text = event.text
|
||||
break
|
||||
}
|
||||
case "step.ended": {
|
||||
if (!pendingAssistant) break
|
||||
pendingAssistant.time.completed = event.timestamp
|
||||
pendingAssistant.cost = event.cost
|
||||
pendingAssistant.tokens = event.tokens
|
||||
break
|
||||
}
|
||||
}
|
||||
export class User extends Schema.Class<User>("Session.Entry.User")({
|
||||
...Base,
|
||||
text: SessionEvent.Prompt.fields.text,
|
||||
files: SessionEvent.Prompt.fields.files,
|
||||
agents: SessionEvent.Prompt.fields.agents,
|
||||
type: Schema.Literal("user"),
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
}),
|
||||
}) {
|
||||
static fromEvent(event: SessionEvent.Prompt) {
|
||||
return new User({
|
||||
id: event.id,
|
||||
type: "user",
|
||||
metadata: event.metadata,
|
||||
text: event.text,
|
||||
files: event.files,
|
||||
agents: event.agents,
|
||||
time: { created: event.timestamp },
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
export interface Interface {
|
||||
readonly decode: (row: typeof SessionEntryTable.$inferSelect) => Entry
|
||||
readonly fromSession: (sessionID: SessionID) => Effect.Effect<Entry[], never>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionEntry") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, never> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const decodeEntry = Schema.decodeUnknownSync(Entry)
|
||||
|
||||
const decode: (typeof Service.Service)["decode"] = (row) => decodeEntry({ ...row, id: row.id, type: row.type })
|
||||
|
||||
const fromSession = Effect.fn("SessionEntry.fromSession")(function* (sessionID: SessionID) {
|
||||
return Database.use((db) =>
|
||||
db
|
||||
.select()
|
||||
.from(SessionEntryTable)
|
||||
.where(eq(SessionEntryTable.session_id, sessionID))
|
||||
.orderBy(SessionEntryTable.id)
|
||||
.all()
|
||||
.map((row) => decode(row)),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
decode,
|
||||
fromSession,
|
||||
})
|
||||
}),
|
||||
)
|
||||
*/
|
||||
}
|
||||
|
||||
export class Synthetic extends Schema.Class<Synthetic>("Session.Entry.Synthetic")({
|
||||
...SessionEvent.Synthetic.fields,
|
||||
...Base,
|
||||
type: Schema.Literal("synthetic"),
|
||||
}) {}
|
||||
|
||||
export class ToolStatePending extends Schema.Class<ToolStatePending>("Session.Entry.ToolState.Pending")({
|
||||
status: Schema.Literal("pending"),
|
||||
input: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class ToolStateRunning extends Schema.Class<ToolStateRunning>("Session.Entry.ToolState.Running")({
|
||||
status: Schema.Literal("running"),
|
||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||
title: Schema.String.pipe(Schema.optional),
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class ToolStateCompleted extends Schema.Class<ToolStateCompleted>("Session.Entry.ToolState.Completed")({
|
||||
status: Schema.Literal("completed"),
|
||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||
output: Schema.String,
|
||||
title: Schema.String,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown),
|
||||
attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class ToolStateError extends Schema.Class<ToolStateError>("Session.Entry.ToolState.Error")({
|
||||
status: Schema.Literal("error"),
|
||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||
error: Schema.String,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError])
|
||||
export type ToolState = Schema.Schema.Type<typeof ToolState>
|
||||
|
||||
export class AssistantTool extends Schema.Class<AssistantTool>("Session.Entry.Assistant.Tool")({
|
||||
type: Schema.Literal("tool"),
|
||||
callID: Schema.String,
|
||||
name: Schema.String,
|
||||
state: ToolState,
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
ran: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
completed: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
pruned: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class AssistantText extends Schema.Class<AssistantText>("Session.Entry.Assistant.Text")({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class AssistantReasoning extends Schema.Class<AssistantReasoning>("Session.Entry.Assistant.Reasoning")({
|
||||
type: Schema.Literal("reasoning"),
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool])
|
||||
export type AssistantContent = Schema.Schema.Type<typeof AssistantContent>
|
||||
|
||||
export class Assistant extends Schema.Class<Assistant>("Session.Entry.Assistant")({
|
||||
...Base,
|
||||
type: Schema.Literal("assistant"),
|
||||
content: AssistantContent.pipe(Schema.Array),
|
||||
cost: Schema.Number.pipe(Schema.optional),
|
||||
tokens: Schema.Struct({
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
reasoning: Schema.Number,
|
||||
cache: Schema.Struct({
|
||||
read: Schema.Number,
|
||||
write: Schema.Number,
|
||||
}),
|
||||
}).pipe(Schema.optional),
|
||||
error: Schema.String.pipe(Schema.optional),
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
completed: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class Compaction extends Schema.Class<Compaction>("Session.Entry.Compaction")({
|
||||
...SessionEvent.Compacted.fields,
|
||||
type: Schema.Literal("compaction"),
|
||||
...Base,
|
||||
}) {}
|
||||
|
||||
export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction])
|
||||
|
||||
export type Entry = Schema.Schema.Type<typeof Entry>
|
||||
|
||||
export type Type = Entry["type"]
|
||||
|
||||
export type History = {
|
||||
entries: Entry[]
|
||||
pending: Entry[]
|
||||
}
|
||||
|
||||
export function step(old: History, event: SessionEvent.Event): History {
|
||||
return produce(old, (draft) => {
|
||||
const lastAssistant = draft.entries.findLast((x) => x.type === "assistant")
|
||||
const pendingAssistant = lastAssistant && !lastAssistant.time.completed ? lastAssistant : undefined
|
||||
|
||||
switch (event.type) {
|
||||
case "prompt": {
|
||||
if (pendingAssistant) {
|
||||
// @ts-expect-error
|
||||
draft.pending.push(User.fromEvent(event))
|
||||
break
|
||||
}
|
||||
// @ts-expect-error
|
||||
draft.entries.push(User.fromEvent(event))
|
||||
break
|
||||
}
|
||||
case "step.started": {
|
||||
if (pendingAssistant) pendingAssistant.time.completed = event.timestamp
|
||||
draft.entries.push({
|
||||
id: event.id,
|
||||
type: "assistant",
|
||||
time: {
|
||||
created: event.timestamp,
|
||||
},
|
||||
content: [],
|
||||
})
|
||||
break
|
||||
}
|
||||
case "text.started": {
|
||||
if (!pendingAssistant) break
|
||||
pendingAssistant.content.push({
|
||||
type: "text",
|
||||
text: "",
|
||||
})
|
||||
break
|
||||
}
|
||||
case "text.delta": {
|
||||
if (!pendingAssistant) break
|
||||
const match = pendingAssistant.content.findLast((x) => x.type === "text")
|
||||
if (match) match.text += event.delta
|
||||
break
|
||||
}
|
||||
case "text.ended": {
|
||||
break
|
||||
}
|
||||
case "tool.input.started": {
|
||||
if (!pendingAssistant) break
|
||||
pendingAssistant.content.push({
|
||||
type: "tool",
|
||||
callID: event.callID,
|
||||
name: event.name,
|
||||
time: {
|
||||
created: event.timestamp,
|
||||
},
|
||||
state: {
|
||||
status: "pending",
|
||||
input: "",
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
case "tool.input.delta": {
|
||||
if (!pendingAssistant) break
|
||||
const match = pendingAssistant.content.findLast((x) => x.type === "tool")
|
||||
// oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string)
|
||||
if (match) match.state.input += event.delta
|
||||
break
|
||||
}
|
||||
case "tool.input.ended": {
|
||||
break
|
||||
}
|
||||
case "tool.called": {
|
||||
if (!pendingAssistant) break
|
||||
const match = pendingAssistant.content.findLast((x) => x.type === "tool")
|
||||
if (match) {
|
||||
match.time.ran = event.timestamp
|
||||
match.state = {
|
||||
status: "running",
|
||||
input: event.input,
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case "tool.success": {
|
||||
if (!pendingAssistant) break
|
||||
const match = pendingAssistant.content.findLast((x) => x.type === "tool")
|
||||
if (match && match.state.status === "running") {
|
||||
match.state = {
|
||||
status: "completed",
|
||||
input: match.state.input,
|
||||
output: event.output ?? "",
|
||||
title: event.title,
|
||||
metadata: event.metadata ?? {},
|
||||
// @ts-expect-error
|
||||
attachments: event.attachments ?? [],
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case "tool.error": {
|
||||
if (!pendingAssistant) break
|
||||
const match = pendingAssistant.content.findLast((x) => x.type === "tool")
|
||||
if (match && match.state.status === "running") {
|
||||
match.state = {
|
||||
status: "error",
|
||||
error: event.error,
|
||||
input: match.state.input,
|
||||
metadata: event.metadata ?? {},
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case "reasoning.started": {
|
||||
if (!pendingAssistant) break
|
||||
pendingAssistant.content.push({
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
})
|
||||
break
|
||||
}
|
||||
case "reasoning.delta": {
|
||||
if (!pendingAssistant) break
|
||||
const match = pendingAssistant.content.findLast((x) => x.type === "reasoning")
|
||||
if (match) match.text += event.delta
|
||||
break
|
||||
}
|
||||
case "reasoning.ended": {
|
||||
if (!pendingAssistant) break
|
||||
const match = pendingAssistant.content.findLast((x) => x.type === "reasoning")
|
||||
if (match) match.text = event.text
|
||||
break
|
||||
}
|
||||
case "step.ended": {
|
||||
if (!pendingAssistant) break
|
||||
pendingAssistant.time.completed = event.timestamp
|
||||
pendingAssistant.cost = event.cost
|
||||
pendingAssistant.tokens = event.tokens
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
export interface Interface {
|
||||
readonly decode: (row: typeof SessionEntryTable.$inferSelect) => Entry
|
||||
readonly fromSession: (sessionID: SessionID) => Effect.Effect<Entry[], never>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionEntry") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, never> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const decodeEntry = Schema.decodeUnknownSync(Entry)
|
||||
|
||||
const decode: (typeof Service.Service)["decode"] = (row) => decodeEntry({ ...row, id: row.id, type: row.type })
|
||||
|
||||
const fromSession = Effect.fn("SessionEntry.fromSession")(function* (sessionID: SessionID) {
|
||||
return Database.use((db) =>
|
||||
db
|
||||
.select()
|
||||
.from(SessionEntryTable)
|
||||
.where(eq(SessionEntryTable.session_id, sessionID))
|
||||
.orderBy(SessionEntryTable.id)
|
||||
.all()
|
||||
.map((row) => decode(row)),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
decode,
|
||||
fromSession,
|
||||
})
|
||||
}),
|
||||
)
|
||||
*/
|
||||
|
||||
export * as SessionEntry from "./session-entry"
|
||||
|
||||
@@ -4,66 +4,66 @@ import { Struct } from "effect"
|
||||
import { Session } from "@/session"
|
||||
import { SessionID } from "@/session/schema"
|
||||
|
||||
export namespace SessionV2 {
|
||||
export const ID = SessionID
|
||||
export const ID = SessionID
|
||||
|
||||
export type ID = Schema.Schema.Type<typeof ID>
|
||||
export type ID = Schema.Schema.Type<typeof ID>
|
||||
|
||||
export class PromptInput extends Schema.Class<PromptInput>("Session.PromptInput")({
|
||||
...Struct.omit(SessionEntry.User.fields, ["time", "type"]),
|
||||
id: Schema.optionalKey(SessionEntry.ID),
|
||||
sessionID: SessionV2.ID,
|
||||
}) {}
|
||||
export class PromptInput extends Schema.Class<PromptInput>("Session.PromptInput")({
|
||||
...Struct.omit(SessionEntry.User.fields, ["time", "type"]),
|
||||
id: Schema.optionalKey(SessionEntry.ID),
|
||||
sessionID: ID,
|
||||
}) {}
|
||||
|
||||
export class CreateInput extends Schema.Class<CreateInput>("Session.CreateInput")({
|
||||
id: Schema.optionalKey(SessionV2.ID),
|
||||
}) {}
|
||||
export class CreateInput extends Schema.Class<CreateInput>("Session.CreateInput")({
|
||||
id: Schema.optionalKey(ID),
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("Session.Info")({
|
||||
id: SessionV2.ID,
|
||||
model: Schema.Struct({
|
||||
id: Schema.String,
|
||||
providerID: Schema.String,
|
||||
modelID: Schema.String,
|
||||
}).pipe(Schema.optional),
|
||||
}) {}
|
||||
export class Info extends Schema.Class<Info>("Session.Info")({
|
||||
id: ID,
|
||||
model: Schema.Struct({
|
||||
id: Schema.String,
|
||||
providerID: Schema.String,
|
||||
modelID: Schema.String,
|
||||
}).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
fromID: (id: SessionV2.ID) => Effect.Effect<Info>
|
||||
create: (input: CreateInput) => Effect.Effect<Info>
|
||||
prompt: (input: PromptInput) => Effect.Effect<SessionEntry.User>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("Session.Service") {}
|
||||
|
||||
export const layer = Layer.effect(Service)(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
|
||||
const create: Interface["create"] = Effect.fn("Session.create")(function* (_input) {
|
||||
throw new Error("Not implemented")
|
||||
})
|
||||
|
||||
const prompt: Interface["prompt"] = Effect.fn("Session.prompt")(function* (_input) {
|
||||
throw new Error("Not implemented")
|
||||
})
|
||||
|
||||
const fromID: Interface["fromID"] = Effect.fn("Session.fromID")(function* (id) {
|
||||
const match = yield* session.get(id)
|
||||
return fromV1(match)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
create,
|
||||
prompt,
|
||||
fromID,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
function fromV1(input: Session.Info): Info {
|
||||
return new Info({
|
||||
id: SessionV2.ID.make(input.id),
|
||||
})
|
||||
}
|
||||
export interface Interface {
|
||||
fromID: (id: ID) => Effect.Effect<Info>
|
||||
create: (input: CreateInput) => Effect.Effect<Info>
|
||||
prompt: (input: PromptInput) => Effect.Effect<SessionEntry.User>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("Session.Service") {}
|
||||
|
||||
export const layer = Layer.effect(Service)(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
|
||||
const create: Interface["create"] = Effect.fn("Session.create")(function* (_input) {
|
||||
throw new Error("Not implemented")
|
||||
})
|
||||
|
||||
const prompt: Interface["prompt"] = Effect.fn("Session.prompt")(function* (_input) {
|
||||
throw new Error("Not implemented")
|
||||
})
|
||||
|
||||
const fromID: Interface["fromID"] = Effect.fn("Session.fromID")(function* (id) {
|
||||
const match = yield* session.get(id)
|
||||
return fromV1(match)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
create,
|
||||
prompt,
|
||||
fromID,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
function fromV1(input: Session.Info): Info {
|
||||
return new Info({
|
||||
id: ID.make(input.id),
|
||||
})
|
||||
}
|
||||
|
||||
export * as SessionV2 from "./session"
|
||||
|
||||
@@ -18,14 +18,14 @@ const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
|
||||
|
||||
it.live("list returns empty when no accounts exist", () =>
|
||||
Effect.gen(function* () {
|
||||
const accounts = yield* AccountRepo.use((r) => r.list())
|
||||
const accounts = yield* AccountRepo.Service.use((r) => r.list())
|
||||
expect(accounts).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("active returns none when no accounts exist", () =>
|
||||
Effect.gen(function* () {
|
||||
const active = yield* AccountRepo.use((r) => r.active())
|
||||
const active = yield* AccountRepo.Service.use((r) => r.active())
|
||||
expect(Option.isNone(active)).toBe(true)
|
||||
}),
|
||||
)
|
||||
@@ -33,7 +33,7 @@ it.live("active returns none when no accounts exist", () =>
|
||||
it.live("persistAccount inserts and getRow retrieves", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "test@example.com",
|
||||
@@ -45,13 +45,13 @@ it.live("persistAccount inserts and getRow retrieves", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||
expect(Option.isSome(row)).toBe(true)
|
||||
const value = Option.getOrThrow(row)
|
||||
expect(value.id).toBe(AccountID.make("user-1"))
|
||||
expect(value.email).toBe("test@example.com")
|
||||
|
||||
const active = yield* AccountRepo.use((r) => r.active())
|
||||
const active = yield* AccountRepo.Service.use((r) => r.active())
|
||||
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-1"))
|
||||
}),
|
||||
)
|
||||
@@ -60,7 +60,7 @@ it.live("persistAccount normalizes trailing slashes in stored server URLs", () =
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "test@example.com",
|
||||
@@ -72,9 +72,9 @@ it.live("persistAccount normalizes trailing slashes in stored server URLs", () =
|
||||
}),
|
||||
)
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
const active = yield* AccountRepo.use((r) => r.active())
|
||||
const list = yield* AccountRepo.use((r) => r.list())
|
||||
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||
const active = yield* AccountRepo.Service.use((r) => r.active())
|
||||
const list = yield* AccountRepo.Service.use((r) => r.list())
|
||||
|
||||
expect(Option.getOrThrow(row).url).toBe("https://control.example.com")
|
||||
expect(Option.getOrThrow(active).url).toBe("https://control.example.com")
|
||||
@@ -87,7 +87,7 @@ it.live("persistAccount sets the active account and org", () =>
|
||||
const id1 = AccountID.make("user-1")
|
||||
const id2 = AccountID.make("user-2")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id: id1,
|
||||
email: "first@example.com",
|
||||
@@ -99,7 +99,7 @@ it.live("persistAccount sets the active account and org", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id: id2,
|
||||
email: "second@example.com",
|
||||
@@ -112,7 +112,7 @@ it.live("persistAccount sets the active account and org", () =>
|
||||
)
|
||||
|
||||
// Last persisted account is active with its org
|
||||
const active = yield* AccountRepo.use((r) => r.active())
|
||||
const active = yield* AccountRepo.Service.use((r) => r.active())
|
||||
expect(Option.isSome(active)).toBe(true)
|
||||
expect(Option.getOrThrow(active).id).toBe(AccountID.make("user-2"))
|
||||
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
|
||||
@@ -124,7 +124,7 @@ it.live("list returns all accounts", () =>
|
||||
const id1 = AccountID.make("user-1")
|
||||
const id2 = AccountID.make("user-2")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id: id1,
|
||||
email: "a@example.com",
|
||||
@@ -136,7 +136,7 @@ it.live("list returns all accounts", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id: id2,
|
||||
email: "b@example.com",
|
||||
@@ -148,7 +148,7 @@ it.live("list returns all accounts", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
const accounts = yield* AccountRepo.use((r) => r.list())
|
||||
const accounts = yield* AccountRepo.Service.use((r) => r.list())
|
||||
expect(accounts.length).toBe(2)
|
||||
expect(accounts.map((a) => a.email).sort()).toEqual(["a@example.com", "b@example.com"])
|
||||
}),
|
||||
@@ -158,7 +158,7 @@ it.live("remove deletes an account", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "test@example.com",
|
||||
@@ -170,9 +170,9 @@ it.live("remove deletes an account", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) => r.remove(id))
|
||||
yield* AccountRepo.Service.use((r) => r.remove(id))
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||
expect(Option.isNone(row)).toBe(true)
|
||||
}),
|
||||
)
|
||||
@@ -182,7 +182,7 @@ it.live("use stores the selected org and marks the account active", () =>
|
||||
const id1 = AccountID.make("user-1")
|
||||
const id2 = AccountID.make("user-2")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id: id1,
|
||||
email: "first@example.com",
|
||||
@@ -194,7 +194,7 @@ it.live("use stores the selected org and marks the account active", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id: id2,
|
||||
email: "second@example.com",
|
||||
@@ -206,13 +206,13 @@ it.live("use stores the selected org and marks the account active", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) => r.use(id1, Option.some(OrgID.make("org-99"))))
|
||||
const active1 = yield* AccountRepo.use((r) => r.active())
|
||||
yield* AccountRepo.Service.use((r) => r.use(id1, Option.some(OrgID.make("org-99"))))
|
||||
const active1 = yield* AccountRepo.Service.use((r) => r.active())
|
||||
expect(Option.getOrThrow(active1).id).toBe(id1)
|
||||
expect(Option.getOrThrow(active1).active_org_id).toBe(OrgID.make("org-99"))
|
||||
|
||||
yield* AccountRepo.use((r) => r.use(id1, Option.none()))
|
||||
const active2 = yield* AccountRepo.use((r) => r.active())
|
||||
yield* AccountRepo.Service.use((r) => r.use(id1, Option.none()))
|
||||
const active2 = yield* AccountRepo.Service.use((r) => r.active())
|
||||
expect(Option.getOrThrow(active2).active_org_id).toBeNull()
|
||||
}),
|
||||
)
|
||||
@@ -221,7 +221,7 @@ it.live("persistToken updates token fields", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "test@example.com",
|
||||
@@ -234,7 +234,7 @@ it.live("persistToken updates token fields", () =>
|
||||
)
|
||||
|
||||
const expiry = Date.now() + 7200_000
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistToken({
|
||||
accountID: id,
|
||||
accessToken: AccessToken.make("new_token"),
|
||||
@@ -243,7 +243,7 @@ it.live("persistToken updates token fields", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||
const value = Option.getOrThrow(row)
|
||||
expect(value.access_token).toBe(AccessToken.make("new_token"))
|
||||
expect(value.refresh_token).toBe(RefreshToken.make("new_refresh"))
|
||||
@@ -255,7 +255,7 @@ it.live("persistToken with no expiry sets token_expiry to null", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "test@example.com",
|
||||
@@ -267,7 +267,7 @@ it.live("persistToken with no expiry sets token_expiry to null", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistToken({
|
||||
accountID: id,
|
||||
accessToken: AccessToken.make("new_token"),
|
||||
@@ -276,7 +276,7 @@ it.live("persistToken with no expiry sets token_expiry to null", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||
expect(Option.getOrThrow(row).token_expiry).toBeNull()
|
||||
}),
|
||||
)
|
||||
@@ -285,7 +285,7 @@ it.live("persistAccount upserts on conflict", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "test@example.com",
|
||||
@@ -297,7 +297,7 @@ it.live("persistAccount upserts on conflict", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "test@example.com",
|
||||
@@ -309,14 +309,14 @@ it.live("persistAccount upserts on conflict", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
const accounts = yield* AccountRepo.use((r) => r.list())
|
||||
const accounts = yield* AccountRepo.Service.use((r) => r.list())
|
||||
expect(accounts.length).toBe(1)
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||
const value = Option.getOrThrow(row)
|
||||
expect(value.access_token).toBe(AccessToken.make("at_v2"))
|
||||
|
||||
const active = yield* AccountRepo.use((r) => r.active())
|
||||
const active = yield* AccountRepo.Service.use((r) => r.active())
|
||||
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
|
||||
}),
|
||||
)
|
||||
@@ -325,7 +325,7 @@ it.live("remove clears active state when deleting the active account", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "test@example.com",
|
||||
@@ -337,16 +337,16 @@ it.live("remove clears active state when deleting the active account", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) => r.remove(id))
|
||||
yield* AccountRepo.Service.use((r) => r.remove(id))
|
||||
|
||||
const active = yield* AccountRepo.use((r) => r.active())
|
||||
const active = yield* AccountRepo.Service.use((r) => r.active())
|
||||
expect(Option.isNone(active)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("getRow returns none for nonexistent account", () =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope")))
|
||||
const row = yield* AccountRepo.Service.use((r) => r.getRow(AccountID.make("nope")))
|
||||
expect(Option.isNone(row)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Duration, Effect, Layer, Option, Schema } from "effect"
|
||||
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http"
|
||||
|
||||
import { AccountRepo } from "../../src/account/repo"
|
||||
import { Account } from "../../src/account"
|
||||
import { Account } from "../../src/account/account"
|
||||
import {
|
||||
AccessToken,
|
||||
AccountID,
|
||||
@@ -122,7 +122,7 @@ it.live("login maps transport failures to account transport errors", () =>
|
||||
|
||||
it.live("orgsByAccount groups orgs per account", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id: AccountID.make("user-1"),
|
||||
email: "one@example.com",
|
||||
@@ -134,7 +134,7 @@ it.live("orgsByAccount groups orgs per account", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id: AccountID.make("user-2"),
|
||||
email: "two@example.com",
|
||||
@@ -177,7 +177,7 @@ it.live("token refresh persists the new token", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "user@example.com",
|
||||
@@ -206,7 +206,7 @@ it.live("token refresh persists the new token", () =>
|
||||
expect(Option.getOrThrow(token)).toBeDefined()
|
||||
expect(String(Option.getOrThrow(token))).toBe("at_new")
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||
const value = Option.getOrThrow(row)
|
||||
expect(value.access_token).toBe(AccessToken.make("at_new"))
|
||||
expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
|
||||
@@ -218,7 +218,7 @@ it.live("token refreshes before expiry when inside the eager refresh window", ()
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "user@example.com",
|
||||
@@ -251,7 +251,7 @@ it.live("token refreshes before expiry when inside the eager refresh window", ()
|
||||
expect(String(Option.getOrThrow(token))).toBe("at_new")
|
||||
expect(refreshCalls).toBe(1)
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||
const value = Option.getOrThrow(row)
|
||||
expect(value.access_token).toBe(AccessToken.make("at_new"))
|
||||
expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
|
||||
@@ -262,7 +262,7 @@ it.live("concurrent config and token requests coalesce token refresh", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "user@example.com",
|
||||
@@ -315,7 +315,7 @@ it.live("concurrent config and token requests coalesce token refresh", () =>
|
||||
expect(String(Option.getOrThrow(token))).toBe("at_new")
|
||||
expect(refreshCalls).toBe(1)
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||
const value = Option.getOrThrow(row)
|
||||
expect(value.access_token).toBe(AccessToken.make("at_new"))
|
||||
expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
|
||||
@@ -326,7 +326,7 @@ it.live("config sends the selected org header", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "user@example.com",
|
||||
@@ -388,7 +388,7 @@ it.live("poll stores the account and first org on success", () =>
|
||||
expect(res.email).toBe("user@example.com")
|
||||
}
|
||||
|
||||
const active = yield* AccountRepo.use((r) => r.active())
|
||||
const active = yield* AccountRepo.Service.use((r) => r.active())
|
||||
expect(Option.getOrThrow(active)).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "user-1",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { getRevertDiffFiles } from "../../../src/cli/cmd/tui/util/revert-diff"
|
||||
|
||||
describe("revert diff", () => {
|
||||
test("prefers the actual file path over /dev/null for added and deleted files", () => {
|
||||
const files = getRevertDiffFiles(`diff --git a/new.txt b/new.txt
|
||||
new file mode 100644
|
||||
index 0000000..3b18e51
|
||||
--- /dev/null
|
||||
+++ b/new.txt
|
||||
@@ -0,0 +1 @@
|
||||
+new content
|
||||
diff --git a/old.txt b/old.txt
|
||||
deleted file mode 100644
|
||||
index 3b18e51..0000000
|
||||
--- a/old.txt
|
||||
+++ /dev/null
|
||||
@@ -1 +0,0 @@
|
||||
-old content
|
||||
`)
|
||||
|
||||
expect(files).toEqual([
|
||||
{
|
||||
filename: "new.txt",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
},
|
||||
{
|
||||
filename: "old.txt",
|
||||
additions: 0,
|
||||
deletions: 1,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,8 @@ import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
|
||||
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
|
||||
import { Account } from "../../src/account/account"
|
||||
import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { Env } from "../../src/env"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
@@ -141,6 +142,42 @@ test("loads JSON config file", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("loads formatter boolean config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
formatter: true,
|
||||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
expect(config.formatter).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("loads lsp boolean config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
lsp: true,
|
||||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
expect(config.lsp).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("loads project config from Git Bash and MSYS2 paths on Windows", async () => {
|
||||
// Git Bash and MSYS2 both use /<drive>/... paths on Windows.
|
||||
await check((dir) => {
|
||||
@@ -757,7 +794,7 @@ test("updates config and writes to file", async () => {
|
||||
const newConfig = { model: "updated/model" }
|
||||
await save(newConfig as any)
|
||||
|
||||
const writtenConfig = await Filesystem.readJson(path.join(tmp.path, "config.json"))
|
||||
const writtenConfig = await Filesystem.readJson<{ model: string }>(path.join(tmp.path, "config.json"))
|
||||
expect(writtenConfig.model).toBe("updated/model")
|
||||
},
|
||||
})
|
||||
@@ -2212,19 +2249,22 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
|
||||
// parseManagedPlist unit tests — pure function, no OS interaction
|
||||
|
||||
test("parseManagedPlist strips MDM metadata keys", async () => {
|
||||
const config = ConfigParse.parse(
|
||||
const config = ConfigParse.schema(
|
||||
Config.Info,
|
||||
await ConfigManaged.parseManagedPlist(
|
||||
JSON.stringify({
|
||||
PayloadDisplayName: "OpenCode Managed",
|
||||
PayloadIdentifier: "ai.opencode.managed.test",
|
||||
PayloadType: "ai.opencode.managed",
|
||||
PayloadUUID: "AAAA-BBBB-CCCC",
|
||||
PayloadVersion: 1,
|
||||
_manualProfile: true,
|
||||
share: "disabled",
|
||||
model: "mdm/model",
|
||||
}),
|
||||
ConfigParse.jsonc(
|
||||
await ConfigManaged.parseManagedPlist(
|
||||
JSON.stringify({
|
||||
PayloadDisplayName: "OpenCode Managed",
|
||||
PayloadIdentifier: "ai.opencode.managed.test",
|
||||
PayloadType: "ai.opencode.managed",
|
||||
PayloadUUID: "AAAA-BBBB-CCCC",
|
||||
PayloadVersion: 1,
|
||||
_manualProfile: true,
|
||||
share: "disabled",
|
||||
model: "mdm/model",
|
||||
}),
|
||||
),
|
||||
"test:mobileconfig",
|
||||
),
|
||||
"test:mobileconfig",
|
||||
)
|
||||
@@ -2237,14 +2277,17 @@ test("parseManagedPlist strips MDM metadata keys", async () => {
|
||||
})
|
||||
|
||||
test("parseManagedPlist parses server settings", async () => {
|
||||
const config = ConfigParse.parse(
|
||||
const config = ConfigParse.schema(
|
||||
Config.Info,
|
||||
await ConfigManaged.parseManagedPlist(
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
server: { hostname: "127.0.0.1", mdns: false },
|
||||
autoupdate: true,
|
||||
}),
|
||||
ConfigParse.jsonc(
|
||||
await ConfigManaged.parseManagedPlist(
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
server: { hostname: "127.0.0.1", mdns: false },
|
||||
autoupdate: true,
|
||||
}),
|
||||
),
|
||||
"test:mobileconfig",
|
||||
),
|
||||
"test:mobileconfig",
|
||||
)
|
||||
@@ -2254,20 +2297,23 @@ test("parseManagedPlist parses server settings", async () => {
|
||||
})
|
||||
|
||||
test("parseManagedPlist parses permission rules", async () => {
|
||||
const config = ConfigParse.parse(
|
||||
const config = ConfigParse.schema(
|
||||
Config.Info,
|
||||
await ConfigManaged.parseManagedPlist(
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
permission: {
|
||||
"*": "ask",
|
||||
bash: { "*": "ask", "rm -rf *": "deny", "curl *": "deny" },
|
||||
grep: "allow",
|
||||
glob: "allow",
|
||||
webfetch: "ask",
|
||||
"~/.ssh/*": "deny",
|
||||
},
|
||||
}),
|
||||
ConfigParse.jsonc(
|
||||
await ConfigManaged.parseManagedPlist(
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
permission: {
|
||||
"*": "ask",
|
||||
bash: { "*": "ask", "rm -rf *": "deny", "curl *": "deny" },
|
||||
grep: "allow",
|
||||
glob: "allow",
|
||||
webfetch: "ask",
|
||||
"~/.ssh/*": "deny",
|
||||
},
|
||||
}),
|
||||
),
|
||||
"test:mobileconfig",
|
||||
),
|
||||
"test:mobileconfig",
|
||||
)
|
||||
@@ -2281,13 +2327,16 @@ test("parseManagedPlist parses permission rules", async () => {
|
||||
})
|
||||
|
||||
test("parseManagedPlist parses enabled_providers", async () => {
|
||||
const config = ConfigParse.parse(
|
||||
const config = ConfigParse.schema(
|
||||
Config.Info,
|
||||
await ConfigManaged.parseManagedPlist(
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
enabled_providers: ["anthropic", "google"],
|
||||
}),
|
||||
ConfigParse.jsonc(
|
||||
await ConfigManaged.parseManagedPlist(
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
enabled_providers: ["anthropic", "google"],
|
||||
}),
|
||||
),
|
||||
"test:mobileconfig",
|
||||
),
|
||||
"test:mobileconfig",
|
||||
)
|
||||
@@ -2295,9 +2344,12 @@ test("parseManagedPlist parses enabled_providers", async () => {
|
||||
})
|
||||
|
||||
test("parseManagedPlist handles empty config", async () => {
|
||||
const config = ConfigParse.parse(
|
||||
const config = ConfigParse.schema(
|
||||
Config.Info,
|
||||
await ConfigManaged.parseManagedPlist(JSON.stringify({ $schema: "https://opencode.ai/config.json" })),
|
||||
ConfigParse.jsonc(
|
||||
await ConfigManaged.parseManagedPlist(JSON.stringify({ $schema: "https://opencode.ai/config.json" })),
|
||||
"test:mobileconfig",
|
||||
),
|
||||
"test:mobileconfig",
|
||||
)
|
||||
expect(config.$schema).toBe("https://opencode.ai/config.json")
|
||||
|
||||
@@ -10,37 +10,55 @@ import * as Formatter from "../../src/format/formatter"
|
||||
const it = testEffect(Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer))
|
||||
|
||||
describe("Format", () => {
|
||||
it.live("status() returns built-in formatters when no config overrides", () =>
|
||||
it.live("status() returns empty list when no formatters are configured", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Format.Service.use((fmt) =>
|
||||
Effect.gen(function* () {
|
||||
const statuses = yield* fmt.status()
|
||||
expect(Array.isArray(statuses)).toBe(true)
|
||||
expect(statuses.length).toBeGreaterThan(0)
|
||||
|
||||
for (const item of statuses) {
|
||||
expect(typeof item.name).toBe("string")
|
||||
expect(Array.isArray(item.extensions)).toBe(true)
|
||||
expect(typeof item.enabled).toBe("boolean")
|
||||
}
|
||||
|
||||
const gofmt = statuses.find((item) => item.name === "gofmt")
|
||||
expect(gofmt).toBeDefined()
|
||||
expect(gofmt!.extensions).toContain(".go")
|
||||
expect(yield* fmt.status()).toEqual([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("status() returns empty list when formatter is disabled", () =>
|
||||
it.live("status() returns built-in formatters when formatter is true", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Format.Service.use((fmt) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* fmt.status()).toEqual([])
|
||||
const statuses = yield* fmt.status()
|
||||
const gofmt = statuses.find((item) => item.name === "gofmt")
|
||||
expect(gofmt).toBeDefined()
|
||||
expect(gofmt!.extensions).toContain(".go")
|
||||
}),
|
||||
),
|
||||
{ config: { formatter: false } },
|
||||
{
|
||||
config: {
|
||||
formatter: true,
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
it.live("status() keeps built-in formatters when config object is provided", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Format.Service.use((fmt) =>
|
||||
Effect.gen(function* () {
|
||||
const statuses = yield* fmt.status()
|
||||
const gofmt = statuses.find((item) => item.name === "gofmt")
|
||||
const mix = statuses.find((item) => item.name === "mix")
|
||||
expect(gofmt).toBeDefined()
|
||||
expect(gofmt!.extensions).toContain(".go")
|
||||
expect(mix).toBeDefined()
|
||||
}),
|
||||
),
|
||||
{
|
||||
config: {
|
||||
formatter: {
|
||||
gofmt: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -51,7 +69,9 @@ describe("Format", () => {
|
||||
Effect.gen(function* () {
|
||||
const statuses = yield* fmt.status()
|
||||
const gofmt = statuses.find((item) => item.name === "gofmt")
|
||||
const mix = statuses.find((item) => item.name === "mix")
|
||||
expect(gofmt).toBeUndefined()
|
||||
expect(mix).toBeDefined()
|
||||
}),
|
||||
),
|
||||
{
|
||||
@@ -111,68 +131,81 @@ describe("Format", () => {
|
||||
const a = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status()), {
|
||||
config: { formatter: false },
|
||||
})
|
||||
const b = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status()))
|
||||
const b = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status()), {
|
||||
config: {
|
||||
formatter: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(a).toEqual([])
|
||||
expect(b.length).toBeGreaterThan(0)
|
||||
expect(b.find((item) => item.name === "gofmt")).toBeDefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("runs enabled checks for matching formatters in parallel", () =>
|
||||
provideTmpdirInstance((path) =>
|
||||
Effect.gen(function* () {
|
||||
const file = `${path}/test.parallel`
|
||||
yield* Effect.promise(() => Bun.write(file, "x"))
|
||||
provideTmpdirInstance(
|
||||
(path) =>
|
||||
Effect.gen(function* () {
|
||||
const file = `${path}/test.parallel`
|
||||
yield* Effect.promise(() => Bun.write(file, "x"))
|
||||
|
||||
const one = {
|
||||
extensions: Formatter.gofmt.extensions,
|
||||
enabled: Formatter.gofmt.enabled,
|
||||
}
|
||||
const two = {
|
||||
extensions: Formatter.mix.extensions,
|
||||
enabled: Formatter.mix.enabled,
|
||||
}
|
||||
const one = {
|
||||
extensions: Formatter.gofmt.extensions,
|
||||
enabled: Formatter.gofmt.enabled,
|
||||
}
|
||||
const two = {
|
||||
extensions: Formatter.mix.extensions,
|
||||
enabled: Formatter.mix.enabled,
|
||||
}
|
||||
|
||||
let active = 0
|
||||
let max = 0
|
||||
let active = 0
|
||||
let max = 0
|
||||
|
||||
yield* Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
Formatter.gofmt.extensions = [".parallel"]
|
||||
Formatter.mix.extensions = [".parallel"]
|
||||
Formatter.gofmt.enabled = async () => {
|
||||
active++
|
||||
max = Math.max(max, active)
|
||||
await Bun.sleep(20)
|
||||
active--
|
||||
return ["sh", "-c", "true"]
|
||||
}
|
||||
Formatter.mix.enabled = async () => {
|
||||
active++
|
||||
max = Math.max(max, active)
|
||||
await Bun.sleep(20)
|
||||
active--
|
||||
return ["sh", "-c", "true"]
|
||||
}
|
||||
}),
|
||||
() =>
|
||||
Format.Service.use((fmt) =>
|
||||
Effect.gen(function* () {
|
||||
yield* fmt.init()
|
||||
yield* fmt.file(file)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
yield* Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
Formatter.gofmt.extensions = one.extensions
|
||||
Formatter.gofmt.enabled = one.enabled
|
||||
Formatter.mix.extensions = two.extensions
|
||||
Formatter.mix.enabled = two.enabled
|
||||
Formatter.gofmt.extensions = [".parallel"]
|
||||
Formatter.mix.extensions = [".parallel"]
|
||||
Formatter.gofmt.enabled = async () => {
|
||||
active++
|
||||
max = Math.max(max, active)
|
||||
await Bun.sleep(20)
|
||||
active--
|
||||
return ["sh", "-c", "true"]
|
||||
}
|
||||
Formatter.mix.enabled = async () => {
|
||||
active++
|
||||
max = Math.max(max, active)
|
||||
await Bun.sleep(20)
|
||||
active--
|
||||
return ["sh", "-c", "true"]
|
||||
}
|
||||
}),
|
||||
)
|
||||
() =>
|
||||
Format.Service.use((fmt) =>
|
||||
Effect.gen(function* () {
|
||||
yield* fmt.init()
|
||||
yield* fmt.file(file)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
Formatter.gofmt.extensions = one.extensions
|
||||
Formatter.gofmt.enabled = one.enabled
|
||||
Formatter.mix.extensions = two.extensions
|
||||
Formatter.mix.enabled = two.enabled
|
||||
}),
|
||||
)
|
||||
|
||||
expect(max).toBe(2)
|
||||
}),
|
||||
expect(max).toBe(2)
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
formatter: {
|
||||
gofmt: {},
|
||||
mix: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -11,15 +11,38 @@ const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.default
|
||||
|
||||
describe("lsp.spawn", () => {
|
||||
it.live("does not spawn builtin LSP for files outside instance", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
try {
|
||||
yield* lsp.touchFile(path.join(dir, "..", "outside.ts"))
|
||||
yield* lsp.hover({
|
||||
file: path.join(dir, "..", "hover.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(spy).toHaveBeenCalledTimes(0)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
}),
|
||||
),
|
||||
{ config: { lsp: true } },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not spawn builtin LSP for files inside instance when LSP is unset", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
try {
|
||||
yield* lsp.touchFile(path.join(dir, "..", "outside.ts"))
|
||||
yield* lsp.hover({
|
||||
file: path.join(dir, "..", "hover.ts"),
|
||||
file: path.join(dir, "src", "inside.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
@@ -32,24 +55,55 @@ describe("lsp.spawn", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("would spawn builtin LSP for files inside instance", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
|
||||
it.live("would spawn builtin LSP for files inside instance when lsp is true", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
try {
|
||||
yield* lsp.hover({
|
||||
file: path.join(dir, "src", "inside.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
}),
|
||||
),
|
||||
try {
|
||||
yield* lsp.hover({
|
||||
file: path.join(dir, "src", "inside.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
}),
|
||||
),
|
||||
{ config: { lsp: true } },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("would spawn builtin LSP for files inside instance when config object is provided", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
try {
|
||||
yield* lsp.hover({
|
||||
file: path.join(dir, "src", "inside.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
}),
|
||||
),
|
||||
{
|
||||
config: {
|
||||
lsp: {
|
||||
eslint: { disabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -46,17 +46,49 @@ describe("LSP service lifecycle", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("hasClients() returns true for .ts files in instance", () =>
|
||||
it.live("hasClients() returns false for .ts files in instance when LSP is unset", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.hasClients(path.join(dir, "test.ts"))
|
||||
expect(result).toBe(true)
|
||||
expect(result).toBe(false)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("hasClients() returns true for .ts files in instance when lsp is true", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.hasClients(path.join(dir, "test.ts"))
|
||||
expect(result).toBe(true)
|
||||
}),
|
||||
),
|
||||
{ config: { lsp: true } },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("hasClients() keeps built-in LSPs when config object is provided", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.hasClients(path.join(dir, "test.ts"))
|
||||
expect(result).toBe(true)
|
||||
}),
|
||||
),
|
||||
{
|
||||
config: {
|
||||
lsp: {
|
||||
eslint: { disabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
it.live("hasClients() returns false for files outside instance", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
|
||||
@@ -14,7 +14,6 @@ const { Instance } = await import("../../src/project/instance")
|
||||
|
||||
const experimental = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
|
||||
// @ts-expect-error tests override the flag directly
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -28,7 +27,6 @@ afterAll(() => {
|
||||
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
|
||||
}
|
||||
|
||||
// @ts-expect-error restore original test flag value
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = experimental
|
||||
})
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import { beforeEach, describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Layer, Option } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
|
||||
import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account"
|
||||
import { Account } from "../../src/account"
|
||||
import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema"
|
||||
import { Account } from "../../src/account/account"
|
||||
import { AccountRepo } from "../../src/account/repo"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { Bus } from "../../src/bus"
|
||||
@@ -72,7 +72,7 @@ const share = (id: SessionID) =>
|
||||
Database.use((db) => db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, id)).get())
|
||||
|
||||
const seed = (url: string, org?: string) =>
|
||||
AccountRepo.use((repo) =>
|
||||
AccountRepo.Service.use((repo) =>
|
||||
repo.persistAccount({
|
||||
id: AccountID.make("account-1"),
|
||||
email: "user@example.com",
|
||||
|
||||
@@ -15,12 +15,10 @@ const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
beforeEach(() => {
|
||||
Database.close()
|
||||
|
||||
// @ts-expect-error don't do this normally, but it works
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// @ts-expect-error don't do this normally, but it works
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original
|
||||
})
|
||||
|
||||
|
||||
@@ -25,14 +25,12 @@ const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
|
||||
beforeEach(() => {
|
||||
Database.close()
|
||||
// @ts-expect-error test override
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
await Instance.disposeAll()
|
||||
// @ts-expect-error test override
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user