Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline edf0ce766d fix(core): restore external directory defaults 2026-07-17 18:30:07 +00:00
65 changed files with 631 additions and 2117 deletions
-161
View File
@@ -1,161 +0,0 @@
#!/usr/bin/env node
import childProcess from "node:child_process"
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { createRequire } from "node:module"
import { fileURLToPath } from "node:url"
const directory = path.dirname(fileURLToPath(import.meta.url))
const require = createRequire(import.meta.url)
const packageJson = JSON.parse(fs.readFileSync(path.join(directory, "package.json"), "utf8"))
const command = Object.keys(packageJson.bin ?? {})[0]
if (!command) throw new Error("OpenCode package does not declare a binary")
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] ?? os.platform()
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] ?? os.arch()
const sourceBinary = platform === "windows" ? `${command}.exe` : command
const targetBinary = path.resolve(directory, packageJson.bin[command])
const dependencies = packageJson.optionalDependencies ?? {}
const base = Object.keys(dependencies).find((name) => name.endsWith(`-${platform}-${arch}`))
if (!base) throw new Error(`OpenCode does not provide a binary for ${platform}-${arch}`)
function supportsAvx2() {
if (arch !== "x64") return false
if (platform === "linux") {
try {
return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8"))
} catch {
return false
}
}
if (platform === "darwin") {
try {
const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], {
encoding: "utf8",
timeout: 1500,
})
return result.status === 0 && (result.stdout || "").trim() === "1"
} catch {
return false
}
}
if (platform === "windows") {
const script =
'(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)'
for (const executable of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) {
try {
const result = childProcess.spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", script], {
encoding: "utf8",
timeout: 3000,
windowsHide: true,
})
if (result.status !== 0) continue
const output = (result.stdout || "").trim().toLowerCase()
if (output === "true" || output === "1") return true
if (output === "false" || output === "0") return false
} catch {
continue
}
}
}
return false
}
function isMusl() {
if (platform !== "linux") return false
try {
if (fs.existsSync("/etc/alpine-release")) return true
const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" })
return `${result.stdout || ""}${result.stderr || ""}`.toLowerCase().includes("musl")
} catch {
return false
}
}
function packageNames() {
const baseline = arch === "x64" && !supportsAvx2()
const names =
platform === "linux"
? isMusl()
? arch === "x64"
? baseline
? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base]
: [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]
: [`${base}-musl`, base]
: arch === "x64"
? baseline
? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`]
: [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]
: [base, `${base}-musl`]
: arch === "x64"
? baseline
? [`${base}-baseline`, base]
: [base, `${base}-baseline`]
: [base]
return names.filter((name) => dependencies[name])
}
function copyBinary(source) {
if (!fs.existsSync(source)) throw new Error(`Binary not found at ${source}`)
fs.mkdirSync(path.dirname(targetBinary), { recursive: true })
if (fs.existsSync(targetBinary)) fs.unlinkSync(targetBinary)
try {
fs.linkSync(source, targetBinary)
} catch {
fs.copyFileSync(source, targetBinary)
}
fs.chmodSync(targetBinary, 0o755)
}
function resolveBinary(name) {
const packagePath = require.resolve(`${name}/package.json`)
return path.join(path.dirname(packagePath), "bin", sourceBinary)
}
function installPackage(name) {
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-install-"))
try {
const result = childProcess.spawnSync(
"npm",
["install", "--ignore-scripts", "--no-save", "--loglevel=error", "--prefix", temp, `${name}@${dependencies[name]}`],
{ stdio: "inherit", windowsHide: true },
)
if (result.status !== 0) return false
copyBinary(path.join(temp, "node_modules", name, "bin", sourceBinary))
return true
} finally {
fs.rmSync(temp, { recursive: true, force: true })
}
}
function verifyBinary() {
return (
childProcess.spawnSync(targetBinary, ["--version"], {
stdio: "ignore",
windowsHide: true,
}).status === 0
)
}
function main() {
const names = packageNames()
for (const name of names) {
try {
copyBinary(resolveBinary(name))
if (verifyBinary()) return
} catch {
if (installPackage(name) && verifyBinary()) return
}
}
throw new Error(`Failed to install OpenCode. Try manually installing ${names.map((name) => JSON.stringify(name)).join(" or ")}.`)
}
try {
main()
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
}
+2 -13
View File
@@ -32,23 +32,12 @@ async function publishDistribution(input: { root: string; name: string; binary:
if (!version) throw new Error(`No binary packages found for ${input.name}`)
await $`mkdir -p ${input.root}/${input.name}/bin`
await $`cp ./script/postinstall.mjs ${input.root}/${input.name}/postinstall.mjs`
await Bun.file(`${input.root}/${input.name}/bin/${input.binary}.exe`).write(
[
`echo "Error: ${input.name}'s postinstall script was not run." >&2`,
'echo "" >&2',
'echo "This occurs when installation scripts are disabled." >&2',
'echo "Run the package postinstall script or reinstall with scripts enabled." >&2',
"exit 1",
"",
].join("\n"),
)
await $`cp ./bin/opencode2.cjs ${input.root}/${input.name}/bin/${input.binary}`
await Bun.file(`${input.root}/${input.name}/package.json`).write(
JSON.stringify(
{
name: input.name,
bin: { [input.binary]: `./bin/${input.binary}.exe` },
scripts: { postinstall: "node ./postinstall.mjs" },
bin: { [input.binary]: `./bin/${input.binary}` },
version,
license: pkg.license,
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
-4
View File
@@ -114,10 +114,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
}),
],
}),
Spec.make("plugin", {
description: "Manage plugins",
commands: [Spec.make("list", { description: "List active plugins" })],
}),
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
Spec.make("mini", {
description: "Start the minimal interactive interface",
@@ -1,4 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/core/global"
import { run } from "@opencode-ai/tui"
import { Commands } from "../commands"
@@ -75,6 +75,6 @@ export default Runtime.handler(Commands, (input) =>
: Effect.logInfo(message, tags)
runFork(effect)
},
}).pipe(Effect.provide(LayerNode.compile(Global.node)))
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))
}),
)
@@ -1,24 +0,0 @@
import { EOL } from "node:os"
import { Effect } from "effect"
import { OpenCode } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.plugin.commands.list,
Effect.fn("cli.plugin.list")(function* () {
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.ensure(options))
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
const plugins = response.data.toSorted((a, b) => a.id.localeCompare(b.id))
if (plugins.length === 0) {
process.stdout.write("No plugins loaded" + EOL)
return
}
process.stdout.write(plugins.map((plugin) => plugin.id).join(EOL) + EOL)
}),
)
+2 -4
View File
@@ -7,6 +7,7 @@ import { Runtime } from "./framework/runtime"
import { Observability } from "@opencode-ai/core/observability"
import { Updater } from "./services/updater"
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { AppProcess } from "@opencode-ai/core/process"
@@ -31,9 +32,6 @@ const Handlers = Runtime.handlers(Commands, {
auth: () => import("./commands/handlers/mcp/auth"),
logout: () => import("./commands/handlers/mcp/logout"),
},
plugin: {
list: () => import("./commands/handlers/plugin/list"),
},
migrate: () => import("./commands/handlers/migrate"),
mini: () => import("./commands/handlers/mini"),
run: () => import("./commands/handlers/run"),
@@ -60,7 +58,7 @@ Effect.logInfo("cli starting", {
Effect.annotateLogs({ role: "cli" }),
Effect.provide(Config.layer),
Effect.provide(Updater.layer),
Effect.provide(LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]))),
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node, Npm.node]))),
Effect.provide(Observability.layer),
Effect.provide(NodeServices.layer),
Effect.scoped,
+2 -2
View File
@@ -7,8 +7,8 @@
// none block each other.
import { Context, Effect, Layer } from "effect"
import { resolve } from "@opencode-ai/tui/config/v1"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { loadRunProviders } from "./catalog.shared"
import { resolveCurrentSession, sessionHistory } from "./session.shared"
@@ -130,7 +130,7 @@ const layer = Layer.effect(
)
const node = makeGlobalNode({ service: Service, layer, deps: [] })
const runtime = makeRuntime(Service, LayerNode.compile(node))
const runtime = makeRuntime(Service, AppNodeBuilder.build(node))
// Fetches available variants and context limits for every provider/model pair.
export async function resolveModelInfo(
+2 -1
View File
@@ -8,6 +8,7 @@
// variant and the persisted file.
import path from "path"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Context, Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
@@ -202,7 +203,7 @@ const node = makeGlobalNode({ service: Service, layer, deps: [FSUtil.node] })
/** @internal Exported for testing. */
export function createVariantRuntime(replacements?: readonly LayerNode.Replacement[]): VariantRuntime {
const runtime = makeRuntime(Service, LayerNode.compile(node, replacements))
const runtime = makeRuntime(Service, AppNodeBuilder.build(node, replacements))
return {
resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined),
saveVariant: (model, variant) => runtime.runPromise((svc) => svc.saveVariant(model, variant)).catch(() => {}),
+40 -71
View File
@@ -1,11 +1,13 @@
export * as ServerProcess from "./server-process"
import { NodeServices } from "@effect/platform-node"
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
import { Service } from "@opencode-ai/client/effect/service"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { AppProcess } from "@opencode-ai/core/process"
import { ProcessLock } from "@opencode-ai/core/util/process-lock"
import { randomBytes, randomUUID } from "node:crypto"
import path from "node:path"
import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect"
@@ -26,7 +28,7 @@ export type Options = {
export const run = Effect.fnUntraced(function* (options: Options) {
return yield* processEffect(options).pipe(
Effect.provide(Updater.layer),
Effect.provide(LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]))),
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
Effect.provide(NodeServices.layer),
)
})
@@ -36,15 +38,14 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
return yield* Effect.scoped(
Effect.gen(function* () {
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
const hostname = options.hostname ?? config.hostname ?? "127.0.0.1"
const port = options.port ?? config.port ?? (options.mode === "service" ? ServiceConfig.defaultPort() : undefined)
if (
serviceOptions !== undefined &&
port !== undefined &&
(yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })) !== undefined
)
return
if (serviceOptions !== undefined) {
const acquired = yield* ProcessLock.acquire(serviceOptions.file + ".lock").pipe(
Effect.as(true),
Effect.catchTag("ProcessLockHeldError", () => Effect.succeed(false)),
)
if (!acquired) return yield* Effect.void
if ((yield* Service.discover(serviceOptions)) !== undefined) return yield* Effect.void
}
const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process"))
const environmentPassword = yield* Env.password
// Keep the lease credential out of the environment inherited by tools.
@@ -52,6 +53,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
delete process.env.OPENCODE_PASSWORD
delete process.env.OPENCODE_SERVER_PASSWORD
}
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
const password =
options.mode === "service"
? yield* ServiceConfig.password()
@@ -61,36 +63,15 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const instanceID = randomUUID()
const server = yield* start({
hostname,
port: Option.fromNullishOr(port),
hostname: options.hostname ?? config.hostname ?? "127.0.0.1",
port: Option.fromNullishOr(options.port ?? config.port),
password,
instanceID,
service:
serviceOptions === undefined
? undefined
: {
onListen: (address, shutdown) => register(address, password, instanceID, serviceOptions.file, shutdown),
},
}).pipe(
Effect.provide(Logger.layer([], { mergeWithExisting: false })),
Effect.catch((error) => {
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
return recognizeIncumbent(serviceOptions, hostname, port).pipe(
Effect.flatMap((found) =>
found
? Effect.void
: Effect.fail(
new Error(
`Managed service port ${port} on ${hostname} is already in use by another process. ` +
"Configure another port with `opencode service set port <port>` and start the service again.",
{ cause: error },
),
),
),
)
}),
)
if (server === undefined) return
: { onListen: (address) => register(address, password, instanceID, serviceOptions.file) },
}).pipe(Effect.provide(Logger.layer([], { mergeWithExisting: false })))
const url = HttpServer.formatAddress(server.address)
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`)
@@ -114,7 +95,6 @@ const register = Effect.fnUntraced(function* (
password: string,
id: string,
file: string,
shutdown: Effect.Effect<void>,
) {
const fs = yield* FileSystem.FileSystem
const temp = file + "." + id + ".tmp"
@@ -127,49 +107,38 @@ const register = Effect.fnUntraced(function* (
password,
}
const encoded = yield* encodeInfo(info)
const publish = fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
yield* publish
const current = fs.readFileString(file).pipe(
Effect.flatMap(decodeInfo),
Effect.orElseSucceed(() => undefined),
)
const owns = (found: Info | undefined) =>
found?.id === info.id &&
found.version === info.version &&
found.url === info.url &&
found.pid === info.pid &&
found.password === info.password
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
yield* current.pipe(
Effect.filterOrFail(owns),
const assertRegistration = Effect.gen(function* () {
const found = yield* current
if (
found !== undefined &&
found.id === info.id &&
found.version === info.version &&
found.url === info.url &&
found.pid === info.pid &&
found.password === info.password
)
return
yield* publish
})
yield* Effect.addFinalizer(() =>
current.pipe(
Effect.flatMap((current) => (current?.id === id ? fs.remove(file) : Effect.void)),
Effect.ignore,
),
)
yield* assertRegistration.pipe(
Effect.catchCause((cause) => Effect.logWarning("failed to reassert service registration", { cause })),
Effect.repeat(Schedule.spaced("5 seconds")),
Effect.ignore,
Effect.andThen(shutdown),
Effect.forkScoped,
)
return current.pipe(
Effect.flatMap((found) => (owns(found) ? fs.remove(file) : Effect.void)),
Effect.ignore,
)
})
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
const found = yield* Service.incumbent({ ...options, url: serviceURL(hostname, port) }).pipe(
Effect.filterOrFail((value) => value !== undefined),
Effect.retry(Schedule.max([Schedule.spaced("100 millis"), Schedule.recurs(60)])),
Effect.option,
)
return Option.isSome(found)
})
function serviceURL(hostname: string, port: number) {
return `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${port}`
}
function addressInUse(error: unknown): boolean {
if (typeof error !== "object" || error === null) return false
if ("code" in error && error.code === "EADDRINUSE") return true
return "cause" in error && addressInUse(error.cause)
}
function waitForStdinClose() {
return Effect.callback<void>((resume) => {
const close = () => resume(Effect.void)
@@ -30,12 +30,6 @@ export function filename(channel = InstallationChannel) {
return `service-${Hash.fast(channel)}.json`
}
export function defaultPort(channel = InstallationChannel) {
if (channel === "latest") return 0xc0de
if (channel === "local") return 0xc0df
return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000)
}
export function versionBelongsToChannel(
version: string | undefined,
channel = InstallationChannel,
+2 -2
View File
@@ -1,6 +1,6 @@
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Deferred, Effect, Schema, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { randomBytes } from "node:crypto"
@@ -53,7 +53,7 @@ const makeEndpoint = Effect.fn("cli.standalone.endpoint")(
pid: proc.pid,
} satisfies Endpoint & { readonly pid: number }
},
Effect.provide(LayerNode.compile(CrossSpawnSpawner.node)),
Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)),
)
export function start(options: Options = {}) {
+20 -244
View File
@@ -1,5 +1,5 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Service, type Info } from "@opencode-ai/client/effect/service"
import { Service } from "@opencode-ai/client/effect/service"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
@@ -17,13 +17,6 @@ import os from "node:os"
import path from "node:path"
import { ServiceConfig } from "../src/services/service-config"
test("managed service ports are stable per installation channel", () => {
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("local")).toBe(0xc0df)
expect(ServiceConfig.defaultPort("preview-a")).toBe(ServiceConfig.defaultPort("preview-a"))
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
})
test("local channel stores service config with the local service filename", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
try {
@@ -97,80 +90,6 @@ test("preview registration migration never moves stable discovery", async () =>
}
})
test("managed service writes its registration once", async () => {
const service = await startManagedService("opencode-service-once-")
try {
const before = await fs.stat(service.registration)
await Bun.sleep(6_000)
const after = await fs.stat(service.registration)
expect(after.ino).toBe(before.ino)
expect(after.mtimeMs).toBe(before.mtimeMs)
expect(await Bun.file(service.registration).json()).toEqual(service.info)
} finally {
await stopManagedService(service)
}
}, 30_000)
test("deleting a managed service registration stops its owner", async () => {
const service = await startManagedService("opencode-service-delete-")
try {
await fs.rm(service.registration)
expect(await waitForExit(service.owner)).toBe(true)
expect(await Bun.file(service.registration).exists()).toBe(false)
await expectPortAvailable(service.port)
} finally {
await stopManagedService(service)
}
}, 30_000)
test("deleting a failed service registration stops its owner", async () => {
const service = await startManagedService("opencode-service-failed-delete-", true)
try {
await waitForFailed(service.info)
await fs.rm(service.registration)
expect(await waitForExit(service.owner)).toBe(true)
await expectPortAvailable(service.port)
} finally {
await stopManagedService(service)
}
}, 30_000)
test("corrupting a managed service registration stops its owner", async () => {
const service = await startManagedService("opencode-service-corrupt-")
try {
await fs.writeFile(service.registration, "not-json")
expect(await waitForExit(service.owner)).toBe(true)
expect(await Bun.file(service.registration).text()).toBe("not-json")
await expectPortAvailable(service.port)
} finally {
await stopManagedService(service)
}
}, 30_000)
test("replacing a managed service registration stops its owner and preserves the foreign owner", async () => {
const service = await startManagedService("opencode-service-foreign-")
const foreign = { ...service.info, id: "foreign-owner", pid: process.pid }
try {
await fs.writeFile(service.registration, JSON.stringify(foreign))
expect(await waitForExit(service.owner)).toBe(true)
expect(await Bun.file(service.registration).json()).toEqual(foreign)
await expectPortAvailable(service.port)
} finally {
await stopManagedService(service)
}
}, 30_000)
test("clean managed service shutdown removes its registration", async () => {
const service = await startManagedService("opencode-service-clean-")
try {
await Effect.runPromise(Service.stop({ file: service.registration }).pipe(Effect.provide(NodeFileSystem.layer)))
expect(await waitForExit(service.owner)).toBe(true)
expect(await Bun.file(service.registration).exists()).toBe(false)
} finally {
await stopManagedService(service)
}
}, 30_000)
test("concurrent service processes elect one server", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-election-"))
const database = path.join(root, "opencode.db")
@@ -211,9 +130,6 @@ test("concurrent service processes elect one server", async () => {
)
const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"]
const registration = path.join(root, "state", "opencode", "service-local.json")
const port = await availablePort()
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
const processes = Array.from({ length: 10 }, () => Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" }))
try {
@@ -221,13 +137,11 @@ test("concurrent service processes elect one server", async () => {
const winner = processes.find((process) => process.pid === info.pid)
const losers = processes.filter((process) => process.pid !== info.pid)
const exited = await Promise.all(
losers.map((process) => Promise.race([process.exited.then(() => true), Bun.sleep(60_000).then(() => false)])),
losers.map((process) => Promise.race([process.exited.then(() => true), Bun.sleep(10_000).then(() => false)])),
)
expect(exited).toEqual(losers.map(() => true))
expect(winner?.exitCode).toBe(null)
expect(new URL(info.url).port).toBe(String(port))
expect(await Bun.file(registration + ".lock").exists()).toBe(false)
expect(
await fetch(new URL("/api/health", info.url), {
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
@@ -237,6 +151,20 @@ test("concurrent service processes elect one server", async () => {
version: info.version,
pid: info.pid,
})
const blockedTemp = registration + "." + info.id + ".tmp"
await fs.mkdir(blockedTemp)
await fs.rm(registration)
await Bun.sleep(6_000)
expect(await Bun.file(registration).exists()).toBe(false)
await fs.rm(blockedTemp, { recursive: true })
const restored = await waitForInfo(registration)
expect(restored.id).toBe(info.id)
expect(restored.pid).toBe(info.pid)
await fs.writeFile(registration, "not-json")
const repaired = await waitForInfo(registration)
expect(repaired.id).toBe(info.id)
expect(repaired.pid).toBe(info.pid)
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
try {
const contenderExited = await Promise.race([
@@ -274,88 +202,9 @@ test("concurrent service processes elect one server", async () => {
await fs.rm(root, { recursive: true, force: true })
}
}
}, 120_000)
}, 60_000)
test("configured managed service port overrides the channel default", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-port-"))
const port = await availablePort()
const env = serviceEnv(root)
const registration = path.join(root, "state", "opencode", "service-local.json")
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
env,
stderr: "pipe",
stdout: "ignore",
})
try {
const info = await waitForInfo(registration)
expect(new URL(info.url).port).toBe(String(port))
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
await owner.exited
} finally {
owner.kill("SIGTERM")
await owner.exited
await fs.rm(root, { recursive: true, force: true })
}
}, 30_000)
test("unrelated managed port occupancy reports an actionable conflict", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-conflict-"))
const listener = Bun.serve({ port: 0, fetch: () => new Response("unrelated") })
const port = listener.port
const registration = path.join(root, "state", "opencode", "service-local.json")
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
const contender = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
env: serviceEnv(root),
stderr: "pipe",
stdout: "pipe",
})
try {
expect(await contender.exited).not.toBe(0)
const output = (await new Response(contender.stdout).text()) + (await new Response(contender.stderr).text())
expect(output).toContain(`Managed service port ${port} on 127.0.0.1 is already in use by another process`)
expect(output).toContain("opencode service set port <port>")
expect(await Bun.file(registration).exists()).toBe(false)
} finally {
listener.stop(true)
contender.kill("SIGTERM")
await contender.exited
await fs.rm(root, { recursive: true, force: true })
}
}, 30_000)
test("stale dead registration is replaced after binding the selected port", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-stale-"))
const port = await availablePort()
const registration = path.join(root, "state", "opencode", "service-local.json")
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
await fs.mkdir(path.dirname(registration), { recursive: true })
await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
await fs.writeFile(
registration,
JSON.stringify({ id: "dead", version: "dead", url: `http://127.0.0.1:${port}`, pid: 2_147_483_647 }),
)
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
env: serviceEnv(root),
stderr: "pipe",
stdout: "ignore",
})
try {
const info = await waitForInfo(registration, (value) => value.id !== "dead")
expect(new URL(info.url).port).toBe(String(port))
expect(info.pid).toBe(owner.pid)
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
await owner.exited
} finally {
owner.kill("SIGTERM")
await owner.exited
await fs.rm(root, { recursive: true, force: true })
}
}, 30_000)
test("a failed service stays registered and owns the selected port until stopped", async () => {
test("a failed service stays registered and owns the lock until stopped", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-failed-"))
const database = path.join(root, "database")
await fs.mkdir(database)
@@ -426,86 +275,13 @@ function waitForExecutionStart(file: string, sessionID: SessionV2.ID) {
)
}
async function waitForInfo(file: string, accept: (info: Info) => boolean = () => true) {
async function waitForInfo(file: string) {
for (let attempt = 0; attempt < 400; attempt++) {
const value = await Bun.file(file)
.json()
.catch(() => undefined)
if (value !== undefined) {
const info = await Schema.decodeUnknownPromise(Service.Info)(value)
if (accept(info)) return info
}
if (value !== undefined) return Schema.decodeUnknownPromise(Service.Info)(value)
await Bun.sleep(50)
}
throw new Error("Timed out waiting for service registration")
}
async function waitForFailed(info: Info) {
for (let attempt = 0; attempt < 400; attempt++) {
const status = await fetch(new URL("/api/health", info.url), {
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
})
.then((response) => response.status)
.catch(() => undefined)
if (status === 500) return
await Bun.sleep(50)
}
throw new Error("Timed out waiting for service boot failure")
}
async function availablePort() {
const server = Bun.serve({ port: 0, fetch: () => new Response() })
const port = server.port
await server.stop(true)
if (port === undefined) throw new Error("Server did not bind a port")
return port
}
function serviceEnv(root: string) {
return {
...process.env,
HOME: root,
OPENCODE_DB: path.join(root, "opencode.db"),
OPENCODE_TEST_HOME: root,
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_CONFIG_HOME: path.join(root, "config"),
XDG_DATA_HOME: path.join(root, "data"),
XDG_STATE_HOME: path.join(root, "state"),
}
}
async function startManagedService(prefix: string, failBoot = false) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix))
const port = await availablePort()
const registration = path.join(root, "state", "opencode", "service-local.json")
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
if (failBoot) await fs.mkdir(path.join(root, "database"))
await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
env: failBoot ? { ...serviceEnv(root), OPENCODE_DB: path.join(root, "database") } : serviceEnv(root),
stderr: "pipe",
stdout: "ignore",
})
const info = await waitForInfo(registration).catch(async (cause) => {
owner.kill("SIGTERM")
await owner.exited
await fs.rm(root, { recursive: true, force: true })
throw cause
})
return { root, port, registration, owner, info }
}
async function stopManagedService(service: Awaited<ReturnType<typeof startManagedService>>) {
service.owner.kill("SIGTERM")
await service.owner.exited
await fs.rm(service.root, { recursive: true, force: true })
}
function waitForExit(process: Bun.Subprocess, timeout = 10_000) {
return Promise.race([process.exited.then(() => true), Bun.sleep(timeout).then(() => false)])
}
async function expectPortAvailable(port: number) {
const server = Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response() })
await server.stop(true)
}
+3 -21
View File
@@ -29,17 +29,6 @@ export const discover = Effect.fn("service.discover")(function* (options: Discov
return (yield* discoverLocal(options))?.endpoint
})
/** Recognize an authenticated compatible service bound to an expected URL, including while it starts or fails. */
export const incumbent = Effect.fn("service.incumbent")(function* (
options: DiscoverOptions & { readonly url: string },
) {
const info = yield* read(options.file)
const found = info === undefined ? undefined : yield* probe({ ...info, url: options.url })
if (found === undefined || found.legacy) return undefined
if (options.version !== undefined && found.version !== options.version) return undefined
return { endpoint: found.endpoint, state: found.state }
})
const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
const found = (yield* registered(options.file)).service
if (found?.state !== "ready") return undefined
@@ -112,15 +101,8 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
lastSpawn = Date.now()
}
return Option.none<LocalService>()
}).pipe(
Effect.repeat({
until: Option.isSome,
schedule: Schedule.max([Schedule.spaced("1 second"), Schedule.recurs(120)]),
}),
)
if (Option.isNone(found))
return yield* Effect.fail(new Error("Timed out waiting for the background service to start"))
return found.value.endpoint
}).pipe(Effect.repeat({ until: Option.isSome, schedule: Schedule.spaced("1 second") }))
return Option.getOrThrow(found).endpoint
})
function contenderFailure(contender: Contender) {
@@ -291,4 +273,4 @@ const requestStop = Effect.fnUntraced(function* (service: LocalService) {
})
/** Effect-based local service lifecycle operations. */
export const Service = { discover, incumbent, ensure, stop, headers, Info }
export const Service = { discover, ensure, stop, headers, Info }
+9 -8
View File
@@ -2,7 +2,13 @@ import { readFile } from "node:fs/promises"
import { spawn, type ChildProcess } from "node:child_process"
import { homedir } from "node:os"
import { join } from "node:path"
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
import type {
DiscoverOptions,
Endpoint,
Info,
EnsureOptions,
StopOptions,
} from "../service.js"
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
export * from "../service.js"
@@ -32,7 +38,6 @@ async function discoverLocal(options: DiscoverOptions) {
/** Ensure a healthy, compatible local service is running. */
export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const deadline = Date.now() + 120_000
const contenders = new Set<Contender>()
let announced = false
let lastSpawn = 0
@@ -61,7 +66,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
}
while (true) {
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
const registration = await registered(options.file, true)
if (registration.service !== undefined) {
@@ -124,9 +128,7 @@ function fallback() {
/** Create HTTP authentication headers for a service endpoint. */
export function headers(endpoint: Endpoint) {
if (endpoint.auth === undefined) return undefined
return {
authorization: "Basic " + Buffer.from(endpoint.auth.username + ":" + endpoint.auth.password).toString("base64"),
}
return { authorization: "Basic " + Buffer.from(endpoint.auth.username + ":" + endpoint.auth.password).toString("base64") }
}
async function read(file?: string) {
@@ -225,8 +227,7 @@ async function kill(service: LocalService, options: { readonly file?: string })
const latest = await find(options)
if (latest === undefined || !same(latest.info, service.info)) return
signal(service.info.pid, "SIGKILL")
if (!(await waitUntilStopped(service.info.pid)))
throw new Error(`Server process ${service.info.pid} is still running`)
if (!(await waitUntilStopped(service.info.pid))) throw new Error(`Server process ${service.info.pid} is still running`)
}
async function requestStop(service: LocalService) {
+1 -3
View File
@@ -108,9 +108,7 @@ const runtime = CodeMode.make({ tools: { opencode: api.tools } })
It is synchronous and returns `{ tools, skipped }`: operations with unsupported encodings, non-JSON bodies, binary
responses, or streaming land in `skipped` instead of producing broken tools. Auth is resolved host-side and never
model-visible; generated tools require `HttpClient.HttpClient` in the environment. `readOnly` properties are omitted
from request signatures and `writeOnly` properties from response signatures. These JSON Schemas are model-facing, not
runtime filters: nested value bodies and server responses pass through unchanged. See the option docstrings in
model-visible; generated tools require `HttpClient.HttpClient` in the environment. See the option docstrings in
`src/openapi/types.ts` for full semantics.
## Outputs
+1 -5
View File
@@ -5,15 +5,11 @@ The initial adapter intentionally skips operations it cannot execute correctly.
- Cookie parameters, authentication, and cookie-header merging.
- Matrix, label, space-delimited, pipe-delimited, `allowReserved`, and parameter `content` serialization.
- External references and complete nested `$defs` support.
- `$anchor` and nested `$id` resource resolution in directional (`readOnly`/`writeOnly`) projection.
- Use-site cleanup for `allOf` branches that reference shared component schemas: per-direction component definitions are projected globally, so a directional annotation declared only at one use site cannot remove the property from a referenced component's definition.
- Hidden-name cleanup inside `then`/`else`/`dependentSchemas`/`dependentRequired`, which constrain the same instance as `allOf`; a hidden property may remain named in those keywords.
- Projection inside `not`/`if`/`contains`, whose semantics would invert or shift if constraints were removed; those subschemas pass through unchanged, and a `$ref` from such a context to a projected `$defs` or component definition still observes hiding.
- Iterative traversal for pathologically deep schema nesting: the directional scan and projection recurse per level and overflow the stack around ten thousand levels, below the pre-existing converter limit of roughly fifty thousand; `fromSpec` throws a catchable `RangeError` either way.
- Relative or templated server URLs and server variables.
- Base URLs containing query strings or fragments.
- Runtime response-schema validation and full content negotiation.
- Binary response values and explicit byte-oriented return types.
- Request/response projection for `readOnly` and `writeOnly` properties.
- SSE, WebSocket, and other streaming transports.
- Recovery of responses rejected by a status-filtering `HttpClient`.
- Configurable request and response size limits.
+3 -7
View File
@@ -3,7 +3,6 @@ import { make, type Definition } from "../tool.js"
import { invoke } from "./runtime.js"
import {
componentDefinitions,
hasDirectionalSchemas,
inputSchema,
isRecord,
methods,
@@ -39,10 +38,7 @@ export const fromSpec = (options: Options): Result => {
const document = options.spec
const schemes = securitySchemes(document)
const defaultSecurity = securityRequirements(document.security)
const requestDefinitions = componentDefinitions(document, "request")
const responseDefinitions = hasDirectionalSchemas(document)
? componentDefinitions(document, "response")
: requestDefinitions
const definitions = componentDefinitions(document)
const paths = isRecord(document.paths) ? document.paths : {}
const used = new Set<string>()
const namespaces = new Set<string>()
@@ -61,7 +57,7 @@ export const fromSpec = (options: Options): Result => {
summary: nonEmptyString(operationValue.summary),
description: nonEmptyString(operationValue.description),
}
const output = operationOutput(document, operationValue, responseDefinitions)
const output = operationOutput(document, operationValue, definitions)
if (!output.ok) {
skipped.push({ method: operation.method, path, reason: output.reason })
continue
@@ -106,7 +102,7 @@ export const fromSpec = (options: Options): Result => {
segments,
make({
description: operation.description ?? operation.summary ?? `${operation.method} ${path}`,
input: inputSchema(input.fields, requestDefinitions),
input: inputSchema(input.fields, definitions),
output: output.value,
run: (input) => invoke(plan, input),
}),
+14 -233
View File
@@ -27,225 +27,22 @@ export const nonEmptyString = (value: unknown): string | undefined =>
export const own = <T>(record: Readonly<Record<string, T>>, key: string): T | undefined =>
Object.hasOwn(record, key) ? record[key] : undefined
const resolvePointer = (root: unknown, ref: string): unknown =>
ref
.slice(2)
.split("/")
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"))
.reduce<unknown>((item, segment) => (isRecord(item) ? own(item, segment) : undefined), root)
export const resolve = (document: Document, value: unknown): unknown => {
const next = (current: unknown, seen: ReadonlySet<string>): unknown => {
if (!isRecord(current)) return current
const ref = nonEmptyString(own(current, "$ref"))
const ref = nonEmptyString(current.$ref)
if (ref === undefined || !ref.startsWith("#/") || seen.has(ref)) return current
const target = resolvePointer(document, ref)
const target = ref
.slice(2)
.split("/")
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"))
.reduce<unknown>((item, segment) => (isRecord(item) ? own(item, segment) : undefined), document)
return target === undefined ? current : next(target, new Set([...seen, ref]))
}
return next(value, new Set())
}
// Model-facing directional projection: request schemas omit `readOnly` properties,
// response schemas omit `writeOnly` properties, and `required` stays consistent.
// Runtime values pass through unchanged.
type SchemaDirection = "request" | "response"
type SchemaResource = { readonly value: unknown; readonly root: unknown }
const hiddenKeyword = { request: "readOnly", response: "writeOnly" } as const
// Resolves one `$ref` hop so every link of a chain has its own sibling declarations
// inspected; cycles terminate in the callers' cycle solver. Local `$defs`/`definitions`
// pointers resolve against the schema being projected, other pointers rebase onto the target.
const resolveResource = (document: Document, resource: SchemaResource): SchemaResource => {
if (!isRecord(resource.value)) return resource
const ref = nonEmptyString(own(resource.value, "$ref"))
if (ref === undefined || !ref.startsWith("#/")) return resource
const local = ref.startsWith("#/$defs/") || ref.startsWith("#/definitions/")
const target = resolvePointer(local ? resource.root : document, ref)
if (target === undefined) return resource
return { value: target, root: local ? resource.root : target }
}
// Hidden-ness and hidden names are memoized per schema object and direction so
// diamond-shaped reference graphs stay linear. Documents are assumed immutable once
// projected; a schema reachable under multiple resolution roots reuses the first result.
type Solver<T> = {
readonly values: Map<unknown, T>
// Discovery index per schema whose strongly connected component is unresolved.
readonly pending: Map<unknown, number>
readonly stack: Array<unknown>
}
type DirectionCache = {
readonly hidden: Solver<boolean>
readonly names: Solver<ReadonlySet<string>>
}
const emptyCache = (): DirectionCache => ({
hidden: { values: new Map(), pending: new Map(), stack: [] },
names: { values: new Map(), pending: new Map(), stack: [] },
})
const projectionCaches = new WeakMap<Document, Record<SchemaDirection, DirectionCache>>()
const projectionCache = (document: Document, direction: SchemaDirection): DirectionCache => {
const existing = projectionCaches.get(document)
if (existing !== undefined) return existing[direction]
const created = { request: emptyCache(), response: emptyCache() }
projectionCaches.set(document, created)
return created[direction]
}
// Tarjan's strongly connected components: cycle members all reach the same
// declarations, so the component root's value is final for every member. Only resolved
// components are cached, keeping results independent of traversal order.
type CycleScope = { lowlink: number }
const solveCycles = <T>(
solver: Solver<T>,
key: unknown,
provisional: T,
scope: CycleScope,
compute: (inner: CycleScope) => T,
): T => {
const cached = solver.values.get(key)
if (cached !== undefined) return cached
const pending = solver.pending.get(key)
if (pending !== undefined) {
scope.lowlink = Math.min(scope.lowlink, pending)
return provisional
}
// Components pop as contiguous stack suffixes, so pending indices stay 0..size-1.
const index = solver.pending.size
const base = solver.stack.length
solver.pending.set(key, index)
solver.stack.push(key)
const inner: CycleScope = { lowlink: Infinity }
const value = compute(inner)
if (inner.lowlink < index) {
scope.lowlink = Math.min(scope.lowlink, inner.lowlink)
return value
}
for (const member of solver.stack.splice(base)) {
solver.pending.delete(member)
solver.values.set(member, value)
}
return value
}
// Most documents have no directional keywords; one cached scan skips projection entirely.
const directionalDocuments = new WeakMap<Document, boolean>()
export const hasDirectionalSchemas = (document: Document): boolean => {
const cached = directionalDocuments.get(document)
if (cached !== undefined) return cached
const contains = (value: unknown): boolean => {
if (Array.isArray(value)) return value.some(contains)
if (!isRecord(value)) return false
if (own(value, "readOnly") === true || own(value, "writeOnly") === true) return true
return Object.values(value).some(contains)
}
const result = contains(document)
directionalDocuments.set(document, result)
return result
}
// OpenAPI 3.1 allows keywords as siblings of `$ref`, so a schema's own declarations
// are inspected before following the reference.
const isHidden = (
document: Document,
resource: SchemaResource,
direction: SchemaDirection,
scope: CycleScope = { lowlink: Infinity },
): boolean => {
const value = resource.value
if (!isRecord(value)) return false
if (own(value, hiddenKeyword[direction]) === true) return true
return solveCycles(projectionCache(document, direction).hidden, value, false, scope, (inner) => {
const target = resolveResource(document, resource)
return (
asArray(own(value, "allOf")).some((item) => isHidden(document, { ...resource, value: item }, direction, inner)) ||
(target.value !== value && isHidden(document, target, direction, inner))
)
})
}
// Hidden property names declared by a schema itself or inherited through `$ref` and
// `allOf` composition, so sibling `required` lists stay consistent after projection.
const hiddenNames = (
document: Document,
resource: SchemaResource,
direction: SchemaDirection,
scope: CycleScope = { lowlink: Infinity },
): ReadonlySet<string> => {
const value = resource.value
if (!isRecord(value)) return new Set()
return solveCycles(projectionCache(document, direction).names, value, new Set(), scope, (inner) => {
const properties = own(value, "properties")
const declared = isRecord(properties)
? Object.entries(properties)
.filter(([, property]) => isHidden(document, { ...resource, value: property }, direction))
.map(([name]) => name)
: []
const composed = asArray(own(value, "allOf")).flatMap((item) => [
...hiddenNames(document, { ...resource, value: item }, direction, inner),
])
const target = resolveResource(document, resource)
const referenced = target.value === value ? [] : hiddenNames(document, target, direction, inner)
return new Set([...declared, ...composed, ...referenced])
})
}
// `not`/`if`/`contains` subschemas pass through unprojected: they negate or select
// rather than assert, so removing hidden properties would invert their semantics.
const nestedSchemas = new Set([
"items",
"additionalProperties",
"unevaluatedProperties",
"propertyNames",
"then",
"else",
])
const nestedSchemaLists = new Set(["anyOf", "oneOf", "prefixItems"])
const nestedSchemaMaps = new Set(["patternProperties", "dependentSchemas", "$defs", "definitions"])
const directionalSchema = (
document: Document,
resource: SchemaResource,
direction: SchemaDirection,
excluded: ReadonlySet<string> = new Set(),
): unknown => {
if (!isRecord(resource.value)) return resource.value
const hidden = new Set([...excluded, ...hiddenNames(document, resource, direction)])
const project = (item: unknown, inherited: ReadonlySet<string> = new Set()): unknown =>
directionalSchema(document, { ...resource, value: item }, direction, inherited)
return Object.fromEntries(
Object.entries(resource.value).map(([key, item]) => {
if (key === "properties" && isRecord(item)) {
return [
key,
Object.fromEntries(
Object.entries(item)
.filter(([name]) => !hidden.has(name))
.map(([name, property]) => [name, project(property)]),
),
]
}
if (key === "required" && Array.isArray(item)) {
return [key, item.filter((name) => typeof name !== "string" || !hidden.has(name))]
}
// allOf branches share one object; hidden names apply across every branch.
if (key === "allOf" && Array.isArray(item)) return [key, item.map((entry) => project(entry, hidden))]
if (nestedSchemas.has(key)) return [key, project(item)]
if (nestedSchemaLists.has(key) && Array.isArray(item)) return [key, item.map((entry) => project(entry))]
if (nestedSchemaMaps.has(key) && isRecord(item)) {
return [key, Object.fromEntries(Object.entries(item).map(([name, entry]) => [name, project(entry)]))]
}
return [key, item]
}),
)
}
const normalizeSchema = (document: Document, value: unknown): JsonSchema => {
const projectSchema = (document: Document, value: unknown): JsonSchema => {
if (!isRecord(value)) return {}
const normalized = nonEmptyString(document.openapi)?.startsWith("3.0")
? fromSchemaOpenApi3_0(value)
@@ -255,21 +52,10 @@ const normalizeSchema = (document: Document, value: unknown): JsonSchema => {
: { ...normalized.schema, $defs: normalized.definitions }
}
const projectSchema = (document: Document, value: unknown, direction: SchemaDirection): JsonSchema =>
normalizeSchema(
document,
hasDirectionalSchemas(document) ? directionalSchema(document, { value, root: value }, direction) : value,
)
export const componentDefinitions = (
document: Document,
direction: SchemaDirection,
): Readonly<Record<string, JsonSchema>> => {
export const componentDefinitions = (document: Document): Readonly<Record<string, JsonSchema>> => {
const components = isRecord(document.components) ? document.components : {}
const schemas = isRecord(components.schemas) ? components.schemas : {}
return Object.fromEntries(
Object.entries(schemas).map(([name, value]) => [name, projectSchema(document, value, direction)]),
)
return Object.fromEntries(Object.entries(schemas).map(([name, value]) => [name, projectSchema(document, value)]))
}
const withDefinitions = (schema: JsonSchema, definitions: Readonly<Record<string, JsonSchema>>): JsonSchema => {
@@ -371,7 +157,7 @@ const operationParameters = (
if (style === "deepObject" && !explode) {
return { ok: false, reason: `query parameter '${name}' uses deepObject with explode=false` }
}
const base = projectSchema(document, resolved.schema, "request")
const base = projectSchema(document, resolved.schema)
const description = nonEmptyString(resolved.description)
unordered.push({
name,
@@ -405,10 +191,7 @@ const operationBody = (
reason: `request body has no JSON content (declared: ${Object.keys(content).join(", ") || "none"})`,
}
}
const resolvedSchema = resolve(document, selected.schema)
const schema = hasDirectionalSchemas(document)
? directionalSchema(document, { value: resolvedSchema, root: resolvedSchema }, "request")
: resolvedSchema
const schema = resolve(document, selected.schema)
const required = resolved.required === true
if (!isFlattenableObjectBody(schema, required)) {
return {
@@ -419,7 +202,7 @@ const operationBody = (
name: "body",
location: "body",
required,
schema: projectSchema(document, selected.schema, "request"),
schema: projectSchema(document, selected.schema),
style: undefined,
explode: undefined,
},
@@ -434,13 +217,11 @@ const operationBody = (
return {
ok: true,
value: {
// Field schemas were already projected with the body as resolution root; a second
// directional pass rooted at the field would misresolve shadowed local $defs.
fields: Object.entries(schema.properties).map(([name, value]) => ({
name,
location: "body" as const,
required: required && requiredProperties.has(name),
schema: normalizeSchema(document, value),
schema: projectSchema(document, value),
style: undefined,
explode: undefined,
})),
@@ -558,7 +339,7 @@ export const operationOutput = (
continue
}
if (!isRecord(value) || value.schema === undefined) return { ok: true, value: undefined }
outcomes.push(projectSchema(document, value.schema, "response"))
outcomes.push(projectSchema(document, value.schema))
}
}
if (outcomes.length === 0) return { ok: true, value: undefined }
+3 -594
View File
@@ -59,53 +59,6 @@ const singleOperation = (operation: Record<string, unknown>, method = "get"): Do
},
})
const directionalSpec = (openapi: string): Document => ({
openapi,
paths: {
"/users": {
post: {
operationId: "users.create",
requestBody: {
required: true,
content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } },
},
responses: {
200: {
description: "Created",
content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } },
},
},
},
},
},
components: {
schemas: {
ReadOnlyID: { type: "string", readOnly: true },
User: {
type: "object",
additionalProperties: false,
required: ["id", "name", "password", "profile", "generated"],
properties: {
id: { type: "string", readOnly: true },
name: { type: "string" },
password: { type: "string", writeOnly: true },
profile: {
type: "object",
additionalProperties: false,
required: ["createdAt", "secret", "label"],
properties: {
createdAt: { type: "string", readOnly: true },
secret: { type: "string", writeOnly: true },
label: { type: "string" },
},
},
generated: { $ref: "#/components/schemas/ReadOnlyID" },
},
},
},
},
})
describe("OpenAPI.fromSpec", () => {
test("covers a representative API from generation through execution", async () => {
const resolutions: Array<string> = []
@@ -401,550 +354,6 @@ describe("OpenAPI.fromSpec", () => {
expect(tool.output.$defs).toMatchObject({ Local: { type: "string" }, Global: { type: "number" } })
})
test("projects read-only and write-only properties by schema direction", () => {
for (const version of ["3.0.3", "3.1.0"]) {
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec(version) }).tools, "users.create")
if (!Tool.isDefinition(tool) || !isRecord(tool.input) || !isRecord(tool.output)) {
throw new Error(`users.create was not generated for OpenAPI ${version}`)
}
expect(inputTypeScript(tool)).toBe(
"{ name: string; password: string; profile: { secret: string; label: string } }",
)
expect(outputTypeScript(tool)).toBe(
"{ id: string; name: string; profile: { createdAt: string; label: string }; generated: string }",
)
const requestDefinitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
const responseDefinitions = isRecord(tool.output.$defs) ? tool.output.$defs : {}
const requestUser = isRecord(requestDefinitions.User) ? requestDefinitions.User : {}
const responseUser = isRecord(responseDefinitions.User) ? responseDefinitions.User : {}
expect(Object.keys(isRecord(requestUser.properties) ? requestUser.properties : {})).toEqual([
"name",
"password",
"profile",
])
expect(requestUser.required).toEqual(["name", "password", "profile"])
expect(Object.keys(isRecord(responseUser.properties) ? responseUser.properties : {})).toEqual([
"id",
"name",
"profile",
"generated",
])
expect(responseUser.required).toEqual(["id", "name", "profile", "generated"])
}
})
test("projects directional annotations through local refs and allOf composition", () => {
const tool = toolAt(
OpenAPI.fromSpec({
baseUrl,
spec: singleOperation(
{
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
additionalProperties: false,
required: ["local", "composed", "name"],
properties: {
local: { $ref: "#/$defs/ReadOnlyValue" },
composed: { allOf: [{ $ref: "#/$defs/ReadOnlyValue" }] },
name: { type: "string" },
},
$defs: {
ReadOnlyValue: { type: "string", readOnly: true },
},
},
},
},
},
},
"post",
),
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
expect(inputTypeScript(tool)).toBe("{ name: string }")
})
test("honors declarations that are siblings of a $ref", () => {
const tool = toolAt(
OpenAPI.fromSpec({
baseUrl,
spec: {
openapi: "3.1.0",
paths: {
"/test": {
post: {
operationId: "test",
responses: { 200: { description: "Success" } },
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
additionalProperties: false,
required: ["record"],
properties: {
record: {
$ref: "#/components/schemas/Base",
properties: { extra: { type: "string", readOnly: true }, note: { type: "string" } },
required: ["extra", "note", "id"],
},
},
},
},
},
},
},
},
},
components: {
schemas: {
Base: {
type: "object",
required: ["id", "name"],
properties: { id: { type: "string", readOnly: true }, name: { type: "string" } },
},
},
},
},
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
const record = isRecord(properties.record) ? properties.record : {}
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
const base = isRecord(definitions.Base) ? definitions.Base : {}
expect(Object.keys(isRecord(record.properties) ? record.properties : {})).toEqual(["note"])
expect(record.required).toEqual(["note"])
expect(Object.keys(isRecord(base.properties) ? base.properties : {})).toEqual(["name"])
expect(base.required).toEqual(["name"])
})
test("honors directional declarations on intermediate reference hops", () => {
const tool = toolAt(
OpenAPI.fromSpec({
baseUrl,
spec: {
...singleOperation(
{
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
additionalProperties: false,
required: ["secret", "name"],
properties: {
// Hidden only by the sibling declaration on the middle hop.
secret: { $ref: "#/components/schemas/Middle" },
name: { type: "string" },
},
},
},
},
},
},
"post",
),
components: {
schemas: {
Middle: { $ref: "#/components/schemas/Plain", readOnly: true },
Plain: { type: "string" },
},
},
},
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
expect(inputTypeScript(tool)).toBe("{ name: string }")
})
test("projects cyclic component references without hanging", () => {
const tool = toolAt(
OpenAPI.fromSpec({
baseUrl,
spec: {
openapi: "3.1.0",
paths: {
"/test": {
post: {
operationId: "test",
responses: { 200: { description: "Success" } },
requestBody: {
required: true,
content: { "application/json": { schema: { $ref: "#/components/schemas/Node" } } },
},
},
},
},
components: {
schemas: {
Node: {
type: "object",
required: ["id", "name", "child"],
properties: {
id: { type: "string", readOnly: true },
name: { type: "string" },
child: { $ref: "#/components/schemas/Node" },
},
},
},
},
},
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
const node = isRecord(definitions.Node) ? definitions.Node : {}
expect(Object.keys(isRecord(node.properties) ? node.properties : {})).toEqual(["name", "child"])
expect(node.required).toEqual(["name", "child"])
})
test("projects diamond-shaped reference graphs in linear time", () => {
// Each component references the next twice; without memoized hidden-ness this is 2^30 work.
const depth = 30
const schemas = Object.fromEntries(
Array.from({ length: depth }, (_, index) => [
`C${index}`,
index === depth - 1
? { type: "object", properties: { id: { type: "string", readOnly: true }, name: { type: "string" } } }
: { allOf: [{ $ref: `#/components/schemas/C${index + 1}` }, { $ref: `#/components/schemas/C${index + 1}` }] },
]),
)
const tool = toolAt(
OpenAPI.fromSpec({
baseUrl,
spec: {
openapi: "3.1.0",
paths: {
"/test": {
post: {
operationId: "test",
responses: { 200: { description: "Success" } },
requestBody: {
required: true,
content: { "application/json": { schema: { $ref: "#/components/schemas/C0" } } },
},
},
},
},
components: { schemas },
},
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
const leaf = isRecord(definitions[`C${depth - 1}`]) ? definitions[`C${depth - 1}`] : {}
expect(Object.keys(isRecord(leaf.properties) ? leaf.properties : {})).toEqual(["name"])
})
test("resolves hiding through reference cycles regardless of evaluation order", () => {
// `Wrap` is hidden only through the cycle member `Loop`; evaluating a property that
// enters the cycle at `Loop` first must not freeze a provisional result for `Wrap`.
const schemas = {
Wrap: { allOf: [{ $ref: "#/components/schemas/Loop" }] },
Loop: { allOf: [{ $ref: "#/components/schemas/Wrap" }, { readOnly: true }] },
}
const body = (properties: Record<string, unknown>) => ({
required: true,
content: {
"application/json": {
schema: {
type: "object",
additionalProperties: false,
required: [...Object.keys(properties), "name"],
properties: { ...properties, name: { type: "string" } },
},
},
},
})
for (const properties of [
{ a: { $ref: "#/components/schemas/Loop" }, b: { $ref: "#/components/schemas/Wrap" } },
{ a: { $ref: "#/components/schemas/Wrap" }, b: { $ref: "#/components/schemas/Loop" } },
]) {
const tool = toolAt(
OpenAPI.fromSpec({
baseUrl,
spec: { ...singleOperation({ requestBody: body(properties) }, "post"), components: { schemas } },
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
expect(inputTypeScript(tool)).toBe("{ name: string }")
}
})
test("keeps not, if, and contains subschemas unprojected", () => {
const tool = toolAt(
OpenAPI.fromSpec({
baseUrl,
spec: singleOperation(
{
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
additionalProperties: false,
required: ["record"],
properties: {
record: {
type: "object",
// Removing `secret` here would turn `not` unsatisfiable and
// flip which branch of `if` applies; both must pass through.
not: { required: ["secret"], properties: { secret: { type: "string", readOnly: true } } },
if: { required: ["kind"], properties: { kind: { type: "string", readOnly: true } } },
},
},
},
},
},
},
},
"post",
),
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
const record: Record<string, unknown> = isRecord(properties.record) ? properties.record : {}
expect(record.not).toEqual({ required: ["secret"], properties: { secret: { type: "string", readOnly: true } } })
expect(record.if).toEqual({ required: ["kind"], properties: { kind: { type: "string", readOnly: true } } })
})
test("does not hide properties whose direction is declared only in anyOf or oneOf alternatives", () => {
// Deliberate scope bound: alternatives may apply, so a directional declaration on
// one alternative does not hide the property; the annotation is preserved as-is.
const tool = toolAt(
OpenAPI.fromSpec({
baseUrl,
spec: singleOperation(
{
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
additionalProperties: false,
required: ["choice", "pick"],
properties: {
choice: { anyOf: [{ type: "string", readOnly: true }, { type: "number" }] },
pick: { oneOf: [{ type: "string", readOnly: true }, { type: "number" }] },
},
},
},
},
},
},
"post",
),
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
const choice: Record<string, unknown> = isRecord(properties.choice) ? properties.choice : {}
const pick: Record<string, unknown> = isRecord(properties.pick) ? properties.pick : {}
expect(Object.keys(properties)).toEqual(["choice", "pick"])
expect(choice.anyOf).toEqual([{ type: "string", readOnly: true }, { type: "number" }])
expect(pick.oneOf).toEqual([{ type: "string", readOnly: true }, { type: "number" }])
})
test("does not misresolve shadowed local $defs when flattening body fields", () => {
const tool = toolAt(
OpenAPI.fromSpec({
baseUrl,
spec: singleOperation(
{
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
additionalProperties: false,
required: ["record"],
$defs: { Value: { type: "string" } },
properties: {
record: {
type: "object",
required: ["x"],
properties: { x: { $ref: "#/$defs/Value" } },
// Shadows the body-level Value; must not affect the body-rooted projection.
$defs: { Value: { type: "string", readOnly: true } },
},
},
},
},
},
},
},
"post",
),
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
const record = isRecord(properties.record) ? properties.record : {}
expect(Object.keys(isRecord(record.properties) ? record.properties : {})).toEqual(["x"])
expect(record.required).toEqual(["x"])
})
test("projects directional annotations inside parameter schemas", () => {
const tool = toolAt(
OpenAPI.fromSpec({
baseUrl,
spec: singleOperation({
parameters: [
{
name: "filter",
in: "query",
required: true,
schema: {
type: "object",
required: ["state", "id"],
properties: { state: { type: "string" }, id: { type: "string", readOnly: true } },
},
},
],
}),
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
expect(inputTypeScript(tool)).toBe("{ filter: { state: string } }")
})
test("ignores inherited directional annotations", () => {
const inherited: Record<string, unknown> = { type: "string" }
Object.setPrototypeOf(inherited, { readOnly: true })
const tool = toolAt(
OpenAPI.fromSpec({
baseUrl,
spec: singleOperation({
parameters: [
{
name: "filter",
in: "query",
required: true,
schema: {
type: "object",
// The own annotation on `id` keeps projection active for the document,
// so `value` pins that prototype-inherited annotations are not read.
properties: { value: inherited, id: { type: "string", readOnly: true } },
required: ["value", "id"],
},
},
],
}),
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
expect(inputTypeScript(tool)).toBe("{ filter: { value: string } }")
})
test("cleans required properties across allOf branches", () => {
const tool = toolAt(
OpenAPI.fromSpec({
baseUrl,
spec: singleOperation(
{
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["id", "name"],
allOf: [
{
type: "object",
required: ["id", "name"],
properties: { id: { type: "string", readOnly: true }, name: { type: "string" } },
},
],
},
},
},
},
},
"post",
),
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
const body = isRecord(properties.body) ? properties.body : {}
const allOf = Array.isArray(body.allOf) ? body.allOf : []
const branch = isRecord(allOf[0]) ? allOf[0] : {}
expect(body.required).toEqual(["name"])
expect(branch.required).toEqual(["name"])
expect(Object.keys(isRecord(branch.properties) ? branch.properties : {})).toEqual(["name"])
})
test("keeps directional schemas model-facing while preserving runtime pass-through", async () => {
const client = recordingClient(() =>
json({
id: "server-id",
name: "Ada",
password: "returned-by-server",
profile: { createdAt: "today", secret: "returned-secret", label: "primary" },
generated: "generated-id",
}),
)
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec("3.1.0") }).tools, "users.create")
if (!Tool.isDefinition(tool)) throw new Error("users.create was not generated")
const result = await Effect.runPromise(
tool
.run({
id: "ignored-top-level",
generated: "ignored-generated",
name: "Ada",
password: "request-secret",
profile: { createdAt: "sent-nested", secret: "nested-secret", label: "primary" },
})
.pipe(Effect.provide(client.layer)),
)
expect(client.requests[0]?.body).toEqual({
name: "Ada",
password: "request-secret",
profile: { createdAt: "sent-nested", secret: "nested-secret", label: "primary" },
})
expect(result).toMatchObject({ password: "returned-by-server", profile: { secret: "returned-secret" } })
})
test("documents that the opencode fixture is unauthenticated", async () => {
const spec = await opencodeSpec()
const components = isRecord(spec.components) ? spec.components : {}
@@ -1116,9 +525,9 @@ describe("OpenAPI.fromSpec", () => {
expect(client.requests[0]?.url).toBe(
`${baseUrl}/test?tags=first+value&tags=second%26value&state=open+now&page=2&location%5Bdirectory%5D=%2Ftmp%2Fa+b&location%5Bworkspace%5D=work%261`,
)
await expect(Effect.runPromise(tool.run({ tags: [{}] }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
"Parameter 'tags' contains an unsupported nested value.",
)
await expect(
Effect.runPromise(tool.run({ tags: [{}] }).pipe(Effect.provide(client.layer))),
).rejects.toThrow("Parameter 'tags' contains an unsupported nested value.")
await expect(
Effect.runPromise(tool.run({ filter: { state: {} } }).pipe(Effect.provide(client.layer))),
).rejects.toThrow("Query parameter 'filter' contains an unsupported nested value.")
+18
View File
@@ -12,6 +12,7 @@ import { ConfigAgentV1 } from "../../v1/config/agent"
import { ConfigMigrateV1 } from "../../v1/config/migrate"
import { Global } from "../../global"
import { PermissionV2 } from "../../permission"
import { SHELL_OUTPUT_GLOB } from "../../permission/defaults"
import type { LocationMutation } from "../../location-mutation"
import type { ReadTool } from "../../tool/read"
import type { EditTool } from "../../tool/edit"
@@ -112,6 +113,23 @@ export const Plugin = define({
})
}
}
// Internal shell output remains readable through broad external-directory denials.
// An exact deny still lets users explicitly revoke access to these files.
for (const current of draft.list()) {
draft.update(current.id, (agent) => {
const denied = agent.permissions.some(
(rule) =>
rule.action === "external_directory" && rule.resource === SHELL_OUTPUT_GLOB && rule.effect === "deny",
)
if (
denied ||
PermissionV2.evaluate("external_directory", SHELL_OUTPUT_GLOB, agent.permissions).effect === "allow"
)
return
agent.permissions.push({ action: "external_directory", resource: SHELL_OUTPUT_GLOB, effect: "allow" })
})
}
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
+46 -28
View File
@@ -9,6 +9,8 @@ import { Reference } from "../../reference"
import { AbsolutePath } from "../../schema"
import { Global } from "../../global"
import { Location } from "../../location"
import { allowExternalDirectories } from "../../permission/defaults"
import { Repository } from "../../repository"
export const Plugin = define({
id: "opencode.config.reference",
@@ -18,35 +20,20 @@ export const Plugin = define({
const global = yield* Global.Service
const loaded = { entries: yield* config.entries() }
yield* ctx.reference.transform((draft) => {
const entries = new Map<string, Reference.Source>()
for (const doc of loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")) {
const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
const description = typeof entry === "string" ? undefined : entry.description
const hidden = typeof entry === "string" ? undefined : entry.hidden
entries.set(
name,
local(entry)
? Reference.LocalSource.make({
type: "local",
path: AbsolutePath.make(
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
),
...(description === undefined ? {} : { description }),
...(hidden === undefined ? {} : { hidden }),
})
: Reference.GitSource.make({
type: "git",
repository: typeof entry === "string" ? entry : entry.repository,
...(entry.branch === undefined ? {} : { branch: entry.branch }),
...(description === undefined ? {} : { description }),
...(hidden === undefined ? {} : { hidden }),
}),
)
}
for (const [name, source] of sources(loaded.entries, location.directory, global.home)) draft.add(name, source)
})
yield* ctx.agent.transform((draft) => {
const permissions = allowExternalDirectories(
Array.from(sources(loaded.entries, location.directory, global.home)).flatMap(([, source]) => {
if (source.type === "local") return [path.join(source.path, "*")]
const repository = Repository.parse(source.repository)
if (!repository || !Repository.isRemote(repository)) return []
return [path.join(Repository.cachePath(global.repos, repository), "*")]
}),
)
for (const current of draft.list()) {
draft.update(current.id, (agent) => agent.permissions.push(...permissions))
}
for (const [name, source] of entries) draft.add(name, source)
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
@@ -54,6 +41,7 @@ export const Plugin = define({
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.reference.reload()),
Effect.andThen(ctx.agent.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
@@ -61,6 +49,36 @@ export const Plugin = define({
}),
})
function sources(entries: readonly Config.Entry[], location: string, home: string) {
const result = new Map<string, Reference.Source>()
for (const doc of entries.filter((entry): entry is Config.Document => entry.type === "document")) {
const directory = doc.path ? path.dirname(doc.path) : location
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
const description = typeof entry === "string" ? undefined : entry.description
const hidden = typeof entry === "string" ? undefined : entry.hidden
result.set(
name,
local(entry)
? Reference.LocalSource.make({
type: "local",
path: AbsolutePath.make(localPath(directory, home, typeof entry === "string" ? entry : entry.path)),
...(description === undefined ? {} : { description }),
...(hidden === undefined ? {} : { hidden }),
})
: Reference.GitSource.make({
type: "git",
repository: typeof entry === "string" ? entry : entry.repository,
...(entry.branch === undefined ? {} : { branch: entry.branch }),
...(description === undefined ? {} : { description }),
...(hidden === undefined ? {} : { hidden }),
}),
)
}
}
return result
}
function validAlias(name: string) {
return name.length > 0 && !/[\/\s`,]/.test(name)
}
+50 -35
View File
@@ -8,6 +8,8 @@ import { AbsolutePath } from "../../schema"
import { SkillV2 } from "../../skill"
import { Global } from "../../global"
import { Location } from "../../location"
import { SkillDiscovery } from "../../skill/discovery"
import { allowExternalDirectories } from "../../permission/defaults"
export const Plugin = define({
id: "opencode.config.skill",
@@ -17,41 +19,19 @@ export const Plugin = define({
const location = yield* Location.Service
const loaded = { entries: yield* config.entries() }
yield* ctx.skill.transform((draft) => {
const claude = loaded.entries.flatMap((entry) => (entry.type === "claude" ? [entry.path] : []))
const agents = loaded.entries.flatMap((entry) => (entry.type === "agents" ? [entry.path] : []))
const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
for (const directory of [...claude, ...agents]) {
draft.source(
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join(directory, "skills")),
}),
)
}
for (const directory of directories) {
draft.source(
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
)
draft.source(
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join(directory, "skills")),
}),
)
}
for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
draft.source(SkillV2.UrlSource.make({ type: "url", url: item }))
continue
}
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
draft.source(
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
}),
)
for (const source of sources(loaded.entries, global.home, location.directory)) draft.source(source)
})
yield* ctx.agent.transform((draft) => {
const permissions = allowExternalDirectories(
sources(loaded.entries, global.home, location.directory).map((source) =>
path.join(
source.type === "directory" ? source.path : SkillDiscovery.cachePath(global.cache, source.url),
"*",
),
),
)
for (const current of draft.list()) {
draft.update(current.id, (agent) => agent.permissions.push(...permissions))
}
})
yield* ctx.event.subscribe().pipe(
@@ -60,9 +40,44 @@ export const Plugin = define({
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.skill.reload()),
Effect.andThen(ctx.agent.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
function sources(entries: readonly Config.Entry[], home: string, directory: string) {
const result: Array<SkillV2.DirectorySource | SkillV2.UrlSource> = []
for (const entry of entries) {
if (entry.type === "claude" || entry.type === "agents") {
result.push(
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(entry.path, "skills")) }),
)
continue
}
if (entry.type === "directory") {
result.push(
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(entry.path, "skill")) }),
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(entry.path, "skills")) }),
)
continue
}
if (entry.type !== "document") continue
for (const item of entry.info.skills ?? []) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
result.push(SkillV2.UrlSource.make({ type: "url", url: item }))
continue
}
const expanded = item.startsWith("~/") ? path.join(home, item.slice(2)) : item
result.push(
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)),
}),
)
}
}
return result
}
+2 -4
View File
@@ -74,7 +74,7 @@ type MakeInput<
T extends Tag | undefined = undefined,
> = NodeIdentity & {
readonly layer: Implementation
readonly deps: (Items | (() => Items)) & CheckDependencies<Implementation, NoInfer<Items>>
readonly deps: Items & CheckDependencies<Implementation, NoInfer<Items>>
readonly tag?: T
}
@@ -90,9 +90,7 @@ export function make<
name: input.service !== undefined ? input.service.key : input.name,
service: input.service,
implementation: input.layer,
get dependencies() {
return typeof input.deps === "function" ? input.deps() : input.deps
},
dependencies: input.deps,
tag: input.tag,
}
}
+1 -2
View File
@@ -70,7 +70,6 @@ export type Inputs = Integration.Inputs
export type OAuthAuthorization = {
readonly url: string
readonly instructions: string
readonly expiresAt?: number
} & (
| {
readonly mode: "auto"
@@ -561,7 +560,7 @@ const layer = Layer.effect(
)
const id = AttemptID.create()
const created = yield* Clock.currentTimeMillis
const time = { created, expires: authorization.expiresAt ?? created + attemptLifetime }
const time = { created, expires: created + attemptLifetime }
yield* SynchronizedRef.update(attempts, (current) =>
new Map(current).set(id, {
status: "pending",
+10
View File
@@ -0,0 +1,10 @@
import path from "path"
import { Global } from "../global"
import { PermissionV2 } from "../permission"
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
export const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
export function allowExternalDirectories(resources: readonly string[]): PermissionV2.Ruleset {
return resources.map((resource): PermissionV2.Rule => ({ action: "external_directory", resource, effect: "allow" }))
}
+1 -3
View File
@@ -7,10 +7,8 @@ import { AgentV2 } from "../agent"
import { Global } from "../global"
import { Location } from "../location"
import { PermissionV2 } from "../permission"
import { SHELL_OUTPUT_GLOB } from "../permission/defaults"
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
// Whitelisted so agents can read a command's full captured output without an external-directory prompt.
const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
const BUILD_SYSTEM =
"You are an AI coding agent. Help the user accomplish software engineering tasks by inspecting the workspace, making targeted changes, and using tools according to the configured permissions."
+1 -1
View File
@@ -147,9 +147,9 @@ const pre = [
const post = [
ConfigReferencePlugin.Plugin,
ConfigSkillPlugin.Plugin,
ConfigAgentPlugin.Plugin,
ConfigCommandPlugin.Plugin,
ConfigSkillPlugin.Plugin,
ConfigProviderPlugin.Plugin,
VariantPlugin.Plugin,
ConfigPolicyPlugin.Plugin,
+6 -14
View File
@@ -133,20 +133,12 @@ const device = {
},
Device,
).pipe(
Effect.flatMap((value) =>
Clock.currentTimeMillis.pipe(
Effect.map((created) => {
const lifetime = positiveSeconds(value.expires_in, 0)
return {
mode: "auto" as const,
url: value.verification_uri_complete ?? value.verification_uri,
instructions: `Open ${value.verification_uri} on any device and enter code: ${value.user_code}`,
...(lifetime ? { expiresAt: created + lifetime * 1000 } : {}),
callback: poll(value).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))),
}
}),
),
),
Effect.map((value) => ({
mode: "auto" as const,
url: value.verification_uri_complete ?? value.verification_uri,
instructions: `Open ${value.verification_uri} on any device and enter code: ${value.user_code}`,
callback: poll(value).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))),
})),
),
refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID })),
} satisfies IntegrationOAuthMethodRegistration
+1 -5
View File
@@ -142,11 +142,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
continue
}
const plugin = yield* load(operation).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(Effect.as(undefined)),
),
)
const plugin = yield* load(operation).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
if (!plugin) continue
const previous = packages.get(operation.target)
if (previous) enabled.delete(previous.id)
+1 -2
View File
@@ -1033,8 +1033,7 @@ const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024
export const node = makeGlobalNode({
service: Service,
layer: layer.pipe(Layer.orDie),
// Defer the execution node across the Session/runner module cycle until the graph is compiled.
deps: () => [
deps: [
Job.node,
Database.node,
EventV2.node,
+5 -1
View File
@@ -69,6 +69,10 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SkillDiscovery") {}
export function cachePath(cache: string, url: string) {
return path.resolve(cache, "skills", Hash.fast(url.endsWith("/") ? url : `${url}/`))
}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -111,7 +115,7 @@ const layer = Layer.effect(
)
if (!data) return []
const sourceRoot = path.resolve(global.cache, "skills", Hash.fast(base))
const sourceRoot = cachePath(global.cache, base)
return yield* Effect.forEach(
data.skills.flatMap((skill) => {
if (!isSafeSegment(skill.name)) {
+61 -2
View File
@@ -10,6 +10,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { SHELL_OUTPUT_GLOB } from "@opencode-ai/core/permission/defaults"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { tmpdir } from "../fixture/tmpdir"
@@ -22,6 +23,11 @@ const defaultPermissions = [
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "ask" },
] satisfies PermissionV2.Ruleset
const shellOutputPermission = {
action: "external_directory",
resource: SHELL_OUTPUT_GLOB,
effect: "allow",
} satisfies PermissionV2.Rule
describe("ConfigAgentPlugin.Plugin", () => {
it.effect("matches POSIX paths against home-relative permissions", () =>
@@ -114,6 +120,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
{ action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "bash", resource: "git *", effect: "allow" },
shellOutputPermission,
])
expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow")
expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("ask")
@@ -132,6 +139,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
{ action: "read", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "deny" },
shellOutputPermission,
])
expect(PermissionV2.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
expect((yield* agents.get(AgentV2.ID.make("late")))?.permissions).toEqual([
@@ -139,11 +147,31 @@ describe("ConfigAgentPlugin.Plugin", () => {
{ action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "allow" },
shellOutputPermission,
])
expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined()
}),
)
it.effect("keeps shell output readable through a broad external-directory deny", () =>
Effect.gen(function* () {
const permissions = yield* loadConfiguredPermissions([
{ action: "external_directory", resource: "*", effect: "deny" },
])
expect(PermissionV2.evaluate("external_directory", SHELL_OUTPUT_GLOB, permissions).effect).toBe("allow")
}),
)
it.effect("respects an exact shell output deny", () =>
Effect.gen(function* () {
const permissions = yield* loadConfiguredPermissions([
{ action: "external_directory", resource: "*", effect: "deny" },
{ action: "external_directory", resource: SHELL_OUTPUT_GLOB, effect: "deny" },
])
expect(PermissionV2.evaluate("external_directory", SHELL_OUTPUT_GLOB, permissions).effect).toBe("deny")
}),
)
it.effect("maps configured agent fields and preserves an unspecified model variant", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
@@ -294,13 +322,21 @@ Use native v2 fields.`,
system: "Review carefully.",
description: "Markdown description",
request: { body: { temperature: 0.5 } },
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
permissions: [
...defaultPermissions,
{ action: "edit", resource: "*", effect: "deny" },
shellOutputPermission,
],
})
expect(yield* agents.get(AgentV2.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
expect(yield* agents.get(AgentV2.ID.make("native"))).toMatchObject({
system: "Use native v2 fields.",
request: { headers: { "x-agent": "native" }, body: { effort: "high" } },
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
permissions: [
...defaultPermissions,
{ action: "edit", resource: "*", effect: "deny" },
shellOutputPermission,
],
})
expect(yield* agents.get(AgentV2.ID.make("disabled"))).toBeUndefined()
expect(yield* agents.get(AgentV2.ID.make("plan"))).toMatchObject({ system: "Make a plan.", mode: "primary" })
@@ -357,3 +393,26 @@ function loadHomePermissions(home: string) {
return agent.permissions
})
}
function loadConfiguredPermissions(permissions: PermissionV2.Ruleset) {
return Effect.gen(function* () {
const agents = yield* AgentV2.Service
const build = AgentV2.ID.make("build")
yield* agents.transform((draft) => draft.update(build, () => {}))
const config = Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({ permissions }),
}),
]),
})
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
Effect.provideService(Config.Service, config),
)
const agent = yield* agents.get(build)
if (!agent) throw new Error("expected configured build agent")
return agent.permissions
})
}
+5 -16
View File
@@ -18,7 +18,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Logger } from "effect"
import { Effect } from "effect"
import { Database } from "../../src/database/database"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
@@ -111,15 +111,8 @@ describe("PluginSupervisor config", () => {
),
)
it.live("logs invalid packages and continues loading", () => {
const output: string[] = []
const logger = Logger.map(Logger.formatStructured, (entry) => {
if (!Array.isArray(entry.message) || entry.message[0] !== "failed to load plugin") return
const details = entry.message[1]
if (typeof details !== "object" || details === null || !("target" in details)) return
if (typeof details.target === "string") output.push(details.target)
})
return withLocation(
it.live("ignores invalid packages and continues loading", () =>
withLocation(
{
plugins: [
"-*",
@@ -137,13 +130,9 @@ describe("PluginSupervisor config", () => {
expect(yield* agents.get(AgentV2.ID.make("configured"))).toMatchObject({
description: "Loaded after invalid plugins",
})
expect(output).toEqual([
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
])
}),
).pipe(Effect.provide(Logger.layer([logger])))
})
),
)
it.live("loads auto-discovered plugin files", () =>
withLocation(
+2
View File
@@ -34,8 +34,10 @@ describe("ConfigSkillPlugin.Plugin", () => {
return { dispose }
})
const agent = host().agent
yield* ConfigSkillPlugin.Plugin.effect(
host({
agent: { ...agent, transform: () => Effect.succeed({ dispose: Effect.void }) },
skill: { list: () => Effect.die("unused skill.list"), transform, reload: () => Effect.void },
}),
).pipe(
@@ -32,7 +32,6 @@ const b = make({ service: B, layer: bLayer, deps: [a] })
const c = make({ service: C, layer: cLayer, deps: [a, b] })
const failing = make({ service: A, layer: failingA, deps: [] })
const dependent = make({ service: B, layer: bLayer, deps: [failing] })
make({ service: B, layer: bLayer, deps: () => [a] })
const inputA = LayerNode.unbound(A, tags.values.app)
const inputDependent = make({ service: B, layer: bLayer, deps: [inputA] })
@@ -47,9 +46,6 @@ make({ service: A, name: "a", layer: aLayer, deps: [] })
// @ts-expect-error B requires A
make({ service: B, layer: bLayer, deps: [] })
// @ts-expect-error Lazy dependencies must still provide A
make({ service: B, layer: bLayer, deps: () => [] })
// @ts-expect-error C requires A and B
make({ service: C, layer: cLayer, deps: [a] })
@@ -37,12 +37,6 @@ describe("layer node", () => {
expect(await Effect.runPromise(program)).toBe("hello production")
})
test("resolves lazy dependencies when compiling", async () => {
const greeting = make({ service: Greeting, layer: greetingLayer, deps: () => [value] })
const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(build(greeting)))
expect(await Effect.runPromise(program)).toBe("hello production")
})
test("exposes roots but hides transitive dependencies", () => {
const layer = build(LayerNode.group([greeting]))
const check: Layer.Layer<Greeting> = layer
+1 -36
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Cause, Clock, Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import { Cause, Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -414,41 +414,6 @@ describe("Integration", () => {
}),
)
it.effect("uses provider-defined OAuth attempt expirations", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const integrationID = Integration.ID.make("openai")
const created = yield* Clock.currentTimeMillis
const expirations = [
created + Duration.toMillis(Duration.minutes(5)),
created + Duration.toMillis(Duration.minutes(20)),
]
yield* Effect.forEach(expirations, (expiresAt, index) => {
const methodID = Integration.MethodID.make(`browser-${index}`)
return Effect.gen(function* () {
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Browser" },
authorize: () =>
Effect.succeed({
mode: "auto" as const,
url: "https://example.com/authorize",
instructions: "Sign in",
expiresAt,
callback: Effect.never,
}),
}),
)
const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} })
expect(attempt.time).toEqual({ created, expires: expiresAt })
})
})
}),
)
it.effect("projects credential and env connections", () => {
const integrationID = Integration.ID.make("acme")
return Effect.acquireUseRelease(
+65
View File
@@ -18,6 +18,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
@@ -112,6 +113,70 @@ describe("LocationServiceMap", () => {
),
)
it.live("allows external skill and reference directories by default", () =>
Effect.acquireRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
(dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
).pipe(
Effect.flatMap(([project, external]) =>
Effect.gen(function* () {
const skill = path.join(external.path, "skills", "example")
const reference = path.join(external.path, "reference")
yield* Effect.promise(() =>
Promise.all([
fs.mkdir(skill, { recursive: true }),
fs.mkdir(reference, { recursive: true }),
fs.writeFile(
path.join(project.path, "opencode.json"),
JSON.stringify({
skills: [path.join(external.path, "skills")],
references: { docs: reference },
}),
),
]),
)
yield* Effect.promise(() =>
fs.writeFile(
path.join(skill, "SKILL.md"),
"---\nname: example\ndescription: Example skill.\n---\n\n# Example\n",
),
)
const locations = yield* LocationServiceMap.Service
const context = locations.get(Location.Ref.make({ directory: AbsolutePath.make(project.path) }))
const permissions = yield* Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
const agents = yield* AgentV2.Service
yield* supervisor.flush
for (let attempt = 0; attempt < 100; attempt++) {
const build = yield* agents.resolve("build")
if (
build &&
PermissionV2.evaluate(
"external_directory",
path.join(skill, "reference", "notes.md"),
build.permissions,
).effect === "allow" &&
PermissionV2.evaluate("external_directory", path.join(reference, "notes.md"), build.permissions)
.effect === "allow"
)
return build.permissions
yield* Effect.sleep("20 millis")
}
return (yield* agents.resolve("build"))?.permissions ?? []
}).pipe(Effect.scoped, Effect.provide(context))
expect(
PermissionV2.evaluate("external_directory", path.join(skill, "reference", "notes.md"), permissions).effect,
).toBe("allow")
expect(
PermissionV2.evaluate("external_directory", path.join(reference, "notes.md"), permissions).effect,
).toBe("allow")
}),
),
),
)
itWithSdk.live("reruns activation for SDK plugins registered during startup", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -17,7 +17,6 @@ import type { Transform } from "./registration.js"
export type IntegrationOAuthAuthorization = {
readonly url: string
readonly instructions: string
readonly expiresAt?: number
} & (
| {
readonly mode: "auto"
+13 -23
View File
@@ -21,10 +21,7 @@ export type Options<E = never, R = never> = {
readonly password: string
readonly instanceID: string
readonly service?: {
readonly onListen: (
address: HttpServer.Address,
shutdown: Effect.Effect<void>,
) => Effect.Effect<Effect.Effect<void>, E, R>
readonly onListen: (address: HttpServer.Address) => Effect.Effect<void, E, R>
}
}
@@ -47,21 +44,17 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(options:
yield* bound.http
.serve(dispatch(options.password, status, application, shutdown), HttpMiddleware.logger)
.pipe(withoutParentSpan)
if (options.service)
yield* options.service.onListen(bound.http.address, Deferred.succeed(shutdown, undefined).pipe(Effect.asVoid)).pipe(
Effect.flatMap((cleanup) =>
Effect.addFinalizer(() => Scope.close(bound.scope, Exit.void).pipe(Effect.andThen(cleanup))),
),
Effect.uninterruptible,
)
if (options.service) yield* options.service.onListen(bound.http.address)
const parentScope = yield* Scope.Scope
const applicationScope = yield* Scope.fork(parentScope)
yield* Effect.addFinalizer(() =>
status.beginStopping.pipe(
Effect.andThen(Ref.set(application, Option.none())),
Effect.andThen(Effect.sync(() => bound.server.closeAllConnections())),
),
status
.beginStopping
.pipe(
Effect.andThen(Ref.set(application, Option.none())),
Effect.andThen(Effect.sync(() => bound.server.closeAllConnections())),
),
)
const boot = Effect.gen(function* () {
@@ -119,7 +112,7 @@ function bind(hostname: string, port: number) {
return yield* Effect.gen(function* () {
const http = yield* NodeHttpServer.make(() => server, { port, host: hostname })
yield* Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))
return { http, server, scope: serverScope }
return { http, server }
}).pipe(
Effect.provideService(Scope.Scope, serverScope),
Effect.onError((cause) => Scope.close(serverScope, Exit.failCause(cause))),
@@ -197,13 +190,10 @@ const control = Effect.fnUntraced(function* (
const healthResponse = Effect.fnUntraced(function* (status: Status.Interface) {
const state = yield* status.current
return HttpServerResponse.jsonUnsafe(
{ healthy: true, version: InstallationVersion, pid: process.pid },
{
status: state.type === "ready" ? 200 : state.type === "failed" ? 500 : 503,
headers: state.type === "starting" || state.type === "stopping" ? { "retry-after": "1" } : undefined,
},
)
return HttpServerResponse.jsonUnsafe({ healthy: true, version: InstallationVersion, pid: process.pid }, {
status: state.type === "ready" ? 200 : state.type === "failed" ? 500 : 503,
headers: state.type === "starting" || state.type === "stopping" ? { "retry-after": "1" } : undefined,
})
})
function unavailable(status: Status.State) {
+2 -2
View File
@@ -420,7 +420,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
const client = useClient()
const toast = useToast()
const themeState = useTheme()
const { themeV2, mode, setMode, locked, lock, unlock } = themeState
const { theme, mode, setMode, locked, lock, unlock } = themeState
const data = useData()
const location = useLocation()
const exit = useExit()
@@ -1087,7 +1087,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
width={dimensions().width}
height={dimensions().height}
flexDirection="column"
backgroundColor={themeV2.background()}
backgroundColor={theme.background}
onMouseDown={(evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (evt.button !== MouseButton.RIGHT) return
@@ -1,11 +1,10 @@
import { TextAttributes } from "@opentui/core"
import { createSignal, For } from "solid-js"
import { For } from "solid-js"
import { useTheme } from "../context/theme"
import { DevTools } from "../devtools"
export function DevToolsSidebar() {
const { themeV2, mode, setMode } = useTheme().contextual("elevated")
const [modeHovered, setModeHovered] = createSignal(false)
const { themeV2 } = useTheme().contextual("elevated")
return (
<box
@@ -17,27 +16,6 @@ export function DevToolsSidebar() {
paddingRight={2}
backgroundColor={themeV2.background()}
>
<box flexShrink={0} marginBottom={1}>
<box marginBottom={1}>
<text fg={themeV2.text.action()} attributes={TextAttributes.BOLD}>
Theme
</text>
</box>
<box flexDirection="row">
<text fg={themeV2.text.subdued()}>Mode</text>
<box flexGrow={1} />
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={modeHovered() ? themeV2.background.action("hovered") : undefined}
onMouseOver={() => setModeHovered(true)}
onMouseOut={() => setModeHovered(false)}
onMouseUp={() => setMode(mode() === "dark" ? "light" : "dark")}
>
<text fg={themeV2.text()}>{mode()}</text>
</box>
</box>
</box>
<For each={DevTools.data()}>
{(group) => (
<box flexShrink={0} marginBottom={1}>
@@ -62,7 +62,7 @@ export function connectionSummary(integration: IntegrationInfo) {
export function DialogIntegration(props: { onConnected?: OnIntegrationConnected } = {}) {
const data = useData()
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const { theme } = useTheme()
const options = createMemo(() =>
integrationOptions(data.location.integration.list() ?? []).map((integration) => {
const methods = connectMethods(integration)
@@ -74,7 +74,7 @@ export function DialogIntegration(props: { onConnected?: OnIntegrationConnected
footer: connectionSummary(integration) || undefined,
category: integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services",
disabled: methods.length === 0,
gutter: connected ? () => <text fg={themeV2.text.feedback.success()}></text> : undefined,
gutter: connected ? () => <text fg={theme.success}></text> : undefined,
onSelect: () =>
credentialConnections(integration).length
? manageConnections(integration, methods, dialog, props.onConnected)
@@ -89,12 +89,12 @@ export function DialogIntegration(props: { onConnected?: OnIntegrationConnected
options={options()}
emptyView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued()}>No integrations available</text>
<text fg={theme.textMuted}>No integrations available</text>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued()}>No integrations found</text>
<text fg={theme.textMuted}>No integrations found</text>
</box>
}
/>
@@ -289,30 +289,23 @@ function CommandPending(props: {
function CommandView(props: { title: string; output: string; message: string }) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
const { theme } = useTheme()
onMount(() => dialog.setSize("large"))
return (
<box gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
<text attributes={TextAttributes.BOLD} fg={theme.text}>
{props.title}
</text>
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc close
</text>
</box>
<box
backgroundColor={overlayTheme.background()}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<text fg={overlayTheme.text()}>{props.output.trim()}</text>
<box backgroundColor={theme.backgroundElement} paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
<text fg={theme.text}>{props.output.trim()}</text>
</box>
<box paddingLeft={2} paddingRight={2}>
<text fg={themeV2.text.subdued()}>{props.message}</text>
<text fg={theme.textMuted}>{props.message}</text>
</box>
</box>
)
@@ -327,7 +320,7 @@ function KeyMethod(props: {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const { themeV2 } = useTheme().contextual("elevated")
const { theme } = useTheme()
const [error, setError] = createSignal<string>()
return (
@@ -345,9 +338,7 @@ function KeyMethod(props: {
.then(() => connected(props.integration, data, dialog, toast, props.onConnected))
.catch((cause) => setError(message(cause)))
}}
description={() => (
<Show when={error()}>{(value) => <text fg={themeV2.text.feedback.error()}>{value()}</text>}</Show>
)}
description={() => <Show when={error()}>{(value) => <text fg={theme.error}>{value()}</text>}</Show>}
/>
)
}
@@ -502,7 +493,7 @@ function OAuthCode(props: {
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const { themeV2 } = useTheme().contextual("elevated")
const { theme } = useTheme()
const [error, setError] = createSignal<string>()
let settled = false
@@ -536,9 +527,9 @@ function OAuthCode(props: {
}}
description={() => (
<box gap={1}>
<text fg={themeV2.text.subdued()}>{props.attempt.instructions}</text>
<Link href={props.attempt.url} fg={themeV2.markdown.link()} />
<Show when={error()}>{(value) => <text fg={themeV2.text.feedback.error()}>{value()}</text>}</Show>
<text fg={theme.textMuted}>{props.attempt.instructions}</text>
<Link href={props.attempt.url} fg={theme.primary} />
<Show when={error()}>{(value) => <text fg={theme.error}>{value()}</text>}</Show>
</box>
)}
/>
@@ -547,31 +538,31 @@ function OAuthCode(props: {
function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const { theme } = useTheme()
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
<text attributes={TextAttributes.BOLD} fg={theme.text}>
{props.title}
</text>
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show when={props.url}>
{(url) => (
<box gap={1}>
<Link href={url()} fg={themeV2.markdown.link()} />
<Link href={url()} fg={theme.primary} />
<Show when={props.instructions}>
{(instructions) => <text fg={themeV2.text.subdued()}>{instructions()}</text>}
{(instructions) => <text fg={theme.textMuted}>{instructions()}</text>}
</Show>
</box>
)}
</Show>
<text fg={themeV2.text.subdued()}>{props.message}</text>
<text fg={theme.textMuted}>{props.message}</text>
<Show when={props.copy}>
<text fg={themeV2.text()}>
c <span style={{ fg: themeV2.text.subdued() }}>copy</span>
<text fg={theme.text}>
c <span style={{ fg: theme.textMuted }}>copy</span>
</text>
</Show>
</box>
+23 -59
View File
@@ -4,7 +4,7 @@ import { Keymap } from "../context/keymap"
import { pipe, sortBy } from "remeda"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useTheme } from "../context/theme"
import { useTheme, type Theme } from "../context/theme"
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import type { McpServer } from "@opencode-ai/client"
import { useClipboard } from "../context/clipboard"
@@ -12,59 +12,30 @@ import { useToast } from "../ui/toast"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { getScrollAcceleration } from "../util/scroll"
import type { ComponentTheme } from "../theme/v2/component"
// Sort by how much attention a server needs: auth prompts first, then failures,
// then healthy servers, and intentionally-off servers last.
function statusMeta(status: McpServer["status"], themeV2: ComponentTheme) {
function statusMeta(status: McpServer["status"], theme: Theme) {
switch (status.status) {
case "needs_auth":
return {
rank: 0,
icon: "!",
label: "Needs authentication",
color: themeV2.text.feedback.warning(),
error: undefined,
bold: false,
}
return { rank: 0, icon: "!", label: "Needs authentication", color: theme.warning, error: undefined, bold: false }
case "needs_client_registration":
return {
rank: 1,
icon: "✗",
label: "Needs registration",
color: themeV2.text.feedback.error(),
error: status.error,
bold: false,
}
return { rank: 1, icon: "✗", label: "Needs registration", color: theme.error, error: status.error, bold: false }
case "failed":
return {
rank: 2,
icon: "✗",
label: "Failed",
color: themeV2.text.feedback.error(),
error: status.error,
bold: false,
}
return { rank: 2, icon: "✗", label: "Failed", color: theme.error, error: status.error, bold: false }
case "connected":
return {
rank: 3,
icon: "✓",
label: "Connected",
color: themeV2.text.feedback.success(),
error: undefined,
bold: true,
}
return { rank: 3, icon: "✓", label: "Connected", color: theme.success, error: undefined, bold: true }
case "pending":
return { rank: 4, icon: "◌", label: "Pending", color: themeV2.text.subdued(), error: undefined, bold: false }
return { rank: 4, icon: "◌", label: "Pending", color: theme.textMuted, error: undefined, bold: false }
default:
return { rank: 5, icon: "○", label: "Disabled", color: themeV2.text.subdued(), error: undefined, bold: false }
return { rank: 5, icon: "○", label: "Disabled", color: theme.textMuted, error: undefined, bold: false }
}
}
export function DialogMcp() {
const data = useData()
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const { theme } = useTheme()
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<McpServer>()
@@ -76,7 +47,7 @@ export function DialogMcp() {
pipe(
data.location.mcp.server.list() ?? [],
sortBy(
(server) => statusMeta(server.status, themeV2).rank,
(server) => statusMeta(server.status, theme).rank,
(server) => server.name,
),
),
@@ -90,7 +61,7 @@ export function DialogMcp() {
const options = createMemo(() =>
servers().map((server) => {
const meta = statusMeta(server.status, themeV2)
const meta = statusMeta(server.status, theme)
return {
value: server.name,
title: server.name,
@@ -106,12 +77,12 @@ export function DialogMcp() {
const focusedError = createMemo(() => {
const name = focused()
const server = servers().find((entry) => entry.name === name)
return server ? statusMeta(server.status, themeV2).error : undefined
return server ? statusMeta(server.status, theme).error : undefined
})
const open = (name: string | undefined) => {
const server = servers().find((entry) => entry.name === name)
if (!server || !statusMeta(server.status, themeV2).error) return
if (!server || !statusMeta(server.status, theme).error) return
setDetail(server)
}
@@ -129,7 +100,7 @@ export function DialogMcp() {
onSelect={(option) => open(option.value as string)}
footer={
<Show when={focusedError()}>
<text fg={themeV2.text.subdued()}>enter to view error</text>
<text fg={theme.textMuted}>enter to view error</text>
</Show>
}
/>
@@ -145,12 +116,11 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
const dialog = useDialog()
const clipboard = useClipboard()
const toast = useToast()
const { themeV2 } = useTheme().contextual("elevated")
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const config = useConfig().data
const [copied, setCopied] = createSignal(false)
const error = () => statusMeta(props.server.status, themeV2).error ?? "Unknown MCP connection error"
const error = () => statusMeta(props.server.status, theme).error ?? "Unknown MCP connection error"
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
let scroll: ScrollBoxRenderable | undefined
@@ -182,35 +152,29 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
return (
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
<text attributes={TextAttributes.BOLD} fg={theme.text}>
MCP server: {props.server.name}
</text>
<text fg={themeV2.text.subdued()} onMouseUp={props.onBack}>
<text fg={theme.textMuted} onMouseUp={props.onBack}>
esc back
</text>
</box>
<text fg={themeV2.text.feedback.error()}> Failed</text>
<box
backgroundColor={overlayTheme.background()}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<text fg={theme.error}> Failed</text>
<box backgroundColor={theme.backgroundElement} paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={getScrollAcceleration(config)}
>
<text fg={overlayTheme.text()} wrapMode="word">
<text fg={theme.text} wrapMode="word">
{error()}
</text>
</scrollbox>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={themeV2.text.subdued()}> scroll</text>
<text fg={themeV2.text.subdued()} onMouseUp={copy}>
<text fg={theme.textMuted}> scroll</text>
<text fg={theme.textMuted} onMouseUp={copy}>
{copied() ? "✓ copied" : "c copy details"}
</text>
</box>
@@ -38,7 +38,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const dialog = useDialog()
const client = useClient()
const dimensions = useTerminalDimensions()
const { themeV2 } = useTheme().contextual("elevated")
const { theme } = useTheme()
const sessionData = useData()
const route = useRoute()
const toast = useToast()
@@ -172,18 +172,16 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
return {
title,
titleView: isRemoving ? (
<span style={{ fg: themeV2.text.feedback.error() }}>Deleting {item.location}</span>
<span style={{ fg: theme.error }}>Deleting {item.location}</span>
) : deleting ? (
<span style={{ fg: themeV2.text.action.destructive() }}>
Press {shortcuts.get("dialog.move_session.delete")} again to confirm
</span>
<span style={{ fg: theme.text }}>Press {shortcuts.get("dialog.move_session.delete")} again to confirm</span>
) : suffix ? (
<>
{visible.slice(0, split)}
<span style={{ fg: themeV2.text.subdued() }}>{visible.slice(split)}</span>
<span style={{ fg: theme.textMuted }}>{visible.slice(split)}</span>
</>
) : undefined,
bg: deleting ? themeV2.background.action.destructive() : undefined,
bg: deleting ? theme.error : undefined,
value: {
type: "directory",
directory: item.location,
@@ -316,7 +314,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
title="Move session"
titleView={
<box flexDirection="row" gap={1}>
<text fg={themeV2.text()} attributes={TextAttributes.BOLD}>
<text fg={theme.text} attributes={TextAttributes.BOLD}>
Move session
</text>
<Show when={working() || directories.loading || loadedProject.loading}>
@@ -329,25 +327,25 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
emptyView={
showError() ? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.feedback.error()} attributes={TextAttributes.BOLD}>
<text fg={theme.error} attributes={TextAttributes.BOLD}>
Could not load project directories
</text>
<text fg={themeV2.text.subdued()}>{errorMessage(loadError())}</text>
<text fg={themeV2.text.subdued()}>Close and reopen Move session to try again.</text>
<text fg={theme.textMuted}>{errorMessage(loadError())}</text>
<text fg={theme.textMuted}>Close and reopen Move session to try again.</text>
</box>
) : directories.loading || loadedProject.loading ? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued()}>Loading project directories</text>
<text fg={theme.textMuted}>Loading project directories</text>
</box>
) : (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued()}>No project directories available</text>
<text fg={theme.textMuted}>No project directories available</text>
</box>
)
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued()}>No project directories found</text>
<text fg={theme.textMuted}>No project directories found</text>
</box>
}
locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())}
@@ -2,7 +2,7 @@ import { RGBA, TextAttributes } from "@opentui/core"
import open from "open"
import { createSignal } from "solid-js"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { selectedForeground, useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "../ui/dialog"
import { Link } from "../ui/link"
import { BgPulse } from "./bg-pulse"
@@ -38,9 +38,10 @@ function panelOverlay(color: RGBA) {
export function DialogRetryAction(props: DialogRetryActionProps) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const { theme } = useTheme()
const fg = selectedForeground(theme)
const showGoTreatment = () => props.link === GO_URL
const textBg = () => (showGoTreatment() ? panelOverlay(themeV2.background()) : undefined)
const textBg = () => (showGoTreatment() ? panelOverlay(theme.backgroundPanel) : undefined)
const [selected, setSelected] = createSignal<"dismiss" | "action">("action")
Keymap.createLayer(() => ({
@@ -85,26 +86,26 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
) : null}
<box zIndex={1} paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={themeV2.text()} bg={textBg()}>
<text attributes={TextAttributes.BOLD} fg={theme.text} bg={textBg()}>
{props.title}
</text>
<text fg={themeV2.text.subdued()} bg={textBg()} onMouseUp={() => dialog.clear()}>
<text fg={theme.textMuted} bg={textBg()} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box gap={0}>
<text fg={themeV2.text.subdued()} bg={textBg()}>
<text fg={theme.textMuted} bg={textBg()}>
{props.message}
</text>
</box>
{props.link ? (
showGoTreatment() ? (
<box alignItems="center" justifyContent="flex-end" height={7} paddingBottom={1}>
<Link href={props.link} fg={themeV2.markdown.link()} bg={textBg()} wrapMode="none" />
<Link href={props.link} fg={theme.primary} bg={textBg()} wrapMode="none" />
</box>
) : (
<box width="100%" flexDirection="row" justifyContent="center" paddingBottom={1}>
<Link href={props.link} fg={themeV2.markdown.link()} wrapMode="none" />
<Link href={props.link} fg={theme.primary} wrapMode="none" />
</box>
)
) : (
@@ -114,14 +115,12 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={
selected() === "dismiss" ? themeV2.background.action("focused") : RGBA.fromInts(0, 0, 0, 0)
}
backgroundColor={selected() === "dismiss" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setSelected("dismiss")}
onMouseUp={() => dismiss(props, dialog)}
>
<text
fg={selected() === "dismiss" ? themeV2.text.action("focused") : themeV2.text.subdued()}
fg={selected() === "dismiss" ? fg : theme.textMuted}
bg={selected() === "dismiss" ? undefined : textBg()}
attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
>
@@ -131,12 +130,12 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={selected() === "action" ? themeV2.background.action("focused") : RGBA.fromInts(0, 0, 0, 0)}
backgroundColor={selected() === "action" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setSelected("action")}
onMouseUp={() => runAction(props, dialog)}
>
<text
fg={selected() === "action" ? themeV2.text.action("focused") : themeV2.text()}
fg={selected() === "action" ? fg : theme.text}
bg={selected() === "action" ? undefined : textBg()}
attributes={selected() === "action" ? TextAttributes.BOLD : undefined}
>
@@ -13,7 +13,7 @@ export function DialogSessionDeleteFailed(props: {
onDone?: () => void
}) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const { theme } = useTheme()
const [store, setStore] = createStore({
active: "delete" as "delete" | "restore",
})
@@ -64,17 +64,17 @@ export function DialogSessionDeleteFailed(props: {
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Failed to Delete Session
</text>
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={themeV2.text.subdued()} wrapMode="word">
<text fg={theme.textMuted} wrapMode="word">
{`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
</text>
<text fg={themeV2.text.subdued()} wrapMode="word">
<text fg={theme.textMuted} wrapMode="word">
Choose how you want to recover this broken workspace session.
</text>
<box flexDirection="column" paddingBottom={1} gap={1}>
@@ -86,7 +86,7 @@ export function DialogSessionDeleteFailed(props: {
paddingRight={1}
paddingTop={1}
paddingBottom={1}
backgroundColor={item.id === store.active ? themeV2.background.action("focused") : undefined}
backgroundColor={item.id === store.active ? theme.primary : undefined}
onMouseUp={() => {
setStore("active", item.id)
void confirm()
@@ -94,14 +94,11 @@ export function DialogSessionDeleteFailed(props: {
>
<text
attributes={TextAttributes.BOLD}
fg={item.id === store.active ? themeV2.text.action("focused") : themeV2.text()}
fg={item.id === store.active ? theme.selectedListItemText : theme.text}
>
{item.title}
</text>
<text
fg={item.id === store.active ? themeV2.text.action("focused") : themeV2.text.subdued()}
wrapMode="word"
>
<text fg={item.id === store.active ? theme.selectedListItemText : theme.textMuted} wrapMode="word">
{item.description}
</text>
</box>
@@ -31,15 +31,14 @@ export function DialogWorkspaceFileChanges(props: {
message?: string
}) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
const { theme } = useTheme()
const config = useConfig().data
const dimensions = useTerminalDimensions()
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
const [store, setStore] = createStore({ active: "yes" as WorkspaceFileChangesChoice })
const height = createMemo(() => Math.min(props.files.length, 8))
const fileNameWidth = createMemo(() =>
Math.max(2, Math.min(60, dimensions().width - 2) - 6 - Math.max(7, ...props.files.map(changeCountWidth))),
const fileNameWidth = createMemo(
() => Math.max(2, Math.min(60, dimensions().width - 2) - 6 - Math.max(7, ...props.files.map(changeCountWidth))),
)
function confirm() {
@@ -72,21 +71,21 @@ export function DialogWorkspaceFileChanges(props: {
return (
<box gap={1}>
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
<text attributes={TextAttributes.BOLD} fg={theme.text}>
{props.title ?? "File Changes Found"}
</text>
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box paddingLeft={2} paddingRight={2}>
<text fg={themeV2.text.subdued()} wrapMode="word">
<text fg={theme.textMuted} wrapMode="word">
{props.message ?? "Do you want to move these changes with the session?"}
</text>
</box>
<scrollbox
height={height()}
backgroundColor={overlayTheme.background()}
backgroundColor={theme.backgroundElement}
scrollbarOptions={{ visible: false }}
scrollAcceleration={scrollAcceleration()}
>
@@ -95,19 +94,15 @@ export function DialogWorkspaceFileChanges(props: {
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<box flexDirection="row" minWidth={0} flexShrink={1}>
<box width={2} flexShrink={0}>
<text fg={overlayTheme.text.subdued()}>{statusLabel(item.status)}</text>
<text fg={theme.textMuted}>{statusLabel(item.status)}</text>
</box>
<FilePath value={item.file} maxWidth={fileNameWidth()} fg={overlayTheme.text.subdued()} />
<FilePath value={item.file} maxWidth={fileNameWidth()} fg={theme.textMuted} />
</box>
<box flexDirection="row" gap={1} minWidth={7} flexShrink={0} justifyContent="flex-end">
<text>
{" "}
{item.additions ? (
<span style={{ fg: overlayTheme.diff.text.added() }}>+{item.additions}</span>
) : null}
{item.deletions ? (
<span style={{ fg: overlayTheme.diff.text.removed() }}> -{item.deletions}</span>
) : null}
{item.additions ? <span style={{ fg: theme.diffAdded }}>+{item.additions}</span> : null}
{item.deletions ? <span style={{ fg: theme.diffRemoved }}> -{item.deletions}</span> : null}
</text>
</box>
</box>
@@ -120,14 +115,14 @@ export function DialogWorkspaceFileChanges(props: {
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={item === store.active ? themeV2.background.action("focused") : undefined}
backgroundColor={item === store.active ? theme.primary : undefined}
onMouseUp={() => {
setStore("active", item)
props.onSelect(item)
dialog.clear()
}}
>
<text fg={item === store.active ? themeV2.text.action("focused") : themeV2.text.subdued()}>{item}</text>
<text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
</box>
)}
</For>
+57 -96
View File
@@ -1,7 +1,7 @@
// Client data layer: apply server events and cache API reads into a Solid store.
// Prefer straightforward projection. API reads replace cached state, except admitted
// inputs survive history refresh until the server projects them. Reconnect invalidates
// cached reads; active UI owners decide what to sync again.
// Prefer straightforward projection. Do not add generation counters, stale-response
// merges, live/history overlays, or other race machinery here—last write wins.
// Reconnect invalidates cached reads; active UI owners decide what to sync again.
import type {
AgentInfo,
@@ -71,6 +71,7 @@ type Store = {
active: Record<string, DataSessionStatus>
message: Record<string, SessionMessageInfo[]>
pending: Record<string, SessionPendingInfo[]>
input: Record<string, string[]>
permission: Record<string, PermissionV2Request[]>
// Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel.
form: Record<string, FormWithLocation[]>
@@ -81,11 +82,6 @@ type Store = {
location: Record<string, LocationData>
}
type PendingOperation =
| { type: "admitted"; item: SessionPendingInfo }
| { type: "promoted"; inputID: string }
| { type: "reverted"; to: string }
function locationKey(location: LocationRef) {
return JSON.stringify([location.directory, location.workspaceID])
}
@@ -135,6 +131,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
active: {},
message: {},
pending: {},
input: {},
permission: {},
form: {},
},
@@ -150,21 +147,18 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
const messageIndex = new Map<string, Map<string, number>>()
const sync = createSync()
const pendingOperations = new Map<string, PendingOperation[]>()
function setSessionActive(sessionID: string, status: DataSessionStatus) {
setStore("session", "active", sessionID, status)
}
function addPending(item: SessionPendingInfo) {
pendingOperations.get(item.sessionID)?.push({ type: "admitted", item })
if (store.session.pending[item.sessionID]?.some((pending) => pending.id === item.id)) return
setStore("session", "pending", item.sessionID, [...(store.session.pending[item.sessionID] ?? []), item])
}
function removePending(sessionID: string, inputID?: string) {
if (!inputID) return
pendingOperations.get(sessionID)?.push({ type: "promoted", inputID })
setStore(
"session",
"pending",
@@ -173,36 +167,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
)
}
function pendingInputs(sessionID: string) {
return (store.session.pending[sessionID] ?? []).filter((item) => item.type !== "compaction")
}
function syncPending(sessionID: string) {
return sync.run(`session.pending:${sessionID}`, async () => {
const operations = pendingOperations.get(sessionID) ?? []
pendingOperations.set(sessionID, operations)
try {
const pending = new Map((await client.api.session.pending.list({ sessionID })).map((item) => [item.id, item]))
operations.forEach((operation) => {
if (operation.type === "admitted") {
pending.set(operation.item.id, operation.item)
return
}
if (operation.type === "promoted") {
pending.delete(operation.inputID)
return
}
pending.forEach((_, id) => {
if (id >= operation.to) pending.delete(id)
})
})
setStore("session", "pending", sessionID, reconcile([...pending.values()]))
} finally {
if (pendingOperations.get(sessionID) === operations) pendingOperations.delete(sessionID)
}
})
}
const message = {
update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) {
setStore(
@@ -235,31 +199,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const item = messages.findLast((item) => item.type === "compaction" && item.status === "running")
return item?.type === "compaction" ? item : undefined
},
fromPending(item: SessionPendingInfo): SessionMessageInfo {
if (item.type === "user")
return {
id: item.id,
type: "user",
...item.data,
time: { created: item.timeCreated },
}
if (item.type === "synthetic")
return {
id: item.id,
type: "synthetic",
...item.data,
time: { created: item.timeCreated },
}
return {
id: item.id,
type: "compaction",
status: "running",
reason: "manual",
summary: "",
recent: "",
time: { created: item.timeCreated },
}
},
latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantTool =>
@@ -330,7 +269,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
function removeSession(sessionID: string) {
messageIndex.delete(sessionID)
pendingOperations.delete(sessionID)
sync.invalidate(`session:${sessionID}`)
sync.invalidate(`session.pending:${sessionID}`)
sync.invalidate(`session.message:${sessionID}`)
@@ -343,6 +281,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
delete draft.active[sessionID]
delete draft.message[sessionID]
delete draft.pending[sessionID]
delete draft.input[sessionID]
delete draft.permission[sessionID]
delete draft.form[sessionID]
for (const [rootID, family] of Object.entries(draft.family)) {
@@ -435,35 +374,59 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}
break
case "session.input.promoted": {
const pending = store.session.pending[event.data.sessionID]?.some((item) => item.id === event.data.inputID)
removePending(event.data.sessionID, event.data.inputID)
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inputID)
if (position === undefined) return
const existing = draft[position]
if (!existing || !pending) return
if (!existing || !store.session.input[event.data.sessionID]?.includes(event.data.inputID)) return
existing.time.created = event.created
draft.splice(position, 1)
draft.push(existing)
index.clear()
draft.forEach((message, indexValue) => index.set(message.id, indexValue))
})
setStore(
"session",
"input",
event.data.sessionID,
(store.session.input[event.data.sessionID] ?? []).filter((id) => id !== event.data.inputID),
)
break
}
case "session.input.admitted": {
const pending: SessionPendingInfo = {
case "session.input.admitted":
addPending({
id: event.data.inputID,
sessionID: event.data.sessionID,
admittedSeq: event.durable.seq,
timeCreated: event.created,
...event.data.input,
}
addPending(pending)
})
if (!store.session.input[event.data.sessionID]?.includes(event.data.inputID))
setStore("session", "input", event.data.sessionID, [
...(store.session.input[event.data.sessionID] ?? []),
event.data.inputID,
])
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, message.fromPending(pending))
message.append(
draft,
index,
event.data.input.type === "user"
? {
id: event.data.inputID,
type: "user",
...event.data.input.data,
time: { created: event.created },
}
: {
id: event.data.inputID,
type: "synthetic",
...event.data.input.data,
time: { created: event.created },
},
)
})
break
}
case "session.instructions.updated":
const instructions = event.metadata?.instructions
if (
@@ -773,12 +736,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
if (store.session.info[event.data.sessionID]) {
setStore("session", "info", event.data.sessionID, "revert", undefined)
}
pendingOperations.get(event.data.sessionID)?.push({ type: "reverted", to: event.data.to })
setStore(
"session",
"pending",
"input",
event.data.sessionID,
(store.session.pending[event.data.sessionID] ?? []).filter((item) => item.id < event.data.to),
(store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.to),
)
message.update(event.data.sessionID, (draft, index) => {
const position = draft.findIndex((item) => item.id >= event.data.to)
@@ -952,10 +914,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
input: {
list(sessionID: string) {
return pendingInputs(sessionID).map((item) => item.id)
return store.session.input[sessionID] ?? []
},
has(sessionID: string, inputID: string) {
return pendingInputs(sessionID).some((item) => item.id === inputID)
return store.session.input[sessionID]?.includes(inputID) ?? false
},
},
pending: {
@@ -963,7 +925,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.session.pending[sessionID] ?? []
},
sync(sessionID: string) {
return syncPending(sessionID)
return sync.run(`session.pending:${sessionID}`, async () => {
const pending = await client.api.session.pending.list({ sessionID })
setStore("session", "pending", sessionID, reconcile(pending))
setStore(
"session",
"input",
sessionID,
reconcile(pending.filter((item) => item.type !== "compaction").map((item) => item.id)),
)
})
},
invalidate(sessionID: string) {
sync.invalidate(`session.pending:${sessionID}`)
@@ -989,21 +960,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
sync(sessionID: string) {
return sync.run(`session.message:${sessionID}`, async () => {
await syncPending(sessionID)
const localInputs = pendingInputs(sessionID).map((item) => item.id)
const pendingMessages = [...(store.session.pending[sessionID] ?? [])].map(message.fromPending)
const projected = await client.api.message.list({ sessionID, limit: 200, order: "desc" })
const next = projected.data.toReversed()
const index = new Map(next.map((message, index) => [message.id, index]))
const localInputIDs = new Set([...localInputs, ...pendingInputs(sessionID).map((item) => item.id)])
localInputIDs.forEach((messageID) => {
const position = messageIndex.get(sessionID)?.get(messageID)
const item = position === undefined ? undefined : store.session.message[sessionID]?.[position]
if (item) message.append(next, index, item)
})
pendingMessages.forEach((item) => message.append(next, index, item))
messageIndex.set(sessionID, index)
setStore("session", "message", sessionID, reconcile(next))
const messages = (
await client.api.message.list({ sessionID, limit: 200, order: "desc" })
).data.toReversed()
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
setStore("session", "message", sessionID, reconcile(messages))
})
},
invalidate(sessionID: string) {
@@ -26,8 +26,7 @@ function Commands(props: { context: Plugin.Context }) {
function Scrap(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const { themeV2 } = useTheme()
const { themeV2: elevatedTheme } = useTheme().contextual("elevated")
const { theme } = useTheme()
Keymap.createLayer(() => ({
commands: [
@@ -43,19 +42,19 @@ function Scrap(props: { context: Plugin.Context }) {
}))
return (
<box width={dimensions().width} height={dimensions().height} backgroundColor={themeV2.background()}>
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background}>
<box flexGrow={1} />
<box
height={1}
flexShrink={0}
backgroundColor={elevatedTheme.background()}
backgroundColor={theme.backgroundPanel}
paddingLeft={1}
paddingRight={1}
flexDirection="row"
>
<text fg={elevatedTheme.text.subdued()}>~/code/anomalyco/opencode</text>
<text fg={theme.textMuted}>~/code/anomalyco/opencode</text>
<box flexGrow={1} />
<text fg={elevatedTheme.text.subdued()}>esc home</text>
<text fg={theme.textMuted}>esc home</text>
</box>
</box>
)
+8 -1
View File
@@ -73,7 +73,14 @@ export function createSessionRows(sessionID: Accessor<string>) {
on([sessionID, () => client.connection.status()], ([id, status]) => {
if (status !== "connected") return
setRows(reconcile(reduce()))
void data.session.message.sync(id).catch(() => undefined)
void data.session.pending.sync(id).catch(() => undefined)
void data.session.message.sync(id).then(
() => {
if (sessionID() !== id) return
setRows(reconcile(reduce()))
},
() => undefined,
)
}),
)
-1
View File
@@ -77,7 +77,6 @@ export function createComponentTheme(current: Accessor<ResolvedThemeView>, mode:
return {
hue,
source: (color: RGBA) => current().source(color),
increase: (color: RGBA, amount = 1) => current().increase(color, amount),
decrease: (color: RGBA, amount = 1) => current().decrease(color, amount),
raise: (color: RGBA) => (mode() === "light" ? current().increase(color) : current().decrease(color)),
-1
View File
@@ -32,7 +32,6 @@ export {
export type {
FormfieldColor,
Hue,
HueSource,
HueScale,
ResolvedActionState,
ResolvedFormfieldState,
+14 -16
View File
@@ -141,36 +141,35 @@ function contextualActions(
function resolveView(
definition: ThemeTokensDefinition,
hue: ResolvedThemeView["hue"],
hueSteps: Pick<ResolvedThemeView, "source" | "increase" | "decrease">,
hueSteps: Pick<ResolvedThemeView, "increase" | "decrease">,
): ResolvedThemeView {
const source: Record<string, unknown> = { hue, ...definition }
return { ...(createResolver(source)(source, "theme") as ResolvedThemeView), hue, ...hueSteps }
}
function compileHueSteps(
hue: ResolvedThemeView["hue"],
): Pick<ResolvedThemeView, "source" | "increase" | "decrease"> {
const index = new WeakMap<RGBA, { hue: keyof typeof hue; step: HueStep; position: number }>()
for (const [name, scale] of Object.entries(hue) as [keyof typeof hue, HueScale][]) {
HueStep.literals.forEach((step, position) => index.set(scale[step], { hue: name, step, position }))
}
function compileHueSteps(hue: ResolvedThemeView["hue"]): Pick<ResolvedThemeView, "increase" | "decrease"> {
const index = new Map(
Object.values(hue).flatMap((scale) =>
HueStep.literals.map((step, position) => [colorKey(scale[step]), { position, scale }] as const),
),
)
const shift = (color: RGBA, amount: number) => {
const match = index.get(color)
const match = index.get(colorKey(color))
if (!match) return color
const offset = Number.isFinite(amount) ? Math.trunc(amount) : 0
const position = Math.max(0, Math.min(HueStep.literals.length - 1, match.position + offset))
return hue[match.hue][HueStep.literals[position]]
return match.scale[HueStep.literals[position]]
}
return {
source: (color) => {
const match = index.get(color)
return match ? { hue: match.hue, step: match.step } : undefined
},
increase: (color, amount = 1) => shift(color, amount),
decrease: (color, amount = 1) => shift(color, -amount),
}
}
function colorKey(color: RGBA) {
return color.toInts().join(":")
}
function resolveHue(definition: HueDefinition) {
const source = definition as Record<string, unknown>
const cache = new Map<string, HueScale>()
@@ -187,8 +186,7 @@ function resolveHue(definition: HueDefinition) {
if (typeof value === "string") {
const match = /^\$hue\.([^.]+)$/.exec(value)
if (!match?.[1]) throw new Error(`Hue alias "${value}" must reference a hue scale`)
const target = resolve(match[1], [...stack, name])
const result = Object.fromEntries(HueStep.literals.map((step) => [step, RGBA.clone(target[step])])) as HueScale
const result = resolve(match[1], [...stack, name])
cache.set(name, result)
return result
}
-2
View File
@@ -15,13 +15,11 @@ export type ResolvedActionState = "default" | ActionState
export type ResolvedFormfieldState = ResolvedActionState
export type HueScale = Readonly<Record<HueStep, RGBA>>
export type Hue = Readonly<Record<BaseHue | HueAlias, HueScale>>
export type HueSource = Readonly<{ hue: BaseHue | HueAlias; step: HueStep }>
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>>
export type FormfieldColor = StatefulColor
export type ResolvedThemeView = {
readonly hue: Hue
readonly source: (color: RGBA) => HueSource | undefined
readonly increase: (color: RGBA, amount?: number) => RGBA
readonly decrease: (color: RGBA, amount?: number) => RGBA
readonly text: {
+1 -1
View File
@@ -267,7 +267,7 @@ function neutralAnchors(theme: Theme, mode: "light" | "dark") {
const light: { step: HueStep; color: RGBA }[] = [
{ step: 100, color: theme.background },
{ step: 200, color: theme.backgroundPanel },
{ step: 300, color: theme.backgroundElement || theme.backgroundMenu },
{ step: 300, color: theme.backgroundMenu },
{ step: 700, color: theme.textMuted },
{ step: 900, color: theme.text },
]
+16 -42
View File
@@ -17,8 +17,7 @@ type Active = ExportFormat | "debug" | "thinking" | "copy" | "export"
export function DialogExportOptions(props: DialogExportOptionsProps) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
const { theme } = useTheme()
const [store, setStore] = createStore({
format: "markdown" as ExportFormat,
debug: false,
@@ -76,33 +75,25 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Export session
</text>
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box flexDirection="row" gap={1}>
<text fg={themeV2.text()}>Export as:</text>
<text fg={theme.text}>Export as:</text>
<box flexDirection="row" gap={1}>
<For each={["markdown", "json"] as const}>
{(format) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={themeV2.background.formfield({
focused: store.active === format,
selected: store.format === format,
})}
backgroundColor={store.format === format ? theme.backgroundElement : undefined}
onMouseUp={() => selectFormat(format)}
>
<text
fg={themeV2.text.formfield({
focused: store.active === format,
selected: store.format === format,
})}
>
<text fg={store.format === format ? theme.text : theme.textMuted}>
{store.format === format ? "◉" : "○"} {format === "markdown" ? "Markdown" : "JSON"}
</text>
</box>
@@ -114,60 +105,43 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
<box
flexDirection="row"
gap={1}
backgroundColor={themeV2.background.formfield({
focused: store.active === "thinking",
selected: store.thinking,
})}
backgroundColor={store.active === "thinking" ? theme.backgroundElement : undefined}
onMouseUp={() => {
setStore("active", "thinking")
setStore("thinking", !store.thinking)
}}
>
<text fg={themeV2.text.formfield({ focused: store.active === "thinking", selected: store.thinking })}>
<text fg={store.active === "thinking" ? theme.primary : theme.textMuted}>
{store.thinking ? "[x]" : "[ ]"}
</text>
<text fg={themeV2.text.formfield({ focused: store.active === "thinking", selected: store.thinking })}>
Include thinking
</text>
<text fg={store.active === "thinking" ? theme.primary : theme.text}>Include thinking</text>
</box>
</Show>
<Show when={store.format === "json"}>
<box
flexDirection="row"
gap={1}
backgroundColor={themeV2.background.formfield({
focused: store.active === "debug",
selected: store.debug,
})}
backgroundColor={store.active === "debug" ? theme.backgroundElement : undefined}
onMouseUp={() => {
setStore("active", "debug")
setStore("debug", !store.debug)
}}
>
<text fg={themeV2.text.formfield({ focused: store.active === "debug", selected: store.debug })}>
{store.debug ? "[x]" : "[ ]"}
</text>
<text fg={themeV2.text.formfield({ focused: store.active === "debug", selected: store.debug })}>
Events (debug)
</text>
<text fg={store.active === "debug" ? theme.primary : theme.textMuted}>{store.debug ? "[x]" : "[ ]"}</text>
<text fg={store.active === "debug" ? theme.primary : theme.text}>Events (debug)</text>
</box>
</Show>
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
<box
paddingLeft={4}
paddingRight={4}
backgroundColor={overlayTheme.background()}
backgroundColor={theme.backgroundElement}
onMouseUp={() => confirm("copy")}
>
<text fg={overlayTheme.text()}>Copy</text>
<text fg={theme.text}>Copy</text>
</box>
<box
paddingLeft={4}
paddingRight={4}
backgroundColor={themeV2.background.action({ focused: store.active === "export" })}
onMouseUp={() => confirm("export")}
>
<text fg={themeV2.text.action({ focused: store.active === "export" })}>Export</text>
<box paddingLeft={4} paddingRight={4} backgroundColor={theme.primary} onMouseUp={() => confirm("export")}>
<text fg={theme.selectedListItemText}>Export</text>
</box>
</box>
</box>
+11 -11
View File
@@ -18,7 +18,7 @@ export type DialogPromptProps = {
export function DialogPrompt(props: DialogPromptProps) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
const { theme } = useTheme()
const shortcuts = Keymap.useShortcuts()
const [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>()
let textarea: TextareaRenderable
@@ -74,10 +74,10 @@ export function DialogPrompt(props: DialogPromptProps) {
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
<text attributes={TextAttributes.BOLD} fg={theme.text}>
{props.title}
</text>
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
@@ -91,20 +91,20 @@ export function DialogPrompt(props: DialogPromptProps) {
}}
initialValue={props.value}
placeholder={props.placeholder ?? "Enter text"}
placeholderColor={themeV2.text.subdued()}
textColor={themeV2.text.formfield({ disabled: props.busy })}
focusedTextColor={themeV2.text.formfield({ disabled: props.busy })}
cursorColor={props.busy ? themeV2.background.formfield("disabled") : themeV2.text()}
placeholderColor={theme.textMuted}
textColor={props.busy ? theme.textMuted : theme.text}
focusedTextColor={props.busy ? theme.textMuted : theme.text}
cursorColor={props.busy ? theme.backgroundElement : theme.text}
/>
<Show when={props.busy}>
<Spinner color={themeV2.text.subdued()}>{props.busyText ?? "Working..."}</Spinner>
<Spinner color={theme.textMuted}>{props.busyText ?? "Working..."}</Spinner>
</Show>
</box>
<box paddingBottom={1} gap={1} flexDirection="row">
<Show when={!props.busy} fallback={<text fg={themeV2.text.subdued()}>processing...</text>}>
<Show when={!props.busy} fallback={<text fg={theme.textMuted}>processing...</text>}>
<Show when={shortcuts.get("dialog.prompt.submit")}>
<text fg={themeV2.text()}>
{shortcuts.get("dialog.prompt.submit")} <span style={{ fg: themeV2.text.subdued() }}>submit</span>
<text fg={theme.text}>
{shortcuts.get("dialog.prompt.submit")} <span style={{ fg: theme.textMuted }}>submit</span>
</text>
</Show>
</Show>
+2 -2
View File
@@ -16,7 +16,7 @@ export function Dialog(
}>,
) {
const dimensions = useTerminalDimensions()
const { themeV2 } = useTheme().contextual("elevated")
const { theme } = useTheme()
const renderer = useRenderer()
let dismiss = false
@@ -59,7 +59,7 @@ export function Dialog(
}}
width={width()}
maxWidth={dimensions().width - 2}
backgroundColor={themeV2.background()}
backgroundColor={theme.backgroundPanel}
paddingTop={1}
>
{props.children}
+3 -3
View File
@@ -14,7 +14,7 @@ type ToastInput = Omit<ToastOptions, "duration"> & { duration?: number }
export function Toast() {
const toast = useToast()
const { themeV2 } = useTheme().contextual("overlay")
const { theme, themeV2 } = useTheme()
const dimensions = useTerminalDimensions()
return (
@@ -31,8 +31,8 @@ export function Toast() {
paddingRight={2}
paddingTop={1}
paddingBottom={1}
backgroundColor={themeV2.background()}
borderColor={themeV2.text.feedback[current().variant]()}
backgroundColor={theme.backgroundPanel}
borderColor={theme[current().variant]}
border={["left", "right"]}
customBorderChars={SplitBorder.customBorderChars}
>
+12 -128
View File
@@ -2377,40 +2377,16 @@ test("settles pending tools when a live failure arrives", async () => {
}
})
test("preserves admitted prompts when hydration races with promotion", async () => {
test("renders admitted prompts immediately and tracks them until promoted", async () => {
const events = createEventStream()
const sessionID = "session-1"
const messageID = "msg_user_1"
const queuedID = "msg_user_2"
const requested = Promise.withResolvers<void>()
const response = Promise.withResolvers<Response>()
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/pending`)
if (url.pathname === `/api/session/${sessionID}/message`)
return json({
data: [
{
admittedSeq: 0,
id: messageID,
sessionID,
timeCreated: 0,
type: "user",
data: { text: "hello" },
delivery: "steer",
},
{
admittedSeq: 1,
id: queuedID,
sessionID,
timeCreated: 1,
type: "user",
data: { text: "queued" },
delivery: "queue",
},
],
data: [{ id: messageID, type: "user", text: "hello", time: { created: 0 } }],
cursor: {},
})
if (url.pathname !== `/api/session/${sessionID}/message`) return
requested.resolve()
return response.promise
}, events)
let sync!: ReturnType<typeof useData>
let ready!: () => void
@@ -2468,11 +2444,12 @@ test("preserves admitted prompts when hydration races with promotion", async ()
])
expect(sync.session.input.list(sessionID)).toEqual([messageID])
const refresh = sync.session.message.sync(sessionID)
await requested.promise
await sync.session.message.sync(sessionID)
expect(sync.session.message.list(sessionID)?.[0]?.metadata).toBeUndefined()
emitEvent(events, {
id: "evt_prompted_1",
created: 1,
created: 0,
type: "session.input.promoted",
durable: durable(sessionID, 1),
data: {
@@ -2484,17 +2461,14 @@ test("preserves admitted prompts when hydration races with promotion", async ()
await wait(() => received.at(-1) === "session.input.promoted")
expect(received.slice(-2)).toEqual(["session.input.admitted", "session.input.promoted"])
unsubscribe()
response.resolve(json({ data: [], cursor: {} }))
await refresh
const message = sync.session.message.get(sessionID, messageID)
const message = sync.session.message.list(sessionID)?.[0]
expect(message?.type).toBe("user")
if (message?.type !== "user") return
expect(message).toMatchObject({ id: messageID, text: "hello" })
expect(message.metadata).toBeUndefined()
expect(sync.session.input.list(sessionID)).toEqual([queuedID])
expect(sync.session.message.list(sessionID).map((message) => message.id)).toEqual([messageID, queuedID])
expect(sync.session.message.get(sessionID, queuedID)).toMatchObject({ id: queuedID, text: "queued" })
expect(sync.session.pending.list(sessionID)).toEqual([])
expect(sync.session.input.list(sessionID)).toEqual([])
expect(sync.session.message.list(sessionID).map((message) => message.id)).toEqual([messageID])
expect(sync.session.message.list("missing")).toEqual([])
expect(sync.session.message.get(sessionID, messageID)).toBe(message)
expect(sync.session.message.get(sessionID, "missing")).toBeUndefined()
@@ -2504,96 +2478,6 @@ test("preserves admitted prompts when hydration races with promotion", async ()
}
})
test("reconciles admissions and promotions that arrive while pending work hydrates", async () => {
const events = createEventStream()
const sessionID = "session-pending-race"
const messageID = "msg_late_user"
const promotedID = "msg_promoted_user"
const requested = Promise.withResolvers<void>()
const response = Promise.withResolvers<Response>()
const calls = createFetch((url) => {
if (url.pathname !== `/api/session/${sessionID}/pending`) return
requested.resolve()
return response.promise
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</ClientProvider>
</TestTuiContexts>
))
try {
const sync = data.session.pending.sync(sessionID)
await requested.promise
emitEvent(events, {
id: "evt_late_admitted",
created: 1,
type: "session.input.admitted",
durable: durable(sessionID),
data: {
sessionID,
inputID: messageID,
input: { type: "user", data: { text: "late" }, delivery: "steer" },
},
})
emitEvent(events, {
id: "evt_promoted_admitted",
created: 2,
type: "session.input.admitted",
durable: durable(sessionID, 1),
data: {
sessionID,
inputID: promotedID,
input: { type: "user", data: { text: "promoted" }, delivery: "steer" },
},
})
emitEvent(events, {
id: "evt_promoted",
created: 3,
type: "session.input.promoted",
durable: durable(sessionID, 2),
data: { sessionID, inputID: promotedID },
})
await wait(() => data.session.pending.list(sessionID).length === 1)
response.resolve(
json({
data: [
{
admittedSeq: 1,
id: promotedID,
sessionID,
timeCreated: 2,
type: "user",
data: { text: "promoted" },
delivery: "steer",
},
],
}),
)
await sync
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual([messageID])
expect(data.session.input.list(sessionID)).toEqual([messageID])
expect(data.session.message.get(sessionID, messageID)).toMatchObject({ text: "late" })
expect(data.session.message.get(sessionID, promotedID)).toMatchObject({ text: "promoted" })
} finally {
app.renderer.destroy()
}
})
test("skips initial instruction state and projects later updates with their message ID", async () => {
const events = createEventStream()
const calls = createFetch(undefined, events)
-1
View File
@@ -109,7 +109,6 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
})
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/active") return json({ data: {} })
if (/^\/api\/session\/[^/]+\/pending$/.test(url.pathname)) return json({ data: [] })
if (url.pathname === "/api/permission/request")
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
if (url.pathname === "/api/form/request")
+1 -3
View File
@@ -25,10 +25,8 @@ test("provides reactive property, variant, state, and context accessors", () =>
expect(theme.decrease(theme.hue.red(300), 2)).toBe(resolved().hue.red[100])
expect(theme.increase(theme.hue.red(900), 3)).toBe(resolved().hue.red[900])
expect(theme.decrease(theme.hue.red(100), 3)).toBe(resolved().hue.red[100])
expect(theme.source(theme.background.surface.offset())).toEqual({ hue: "neutral", step: 200 })
const equivalent = RGBA.fromInts(...resolved().hue.green[500].toInts())
expect(theme.source(equivalent)).toBeUndefined()
expect(theme.increase(equivalent, 1)).toBe(equivalent)
expect(theme.increase(equivalent, 1)).toBe(resolved().hue.green[600])
const unmatched = RGBA.fromInts(1, 2, 3)
expect(theme.increase(unmatched, 1)).toBe(unmatched)
expect(theme.text.subdued()).toBe(resolved().text.subdued)
+6 -37
View File
@@ -12,15 +12,9 @@ test("resolves independent definitions and hue aliases", () => {
const lightTheme = resolveTheme(light)
const darkTheme = resolveTheme(dark)
expect(lightTheme.hue.accent).not.toBe(lightTheme.hue.blue)
expect(lightTheme.hue.accent[500].equals(lightTheme.hue.blue[500])).toBeTrue()
expect(lightTheme.hue.interactive).not.toBe(lightTheme.hue.blue)
expect(lightTheme.hue.interactive[500].equals(lightTheme.hue.blue[500])).toBeTrue()
expect(lightTheme.hue.neutral).not.toBe(lightTheme.hue.gray)
expect(lightTheme.hue.neutral[500].equals(lightTheme.hue.gray[500])).toBeTrue()
expect(lightTheme.source(lightTheme.hue.blue[500])).toEqual({ hue: "blue", step: 500 })
expect(lightTheme.source(lightTheme.hue.neutral[200])).toEqual({ hue: "neutral", step: 200 })
expect(lightTheme.source(lightTheme.background.surface.offset)).toEqual({ hue: "neutral", step: 200 })
expect(lightTheme.hue.accent).toBe(lightTheme.hue.blue)
expect(lightTheme.hue.interactive).toBe(lightTheme.hue.blue)
expect(lightTheme.hue.neutral).toBe(lightTheme.hue.gray)
expect(lightTheme.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200])
expect(lightTheme.decrease(lightTheme.hue.red[200])).toBe(lightTheme.hue.red[100])
expect(lightTheme.contexts["@context:elevated"]?.increase(lightTheme.hue.red[100])).toBe(
@@ -70,15 +64,9 @@ test("resolves base hue aliases and rejects circular hue aliases", () => {
"light",
)
expect(aliased.hue.blue).not.toBe(aliased.hue.red)
expect(aliased.hue.blue[500].equals(aliased.hue.red[500])).toBeTrue()
expect(aliased.hue.purple).not.toBe(aliased.hue.blue)
expect(aliased.hue.purple[500].equals(aliased.hue.red[500])).toBeTrue()
expect(overridden.hue.blue).not.toBe(overridden.hue.red)
expect(overridden.hue.blue[500].equals(overridden.hue.red[500])).toBeTrue()
expect(aliased.source(aliased.hue.red[500])).toEqual({ hue: "red", step: 500 })
expect(aliased.source(aliased.hue.blue[500])).toEqual({ hue: "blue", step: 500 })
expect(aliased.source(aliased.hue.purple[500])).toEqual({ hue: "purple", step: 500 })
expect(aliased.hue.blue).toBe(aliased.hue.red)
expect(aliased.hue.purple).toBe(aliased.hue.red)
expect(overridden.hue.blue).toBe(overridden.hue.red)
expect(() =>
resolveTheme({
...light,
@@ -87,25 +75,6 @@ test("resolves base hue aliases and rejects circular hue aliases", () => {
).toThrow("Circular hue reference: red -> blue -> red")
})
test("steps by hue source when adjacent colors have equal values", () => {
if (typeof light.hue.gray !== "object") throw new Error("Expected a concrete gray scale")
const theme = resolveTheme({
...light,
hue: {
...light.hue,
gray: { ...light.hue.gray, 200: "#eee8d5", 300: "#eee8d5", 400: "#d3d7c6" },
neutral: "$hue.gray",
},
})
expect(theme.hue.neutral[200]).not.toBe(theme.hue.neutral[300])
expect(theme.hue.neutral[200].equals(theme.hue.neutral[300])).toBeTrue()
expect(theme.source(theme.hue.neutral[200])).toEqual({ hue: "neutral", step: 200 })
expect(theme.source(theme.hue.neutral[300])).toEqual({ hue: "neutral", step: 300 })
expect(theme.increase(theme.hue.neutral[200])).toBe(theme.hue.neutral[300])
expect(theme.increase(theme.hue.neutral[300])).toBe(theme.hue.neutral[400])
})
test("merges partial files with the selected OpenCode defaults", () => {
const theme = resolveThemeFile(
{
@@ -85,7 +85,7 @@ test("infers chromatic hues, anchors light and dark colors, and aliases ambiguou
expect(() => resolveThemeFile(migrated, "dark")).not.toThrow()
})
test("builds gray from V1 surfaces and text without using menus or borders", () => {
test("builds gray from V1 surfaces and text without using borders", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
source.theme.backgroundMenu = { light: "#ededed", dark: "#252525" }
const light = resolveV1(source, "light")
@@ -97,12 +97,12 @@ test("builds gray from V1 surfaces and text without using menus or borders", ()
expect(lightGray[100]).toBe(hex(light.background))
expect(lightGray[200]).toBe(hex(light.backgroundPanel))
expect(lightGray[300]).toBe(hex(light.backgroundElement))
expect(lightGray[300]).toBe(hex(light.backgroundMenu))
expect(lightGray[700]).toBe(hex(light.textMuted))
expect(lightGray[900]).toBe(hex(light.text))
expect(darkGray[100]).toBe(hex(dark.text))
expect(darkGray[300]).toBe(hex(dark.textMuted))
expect(darkGray[700]).toBe(hex(dark.backgroundElement))
expect(darkGray[700]).toBe(hex(dark.backgroundMenu))
expect(darkGray[800]).toBe(hex(dark.backgroundPanel))
expect(darkGray[900]).toBe(hex(dark.background))