Compare commits

..

26 Commits

Author SHA1 Message Date
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 d8077552ea chore: rerun ci 2026-07-12 19:37:34 +00:00
Aiden Cline df04a0133b feat(core): port provider integrations to v2 2026-07-12 18:40:56 +00:00
opencode-agent[bot] 430750e2f8 fix(tui): remove unsupported MCP toggle (#36531)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-12 10:56:24 -05:00
Kit Langton 7913c4a490 refactor(server): share event stream encoding (#36484) 2026-07-12 11:48:52 -04:00
Kit Langton 5414697bd1 fix(tui): show background subagent completion (#36530)
Co-authored-by: Kit Langton <7587245+kitlangton@users.noreply.github.com>
2026-07-12 11:43:13 -04:00
Kit Langton e22e3b8f2c fix(cli): keep update preflight through TUI loading (#36475)
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-07-11 22:19:13 -04:00
Kit Langton 8e657c7db5 fix(core): settle malformed tool input on failure (#36477) 2026-07-11 21:41:57 -04:00
Kit Langton 02e2277057 fix(tui): lower durable event log level (#36479) 2026-07-11 21:37:08 -04:00
Dax Raad ac1ddc3f83 refactor(tui): simplify config and tool display 2026-07-11 19:51:19 -04:00
opencode-agent[bot] 8f904c8e9a fix(cli): load v2 tui config (#36466)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-11 19:13:29 -04:00
Dax Raad ec07ee56f4 feat(tui): add plugin route api 2026-07-11 18:52:04 -04:00
Dax Raad de86d73d19 feat(tui): add v2 plugin context 2026-07-11 17:56:25 -04:00
Dax Raad 0fa9e5039e refactor(tui): reduce legacy sync usage 2026-07-11 17:05:23 -04:00
Dax Raad c073387723 refactor(tui): use default client export 2026-07-11 16:44:55 -04:00
Dax Raad 1956497f42 fix(client): loosen opaque promise outputs 2026-07-11 16:38:02 -04:00
Dax Raad 2a7e32c416 refactor(tui): remove legacy sdk surfaces 2026-07-11 16:38:02 -04:00
Kit Langton 56a7c06a80 fix(cli): hand update completion directly to the TUI (#36455) 2026-07-11 15:42:31 -04:00
Aiden Cline 75e8fd4da2 test(codemode): drop leftover $codemode references (#36451) 2026-07-11 14:15:11 -05:00
opencode-agent[bot] b9f39dd751 fix(core): disable unused fff content caches (#36452)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-11 15:03:54 -04:00
Aiden Cline 6eeeb4bfcf feat(codemode): make search a global built-in and rewrite README (#36450) 2026-07-11 13:43:33 -05:00
Kit Langton 1ccaca826e feat(cli): update preflight UI while replacing the background service (#36448) 2026-07-11 14:43:16 -04:00
Aiden Cline 04f9b15178 fix(core): include child session id in background subagent start text (#36447) 2026-07-11 13:34:30 -05:00
Kit Langton 66b9cc7931 fix(client): accept larger SSE events (#36442) 2026-07-11 14:15:53 -04:00
Kit Langton e3f7637eb2 feat(tui): add semantic file path truncation (#36352) 2026-07-11 13:57:29 -04:00
Kit Langton 5945a8d429 fix(tui): restore queued compaction indicator (#36440) 2026-07-11 13:54:23 -04:00
Kit Langton c7ceccf869 fix(core): skip fff for aggregate locations (#36437) 2026-07-11 13:47:41 -04:00
166 changed files with 4980 additions and 3758 deletions
-1
View File
@@ -1003,7 +1003,6 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/ui": "workspace:*",
+16 -5
View File
@@ -2,12 +2,13 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/core/global"
import { run } from "@opencode-ai/tui"
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
import { TuiConfig } from "@opencode-ai/tui/config"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { TuiConfig } from "../../tui-config"
import { Effect, Option } from "effect"
import { Server } from "../../services/server"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () {
@@ -15,23 +16,33 @@ export default Runtime.handler(Commands, (input) =>
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.forkScoped)
const preflight = UpdatePreflight.make()
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
const server = yield* Server.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
onStart: (reason) =>
onStart: (reason, existing) => {
if (reason === "version-mismatch" && preflight.begin(existing?.version)) return
process.stderr.write(
reason === "version-mismatch"
? "Restarting background server (version mismatch)...\n"
: "Starting background server...\n",
),
})
const config = TuiConfig.resolve({}, { terminalSuspend: false })
)
},
}).pipe(
Effect.tapError(() =>
Effect.promise(() => preflight.fail("OpenCode update could not start the new background service")),
),
)
preflight.loading()
const config = yield* TuiConfig.load()
let disposeSlots: (() => void) | undefined
const runFork = Effect.runForkWith(yield* Effect.context())
yield* run({
server,
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
config,
terminalHandoff: () => preflight.finish(),
log: (level, message, tags) => {
const effect =
level === "debug"
+1 -1
View File
@@ -6,7 +6,7 @@
// history ring. All are async because they read config or hit the SDK, but
// none block each other.
import { Context, Effect, Layer } from "effect"
import { resolve } from "@opencode-ai/tui/config"
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 { makeRuntime } from "@opencode-ai/core/effect/runtime"
+1 -1
View File
@@ -13,7 +13,7 @@
// → OpenTUI split-footer renderer writes to terminal
import type { OpenCodeClient, ReferenceListOutput } from "@opencode-ai/client/promise"
import type { FilePart, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
import type { TuiConfig } from "@opencode-ai/tui/config"
import type { TuiConfig } from "@opencode-ai/tui/config/v1"
export type RunFilePart = {
type: "file"
@@ -0,0 +1,494 @@
/** @jsxImportSource @opentui/solid */
// Split-footer status shown while a freshly launched CLI replaces a
// version-mismatched background service before the TUI attaches.
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core"
import { render, useTerminalDimensions } from "@opentui/solid"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
import { go } from "@opencode-ai/tui/logo"
import {
batch,
createEffect,
createMemo,
createSignal,
For,
Index,
on,
onCleanup,
onMount,
Show,
untrack,
} from "solid-js"
const stages = ["Keeping your session safe", "Starting the new background service", "Loading OpenCode"] as const
const stageFloor = 480
const transitionDuration = 420
const completionHold = 650
export type Handle = {
readonly begin: (from?: string) => boolean
readonly loading: () => void
readonly finish: () => Promise<Handoff | undefined>
readonly fail: (message: string) => Promise<void>
readonly close: () => Promise<void>
}
export type Handoff = {
readonly renderer: CliRenderer
readonly mode: ThemeMode | null
readonly complete: () => void
}
export const make = (): Handle => {
let session: Promise<Session | undefined> | undefined
return {
begin: (from) => {
if (!process.stdout.isTTY || !process.stdin.isTTY) return false
session ??= open(from).catch(() => {
process.stderr.write("Restarting background server (version mismatch)...\n")
return undefined
})
return true
},
loading: () => {
void session?.then((active) => active?.loading())
},
finish: async () => {
const active = await session
return active?.finish()
},
fail: async (message) => {
const active = await session
await active?.fail(message)
},
close: async () => {
const active = await session
await active?.close()
},
}
}
type Session = {
readonly loading: () => Promise<void>
readonly finish: () => Promise<Handoff>
readonly fail: (message: string) => Promise<void>
readonly close: () => Promise<void>
}
async function open(from?: string): Promise<Session> {
registerOpencodeSpinner()
const [active, setActive] = createSignal(0)
const [outcome, setOutcome] = createSignal<"running" | "success" | "failure">("running")
const [failure, setFailure] = createSignal("")
const [animating, setAnimating] = createSignal(true)
const [visible, setVisible] = createSignal(true)
let resolveOutcome: (() => void) | undefined
const renderer = await createCliRenderer({
stdin: process.stdin,
useMouse: false,
autoFocus: false,
openConsoleOnError: false,
exitOnCtrlC: false,
screenMode: "split-footer",
footerHeight: 4,
targetFps: 60,
useKittyKeyboard: {},
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
},
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
})
const terminalMode = renderer.waitForThemeMode(1000).catch(() => null)
await render(
() => (
<Show when={visible()}>
<UpdateFooter
from={from}
active={active}
outcome={outcome}
failure={failure}
animating={animating}
renderer={renderer}
onOutcomeSettled={() => resolveOutcome?.()}
/>
</Show>
),
renderer,
).catch((error) => {
if (!renderer.isDestroyed) renderer.destroy()
throw error
})
let shownAt = performance.now()
const waitForStage = async () => {
const remaining = stageFloor - (performance.now() - shownAt)
if (remaining > 0) await Bun.sleep(remaining)
}
const advance = async (stage: number) => {
await waitForStage()
if (outcome() !== "running") return
setActive(stage)
shownAt = performance.now()
}
// Service.start currently exposes only its start boundary, so this first
// transition is time-based. Finer lifecycle callbacks remain follow-up work.
const auto = advance(1)
const transitionTo = async (next: "success" | "failure", hold: number) => {
const settled = Promise.withResolvers<void>()
resolveOutcome = settled.resolve
setOutcome(next)
const completed = await Promise.race([
settled.promise.then(() => true),
Bun.sleep(transitionDuration + 500).then(() => false),
])
resolveOutcome = undefined
setAnimating(false)
if (completed) await Bun.sleep(hold)
}
let closing: Promise<void> | undefined
let transferred = false
const close = () =>
(closing ??= (async () => {
if (transferred) return
setAnimating(false)
if (renderer.isDestroyed) return
renderer.pause()
await Promise.race([renderer.idle(), Bun.sleep(500)])
renderer.destroy()
})())
let loading: Promise<void> | undefined
const load = () =>
(loading ??= (async () => {
await auto
await advance(2)
})())
let settled: Promise<void> | undefined
const settle = (task: () => Promise<void>) => (settled ??= task())
return {
loading: load,
finish: async () => {
await settle(async () => {
await load()
await waitForStage()
await transitionTo("success", completionHold)
})
const mode = await terminalMode
renderer.externalOutputMode = "passthrough"
renderer.screenMode = "alternate-screen"
renderer.consoleMode = "console-overlay"
renderer.requestRender()
await Promise.race([renderer.idle(), Bun.sleep(500)])
transferred = true
return {
renderer,
mode,
complete: () => setVisible(false),
}
},
fail: (message) =>
settle(async () => {
setFailure(message)
await transitionTo("failure", 250)
await close()
}),
close,
}
}
const colors = {
accent: RGBA.fromHex("#a6b8ff"),
accentBright: RGBA.fromHex("#eef1ff"),
accentDim: RGBA.fromHex("#596998"),
error: RGBA.fromHex("#ff8192"),
muted: RGBA.fromHex("#808080"),
success: RGBA.fromHex("#8bd5a5"),
text: RGBA.fromHex("#eeeeee"),
}
const monogram = go.right.slice(1)
const sweepBlend = 8
const textDim = RGBA.fromHex("#4c4c4c")
const rampSteps = 32
const blend = (from: RGBA, to: RGBA, amount: number) =>
RGBA.fromValues(
from.r + (to.r - from.r) * amount,
from.g + (to.g - from.g) * amount,
from.b + (to.b - from.b) * amount,
)
const ramp = (from: RGBA, to: RGBA) =>
Array.from({ length: rampSteps + 1 }, (_, step) => blend(from, to, step / rampSteps))
const railRamp = ramp(colors.accentDim, colors.accentBright)
const monogramRamp = ramp(colors.muted, colors.accent)
const rampCache = new Map<RGBA, ReadonlyArray<RGBA>>()
const rampFor = (color: RGBA) => {
const cached = rampCache.get(color)
if (cached) return cached
const result = ramp(textDim, color)
rampCache.set(color, result)
return result
}
const shade = (palette: ReadonlyArray<RGBA>, brightness: number) =>
palette[Math.round(Math.max(0, Math.min(1, brightness)) * rampSteps)]
type Cell = { readonly char: string; readonly color: RGBA; readonly bold?: boolean }
const styled = (text: string, color: RGBA, bold?: boolean): Cell[] =>
Array.from(text).map((char) => ({ char, color, bold }))
const phrase = (...segments: ReadonlyArray<readonly [string, RGBA, boolean?]>): Cell[] =>
segments.flatMap((segment, index) => [
...(index > 0 ? styled(" ", colors.muted) : []),
...styled(segment[0], segment[1], segment[2]),
])
function Monogram(props: { ink: () => RGBA }) {
const shadow = createMemo(() => {
const ink = props.ink()
return RGBA.fromValues(ink.r * 0.25, ink.g * 0.25, ink.b * 0.25)
})
return (
<box flexDirection="column">
<For each={monogram}>
{(line) => (
<box flexDirection="row">
<For each={Array.from(line)}>
{(char) =>
char === "_" ? (
<text bg={shadow()} selectable={false}>
{" "}
</text>
) : (
<text fg={props.ink()} selectable={false}>
{char}
</text>
)
}
</For>
</box>
)}
</For>
</box>
)
}
type CellTransition = { from: Cell[]; to: Cell[]; done?: () => void }
function createTransition(render: (transition: CellTransition, progress: number) => Cell[]) {
const [state, setState] = createSignal<{ from: Cell[]; to: Cell[]; done?: () => void } | undefined>()
const [progress, setProgress] = createSignal(0)
let elapsed = 0
const cells = createMemo(() => {
const transition = state()
if (!transition) return undefined
return render(transition, progress())
})
return {
start(from: Cell[], to: Cell[], done?: () => void) {
elapsed = 0
setProgress(0)
setState({ from, to, done })
},
tick(deltaTime: number) {
const transition = state()
if (!transition) return
elapsed = Math.min(transitionDuration, elapsed + deltaTime)
setProgress(elapsed / transitionDuration)
if (elapsed < transitionDuration) return
setState(undefined)
transition.done?.()
},
cells,
progress,
}
}
const createSweep = () =>
createTransition((transition, progress) => {
const length = Math.max(transition.from.length, transition.to.length)
const front = smoothstep(progress) * (length + 2 * sweepBlend) - sweepBlend
return Array.from({ length }, (_, index) => {
const passed = Math.max(0, Math.min(1, (front - index) / sweepBlend))
const brightness = smoothstep(Math.abs(passed * 2 - 1))
const cell = (passed >= 0.5 ? transition.to[index] : transition.from[index]) ?? {
char: " ",
color: colors.text,
}
return { ...cell, color: shade(rampFor(cell.color), brightness) }
})
})
const createFade = () =>
createTransition((transition, progress) => {
const entering = progress >= 0.5
const brightness = smoothstep(entering ? progress * 2 - 1 : 1 - progress * 2)
return (entering ? transition.to : transition.from).map((cell) => ({
...cell,
color: shade(rampFor(cell.color), brightness),
}))
})
const smoothstep = (value: number) => value * value * (3 - 2 * value)
const frameDone = Promise.resolve()
function UpdateFooter(props: {
from?: string
active: () => number
outcome: () => "running" | "success" | "failure"
failure: () => string
animating: () => boolean
renderer: CliRenderer
onOutcomeSettled: () => void
}) {
const term = useTerminalDimensions()
const [position, setPosition] = createSignal(0)
const [pulse, setPulse] = createSignal(0)
const headerFade = createFade()
const statusSweep = createSweep()
const runningHeader = () =>
phrase(
["OpenCode", colors.muted, true],
["is updating", colors.muted],
...(props.from
? ([
["from", colors.muted],
[props.from, colors.accentDim],
] as const)
: []),
["to", colors.muted],
[InstallationVersion, colors.accent],
)
const completedHeader = phrase(
["OpenCode", colors.muted, true],
["updated to", colors.muted],
[InstallationVersion, colors.accent],
)
const pausedHeader = phrase(["OpenCode", colors.muted, true], ["update paused", colors.muted])
const outcomeStatus = () =>
props.outcome() === "success"
? [...styled("✓", colors.success), ...styled(" Ready", colors.text)]
: [...styled("!", colors.error), ...styled(" " + props.failure(), colors.text)]
let previousStage: string = stages[0]
createEffect(
on(props.active, (index) => {
if (props.outcome() !== "running") return
const next = stages[index]
if (next === previousStage) return
statusSweep.start(styled(previousStage, colors.text), styled(next, colors.text))
previousStage = next
}),
)
createEffect(
on(
props.outcome,
(outcome) => {
if (outcome === "running") return
const visibleStatus = untrack(statusSweep.cells) ?? styled(previousStage, colors.text)
headerFade.start(runningHeader(), outcome === "success" ? completedHeader : pausedHeader)
statusSweep.start([...styled(" ", colors.text), ...visibleStatus], outcomeStatus(), props.onOutcomeSettled)
},
{ defer: true },
),
)
const header = createMemo(
() =>
headerFade.cells() ??
(props.outcome() === "success"
? completedHeader
: props.outcome() === "failure"
? pausedHeader
: runningHeader()),
)
const monogramInk = createMemo(() =>
props.outcome() === "success" ? shade(monogramRamp, smoothstep(headerFade.progress())) : colors.muted,
)
const rail = createMemo(() => {
const width = Math.max(0, Math.min(30, term().width - 39))
if (width === 0) return []
const filled = Math.round(position() * width)
const glowRadius = 6
const span = Math.max(1, filled + glowRadius * 2)
const center = pulse() * span - glowRadius
const success = props.outcome() === "success"
const completion = smoothstep(headerFade.progress())
return Array.from({ length: width }, (_, index) => {
const color =
index >= filled
? colors.muted
: shade(railRamp, Math.max(0, 1 - Math.abs(index - center) / glowRadius) ** 2)
return {
char: success || index < filled ? "━" : "·",
color: success ? blend(color, colors.accent, completion) : color,
}
})
})
onMount(() => {
let value = 0
let velocity = 0
let phase = 0
const frame = (deltaTime: number) => {
if (!props.animating()) return frameDone
const elapsed = Math.min(0.032, deltaTime / 1_000)
const stiffness = 110
const damping = 2 * Math.sqrt(stiffness)
const target = props.outcome() === "success" ? 1 : (props.active() + 1) / stages.length
velocity += (stiffness * (target - value) - damping * velocity) * elapsed
value += velocity * elapsed
phase = (phase + deltaTime / 900) % 1
batch(() => {
setPosition(Math.max(0, Math.min(1, value)))
setPulse(phase)
})
headerFade.tick(deltaTime)
statusSweep.tick(deltaTime)
return frameDone
}
props.renderer.setFrameCallback(frame)
onCleanup(() => props.renderer.removeFrameCallback(frame))
})
return (
<box width="100%" height={4} flexDirection="row" gap={1} live={props.animating()}>
<Monogram ink={monogramInk} />
<box flexDirection="column" flexGrow={1} overflow="hidden">
<CellLine cells={header()} />
<Show
when={props.outcome() === "running"}
fallback={<CellLine cells={statusSweep.cells() ?? outcomeStatus()} />}
>
<box flexDirection="row" gap={1}>
<spinner frames={SPINNER_FRAMES} interval={80} color={colors.accent} />
<CellLine cells={statusSweep.cells() ?? styled(stages[props.active()], colors.text)} />
</box>
</Show>
<box flexDirection="row" gap={1}>
<CellLine cells={rail()} />
<text fg={colors.muted}>
{props.outcome() === "success" ? stages.length : props.active() + 1}/{stages.length}
</text>
</box>
</box>
</box>
)
}
function CellLine(props: { cells: ReadonlyArray<Cell> }) {
return (
<text truncate>
<Index each={props.cells}>
{(cell) => (
<span
style={{
fg: cell().color,
attributes: cell().bold ? TextAttributes.BOLD : TextAttributes.NONE,
}}
>
{cell().char}
</span>
)}
</Index>
</text>
)
}
export * as UpdatePreflight from "./update-preflight"
+24
View File
@@ -0,0 +1,24 @@
export * as TuiConfig from "./tui-config"
import { Global } from "@opencode-ai/core/global"
import { TuiConfig } from "@opencode-ai/tui/config/v1"
import { Effect, FileSystem, Option, Schema } from "effect"
import { parse, type ParseError } from "jsonc-parser"
import path from "path"
export const load = Effect.fn("TuiConfig.load")(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const filepath = path.join(global.config, "tui.json")
const text = yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!text) return TuiConfig.resolve({}, { terminalSuspend: process.platform !== "win32" })
const errors: ParseError[] = []
const input: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return TuiConfig.resolve({}, { terminalSuspend: process.platform !== "win32" })
return TuiConfig.resolve(
Option.getOrElse(Schema.decodeUnknownOption(TuiConfig.Info)(input), () => ({})),
{ terminalSuspend: process.platform !== "win32" },
)
})
+1 -1
View File
@@ -2,7 +2,7 @@
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { testRender, useRenderer } from "@opentui/solid"
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import { resolve } from "@opencode-ai/tui/config"
import { resolve } from "@opencode-ai/tui/config/v1"
import { expect, test } from "bun:test"
import { createComponent, createSignal } from "solid-js"
import { RunFooterView } from "../src/mini/footer.view"
+25
View File
@@ -0,0 +1,25 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/core/global"
import { Effect } from "effect"
import { expect, test } from "bun:test"
import path from "path"
import { TuiConfig } from "../src/tui-config"
test("loads the global tui config", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
await Bun.write(path.join(directory, "tui.json"), JSON.stringify({ keybinds: { leader: "ctrl+o" } }))
try {
const config = await Effect.runPromise(
TuiConfig.load().pipe(
Effect.provide(Global.layerWith({ config: directory })),
Effect.provide(NodeFileSystem.layer),
),
)
expect(config.keybinds.get("leader")?.[0]?.key).toBe("ctrl+o")
expect(config.keybinds.get("session.new")?.[0]?.key).toBe("<leader>n")
} finally {
await Bun.$`rm -rf ${directory}`
}
})
+7 -8
View File
@@ -406,6 +406,7 @@ export type Endpoint10_2Input = {
readonly integrationID: Endpoint10_2Request["params"]["integrationID"]
readonly location?: Endpoint10_2Request["query"]["location"]
readonly key: Endpoint10_2Request["payload"]["key"]
readonly inputs?: Endpoint10_2Request["payload"]["inputs"]
readonly label?: Endpoint10_2Request["payload"]["label"]
}
export type Endpoint10_2Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.connect.key"]>>
@@ -476,18 +477,16 @@ export interface IntegrationApi<E = never> {
type Endpoint11_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
export type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] }
export type Endpoint11_0Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.list"]>>
export type ServerMcpListOperation<E = never> = (input?: Endpoint11_0Input) => Effect.Effect<Endpoint11_0Output, E>
export type McpListOperation<E = never> = (input?: Endpoint11_0Input) => Effect.Effect<Endpoint11_0Output, E>
type Endpoint11_1Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
export type Endpoint11_1Input = { readonly location?: Endpoint11_1Request["query"]["location"] }
export type Endpoint11_1Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.resource.catalog"]>>
export type ServerMcpResourceCatalogOperation<E = never> = (
input?: Endpoint11_1Input,
) => Effect.Effect<Endpoint11_1Output, E>
export type McpResourceCatalogOperation<E = never> = (input?: Endpoint11_1Input) => Effect.Effect<Endpoint11_1Output, E>
export interface ServerMcpApi<E = never> {
readonly list: ServerMcpListOperation<E>
readonly resource: { readonly catalog: ServerMcpResourceCatalogOperation<E> }
export interface McpApi<E = never> {
readonly list: McpListOperation<E>
readonly resource: { readonly catalog: McpResourceCatalogOperation<E> }
}
type Endpoint12_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
@@ -955,7 +954,7 @@ export interface AppApi<E = never> {
readonly generate: GenerateApi<E>
readonly provider: ProviderApi<E>
readonly integration: IntegrationApi<E>
readonly "server.mcp": ServerMcpApi<E>
readonly mcp: McpApi<E>
readonly credential: CredentialApi<E>
readonly project: ProjectApi<E>
readonly form: FormApi<E>
@@ -504,13 +504,14 @@ type Endpoint10_2Input = {
readonly integrationID: Endpoint10_2Request["params"]["integrationID"]
readonly location?: Endpoint10_2Request["query"]["location"]
readonly key: Endpoint10_2Request["payload"]["key"]
readonly inputs?: Endpoint10_2Request["payload"]["inputs"]
readonly label?: Endpoint10_2Request["payload"]["label"]
}
const Endpoint10_2 = (raw: RawClient["server.integration"]) => (input: Endpoint10_2Input) =>
raw["integration.connect.key"]({
params: { integrationID: input["integrationID"] },
query: { location: input["location"] },
payload: { key: input["key"], label: input["label"] },
payload: { key: input["key"], inputs: input["inputs"], label: input["label"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint10_3Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
@@ -1134,7 +1135,7 @@ const adaptClient = (raw: RawClient) => ({
generate: adaptGroup8(raw["server.generate"]),
provider: adaptGroup9(raw["server.provider"]),
integration: adaptGroup10(raw["server.integration"]),
"server.mcp": adaptGroup11(raw["server.mcp"]),
mcp: adaptGroup11(raw["server.mcp"]),
credential: adaptGroup12(raw["server.credential"]),
project: adaptGroup13(raw["server.project"]),
form: adaptGroup14(raw["server.form"]),
+7 -2
View File
@@ -35,7 +35,10 @@ export type Options = {
export type StartReason = "missing" | "version-mismatch"
export type StartOptions = Options & {
readonly onStart?: (reason: StartReason) => void
// Called once when start() decides it must spawn: either no service was
// found, or a healthy service with a different version is being replaced.
// `existing` carries the registration of the service being replaced.
readonly onStart?: (reason: StartReason, existing?: Info) => void
}
// Read-only lookup: registration file plus health check and version gate.
@@ -57,7 +60,9 @@ export const start = Effect.fn("service.start")(function* (options: StartOptions
const compatible = yield* discover(options)
if (compatible !== undefined) return compatible
const mismatched = yield* find(options)
yield* Effect.sync(() => options.onStart?.(mismatched === undefined ? "missing" : "version-mismatch"))
yield* Effect.sync(() =>
options.onStart?.(mismatched === undefined ? "missing" : "version-mismatch", mismatched?.info),
)
if (mismatched !== undefined) yield* kill(mismatched.info, options).pipe(Effect.ignore)
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
@@ -1,4 +1,9 @@
export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"
export type ClientErrorReason =
| "Transport"
| "UnexpectedStatus"
| "UnsupportedContentType"
| "MalformedResponse"
| "SseEventTooLarge"
export class ClientError extends Error {
override readonly name = "ClientError"
+13 -11
View File
@@ -90,10 +90,10 @@ import type {
IntegrationAttemptCompleteOutput,
IntegrationAttemptCancelInput,
IntegrationAttemptCancelOutput,
ServerMcpListInput,
ServerMcpListOutput,
ServerMcpResourceCatalogInput,
ServerMcpResourceCatalogOutput,
McpListInput,
McpListOutput,
McpResourceCatalogInput,
McpResourceCatalogOutput,
CredentialUpdateInput,
CredentialUpdateOutput,
CredentialRemoveInput,
@@ -213,6 +213,8 @@ interface RequestDescriptor {
readonly binary?: true
}
const maxSseEventBytes = 16 * 1024 * 1024
export function make(options: ClientOptions) {
const fetch = options.fetch ?? globalThis.fetch
@@ -289,7 +291,7 @@ export function make(options: ClientOptions) {
throw new ClientError("Transport", { cause })
}
buffer += decoder.decode(next.value, { stream: !next.done })
if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")
if (buffer.length > maxSseEventBytes) throw new ClientError("SseEventTooLarge")
const trailingCarriageReturn = !next.done && buffer.endsWith("\r")
if (trailingCarriageReturn) buffer = buffer.slice(0, -1)
buffer = buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n")
@@ -878,7 +880,7 @@ export function make(options: ClientOptions) {
method: "POST",
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`,
query: { location: input["location"] },
body: { key: input["key"], label: input["label"] },
body: { key: input["key"], inputs: input["inputs"], label: input["label"] },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
@@ -939,9 +941,9 @@ export function make(options: ClientOptions) {
),
},
},
"server.mcp": {
list: (input?: ServerMcpListInput, requestOptions?: RequestOptions) =>
request<ServerMcpListOutput>(
mcp: {
list: (input?: McpListInput, requestOptions?: RequestOptions) =>
request<McpListOutput>(
{
method: "GET",
path: `/api/mcp`,
@@ -953,8 +955,8 @@ export function make(options: ClientOptions) {
requestOptions,
),
resource: {
catalog: (input?: ServerMcpResourceCatalogInput, requestOptions?: RequestOptions) =>
request<ServerMcpResourceCatalogOutput>(
catalog: (input?: McpResourceCatalogInput, requestOptions?: RequestOptions) =>
request<McpResourceCatalogOutput>(
{
method: "GET",
path: `/api/mcp/resource`,
+167 -135
View File
@@ -130,9 +130,9 @@ export type SessionMessageCompactionCompleted = {
export type InstructionEntryKey = string
export type SessionPendingSyntheticData1 = { text: string; description?: string; metadata?: { [x: string]: unknown } }
export type SessionPendingSyntheticData1 = { text: string; description?: string; metadata?: { [x: string]: any } }
export type Shell = {
export type ShellInfo = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
@@ -141,19 +141,19 @@ export type Shell = {
file: string
pid?: number
exit?: number
metadata: { [x: string]: unknown }
metadata: { [x: string]: any }
time: { started: number; completed?: number }
}
export type SessionMessageProviderState3 = { [x: string]: unknown }
export type SessionMessageProviderState3 = { [x: string]: any }
export type SessionMessageProviderState4 = { [x: string]: unknown }
export type SessionMessageProviderState4 = { [x: string]: any }
export type SessionMessageProviderState5 = { [x: string]: unknown }
export type SessionMessageProviderState5 = { [x: string]: any }
export type SessionMessageProviderState6 = { [x: string]: unknown }
export type SessionMessageProviderState6 = { [x: string]: any }
export type SessionMessageProviderState7 = { [x: string]: unknown }
export type SessionMessageProviderState7 = { [x: string]: any }
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
@@ -183,8 +183,6 @@ export type ProviderV2Info = {
export type IntegrationWhen = { key: string; op: "eq" | "neq"; value: string }
export type IntegrationKeyMethod = { type: "key"; label?: string }
export type IntegrationEnvMethod = { type: "env"; names: Array<string> }
export type ConnectionCredentialInfo = { type: "credential"; id: string; label: string }
@@ -445,7 +443,7 @@ export type QuestionV2Tool = { messageID: string; callID: string }
export type QuestionV2Answer = Array<string>
export type FormMetadata1 = { [x: string]: unknown }
export type FormMetadata1 = { [x: string]: any }
export type FormWhen1 = { key: string; op: "eq" | "neq"; value: string | number | boolean }
@@ -466,7 +464,7 @@ export type QuestionTool = { messageID: string; callID: string }
export type QuestionAnswer = Array<string>
export type Shell1 = {
export type ShellInfo1 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
@@ -474,9 +472,9 @@ export type Shell1 = {
shell: string
file: string
pid?: number
exit?: number | "Infinity" | "-Infinity" | "NaN"
exit?: number
metadata: { [x: string]: JsonValue }
time: { started: number | "Infinity" | "-Infinity" | "NaN"; completed?: number | "Infinity" | "-Infinity" | "NaN" }
time: { started: number; completed?: number }
}
export type ReferenceLocalSource = { type: "local"; path: string; description?: string; hidden?: boolean }
@@ -527,7 +525,7 @@ export type PermissionV2Rule = { action: string; resource: string; effect: Permi
export type SessionAgentSelected = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.agent.selected"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -537,7 +535,7 @@ export type SessionAgentSelected = {
export type SessionModelSelected = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.model.selected"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -547,7 +545,7 @@ export type SessionModelSelected = {
export type SessionMoved = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.moved"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -557,7 +555,7 @@ export type SessionMoved = {
export type SessionRenamed = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.renamed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -567,7 +565,7 @@ export type SessionRenamed = {
export type SessionDeleted = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.deleted"
durable: { aggregateID: string; seq: number; version: 2 }
location?: LocationRef
@@ -577,7 +575,7 @@ export type SessionDeleted = {
export type SessionForked = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.forked"
durable: { aggregateID: string; seq: number; version: 2 }
location?: LocationRef
@@ -587,7 +585,7 @@ export type SessionForked = {
export type SessionInputPromoted = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.input.promoted"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -597,7 +595,7 @@ export type SessionInputPromoted = {
export type SessionExecutionStarted = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.execution.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -607,7 +605,7 @@ export type SessionExecutionStarted = {
export type SessionExecutionSucceeded = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.execution.succeeded"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -617,7 +615,7 @@ export type SessionExecutionSucceeded = {
export type SessionExecutionInterrupted = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.execution.interrupted"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -627,7 +625,7 @@ export type SessionExecutionInterrupted = {
export type SessionInstructionsUpdated = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.instructions.updated"
durable: { aggregateID: string; seq: number; version: 2 }
location?: LocationRef
@@ -637,17 +635,17 @@ export type SessionInstructionsUpdated = {
export type SessionSynthetic = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.synthetic"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; text: string; description?: string; metadata?: { [x: string]: unknown } }
data: { sessionID: string; text: string; description?: string; metadata?: { [x: string]: any } }
}
export type SessionSkillActivated = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.skill.activated"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -657,7 +655,7 @@ export type SessionSkillActivated = {
export type SessionStepStarted = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.step.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -667,7 +665,7 @@ export type SessionStepStarted = {
export type SessionStepEnded = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.step.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -685,7 +683,7 @@ export type SessionStepEnded = {
export type SessionTextStarted = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.text.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -695,7 +693,7 @@ export type SessionTextStarted = {
export type SessionTextEnded = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.text.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -705,7 +703,7 @@ export type SessionTextEnded = {
export type SessionToolInputStarted = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.tool.input.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -715,7 +713,7 @@ export type SessionToolInputStarted = {
export type SessionToolInputEnded = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.tool.input.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -725,7 +723,7 @@ export type SessionToolInputEnded = {
export type SessionCompactionAdmitted = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.compaction.admitted"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -735,7 +733,7 @@ export type SessionCompactionAdmitted = {
export type SessionCompactionStarted = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.compaction.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -745,7 +743,7 @@ export type SessionCompactionStarted = {
export type SessionCompactionEnded = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -755,7 +753,7 @@ export type SessionCompactionEnded = {
export type SessionRevertCleared = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.revert.cleared"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -765,7 +763,7 @@ export type SessionRevertCleared = {
export type SessionRevertCommitted = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.revert.committed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -775,7 +773,7 @@ export type SessionRevertCommitted = {
export type ModelsDevRefreshed = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "models-dev.refreshed"
location?: LocationRef
data: {}
@@ -784,7 +782,7 @@ export type ModelsDevRefreshed = {
export type IntegrationUpdated = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "integration.updated"
location?: LocationRef
data: {}
@@ -793,7 +791,7 @@ export type IntegrationUpdated = {
export type IntegrationConnectionUpdated = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "integration.connection.updated"
location?: LocationRef
data: { integrationID: string }
@@ -802,7 +800,7 @@ export type IntegrationConnectionUpdated = {
export type CatalogUpdated = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "catalog.updated"
location?: LocationRef
data: {}
@@ -811,7 +809,7 @@ export type CatalogUpdated = {
export type AgentUpdated = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "agent.updated"
location?: LocationRef
data: {}
@@ -820,7 +818,7 @@ export type AgentUpdated = {
export type MessageRemoved = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "message.removed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -830,7 +828,7 @@ export type MessageRemoved = {
export type MessagePartRemoved = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "message.part.removed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -840,7 +838,7 @@ export type MessagePartRemoved = {
export type SessionUsageUpdated = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.usage.updated"
location?: LocationRef
data: { sessionID: string; cost: MoneyUSD; tokens: TokenUsageInfo }
@@ -849,7 +847,7 @@ export type SessionUsageUpdated = {
export type SessionTextDelta = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.text.delta"
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; ordinal: number; delta: string }
@@ -858,7 +856,7 @@ export type SessionTextDelta = {
export type SessionReasoningDelta = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.reasoning.delta"
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; ordinal: number; delta: string }
@@ -867,7 +865,7 @@ export type SessionReasoningDelta = {
export type SessionToolInputDelta = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.tool.input.delta"
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; callID: string; delta: string }
@@ -876,7 +874,7 @@ export type SessionToolInputDelta = {
export type SessionCompactionDelta = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.compaction.delta"
location?: LocationRef
data: { sessionID: string; text: string }
@@ -885,7 +883,7 @@ export type SessionCompactionDelta = {
export type FilesystemChanged = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "filesystem.changed"
location?: LocationRef
data: { file: string; event: "add" | "change" | "unlink" }
@@ -894,7 +892,7 @@ export type FilesystemChanged = {
export type ReferenceUpdated = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "reference.updated"
location?: LocationRef
data: {}
@@ -903,7 +901,7 @@ export type ReferenceUpdated = {
export type PluginAdded = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "plugin.added"
location?: LocationRef
data: { id: string }
@@ -912,7 +910,7 @@ export type PluginAdded = {
export type PluginUpdated = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "plugin.updated"
location?: LocationRef
data: {}
@@ -921,7 +919,7 @@ export type PluginUpdated = {
export type ProjectDirectoriesUpdated = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "project.directories.updated"
location?: LocationRef
data: { projectID: string }
@@ -930,7 +928,7 @@ export type ProjectDirectoriesUpdated = {
export type CommandUpdated = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "command.updated"
location?: LocationRef
data: {}
@@ -939,7 +937,7 @@ export type CommandUpdated = {
export type ConfigUpdated = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "config.updated"
location?: LocationRef
data: {}
@@ -948,7 +946,7 @@ export type ConfigUpdated = {
export type SkillUpdated = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "skill.updated"
location?: LocationRef
data: {}
@@ -957,7 +955,7 @@ export type SkillUpdated = {
export type PtyExited = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "pty.exited"
location?: LocationRef
data: { id: string; exitCode: number }
@@ -966,7 +964,7 @@ export type PtyExited = {
export type PtyDeleted = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "pty.deleted"
location?: LocationRef
data: { id: string }
@@ -975,7 +973,7 @@ export type PtyDeleted = {
export type ShellExited = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "shell.exited"
location?: LocationRef
data: { id: string; exit?: number; status: "running" | "exited" | "timeout" | "killed" }
@@ -984,7 +982,7 @@ export type ShellExited = {
export type ShellDeleted = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "shell.deleted"
location?: LocationRef
data: { id: string }
@@ -993,7 +991,7 @@ export type ShellDeleted = {
export type QuestionV2Rejected = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "question.v2.rejected"
location?: LocationRef
data: { sessionID: string; requestID: string }
@@ -1002,7 +1000,7 @@ export type QuestionV2Rejected = {
export type FormCancelled = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "form.cancelled"
location?: LocationRef
data: { id: string; sessionID: string }
@@ -1011,7 +1009,7 @@ export type FormCancelled = {
export type SessionIdle = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.idle"
location?: LocationRef
data: { sessionID: string }
@@ -1020,7 +1018,7 @@ export type SessionIdle = {
export type TuiPromptAppend = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "tui.prompt.append"
location?: LocationRef
data: { text: string }
@@ -1029,7 +1027,7 @@ export type TuiPromptAppend = {
export type TuiCommandExecute = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "tui.command.execute"
location?: LocationRef
data: {
@@ -1058,7 +1056,7 @@ export type TuiCommandExecute = {
export type TuiToastShow = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "tui.toast.show"
location?: LocationRef
data: {
@@ -1072,7 +1070,7 @@ export type TuiToastShow = {
export type TuiSessionSelect = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "tui.session.select"
location?: LocationRef
data: { sessionID: string }
@@ -1081,7 +1079,7 @@ export type TuiSessionSelect = {
export type InstallationUpdated = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "installation.updated"
location?: LocationRef
data: { version: string }
@@ -1090,7 +1088,7 @@ export type InstallationUpdated = {
export type InstallationUpdateAvailable = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "installation.update-available"
location?: LocationRef
data: { version: string }
@@ -1099,7 +1097,7 @@ export type InstallationUpdateAvailable = {
export type VcsBranchUpdated = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "vcs.branch.updated"
location?: LocationRef
data: { branch?: string }
@@ -1108,7 +1106,7 @@ export type VcsBranchUpdated = {
export type McpStatusChanged = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "mcp.status.changed"
location?: LocationRef
data: { server: string }
@@ -1117,7 +1115,7 @@ export type McpStatusChanged = {
export type McpResourcesChanged = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "mcp.resources.changed"
location?: LocationRef
data: { server: string }
@@ -1126,7 +1124,7 @@ export type McpResourcesChanged = {
export type PermissionAsked = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "permission.asked"
location?: LocationRef
data: {
@@ -1134,7 +1132,7 @@ export type PermissionAsked = {
sessionID: string
permission: string
patterns: Array<string>
metadata: { [x: string]: unknown }
metadata: { [x: string]: any }
always: Array<string>
tool?: { messageID: string; callID: string } | undefined
}
@@ -1143,7 +1141,7 @@ export type PermissionAsked = {
export type PermissionReplied = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "permission.replied"
location?: LocationRef
data: { sessionID: string; requestID: string; reply: "once" | "always" | "reject" }
@@ -1152,7 +1150,7 @@ export type PermissionReplied = {
export type QuestionRejected = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "question.rejected"
location?: LocationRef
data: { sessionID: string; requestID: string }
@@ -1160,7 +1158,7 @@ export type QuestionRejected = {
export type V2EventServerConnected = {
id: string
metadata?: { [x: string]: unknown } | undefined
metadata?: { [x: string]: any } | undefined
location?: LocationRef | undefined
type: "server.connected"
data: {}
@@ -1213,7 +1211,7 @@ export type SessionMessageCompactionFailed = {
export type SessionExecutionFailed = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.execution.failed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -1223,7 +1221,7 @@ export type SessionExecutionFailed = {
export type SessionStepFailed = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.step.failed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -1239,7 +1237,7 @@ export type SessionStepFailed = {
export type SessionRetryScheduled = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.retry.scheduled"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -1249,7 +1247,7 @@ export type SessionRetryScheduled = {
export type SessionCompactionFailed = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.compaction.failed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -1267,23 +1265,23 @@ export type SessionPendingSyntheticMessage = {
export type SessionShellStarted = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.shell.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; shell: Shell }
data: { sessionID: string; shell: ShellInfo }
}
export type SessionShellEnded = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.shell.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
shell: Shell
shell: ShellInfo
output: { output: string; cursor: number; size: number; truncated: boolean }
}
}
@@ -1291,16 +1289,16 @@ export type SessionShellEnded = {
export type ShellCreated = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "shell.created"
location?: LocationRef
data: { info: Shell }
data: { info: ShellInfo }
}
export type SessionReasoningStarted = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.reasoning.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -1310,7 +1308,7 @@ export type SessionReasoningStarted = {
export type SessionReasoningEnded = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.reasoning.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -1326,7 +1324,7 @@ export type SessionReasoningEnded = {
export type SessionToolCalled = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.tool.called"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -1334,7 +1332,7 @@ export type SessionToolCalled = {
sessionID: string
assistantMessageID: string
callID: string
input: { [x: string]: unknown }
input: { [x: string]: any }
executed: boolean
state?: SessionMessageProviderState5
}
@@ -1343,7 +1341,7 @@ export type SessionToolCalled = {
export type SessionToolFailed = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.tool.failed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -1352,7 +1350,7 @@ export type SessionToolFailed = {
assistantMessageID: string
callID: string
error: SessionStructuredError
result?: unknown
result?: any
executed: boolean
resultState?: SessionMessageProviderState7
}
@@ -1490,7 +1488,7 @@ export type PermissionV2Request = {
export type PermissionV2Asked = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "permission.v2.asked"
location?: LocationRef
data: {
@@ -1499,7 +1497,7 @@ export type PermissionV2Asked = {
action: string
resources: Array<string>
save?: Array<string>
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
source?: PermissionV2Source
}
}
@@ -1558,7 +1556,7 @@ export type RetryPart = {
export type SessionError = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.error"
location?: LocationRef
data: {
@@ -1592,7 +1590,7 @@ export type SymbolSource = {
export type PermissionV2Replied = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "permission.v2.replied"
location?: LocationRef
data: { sessionID: string; requestID: string; reply: PermissionV2Reply }
@@ -1601,7 +1599,7 @@ export type PermissionV2Replied = {
export type PtyCreated = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "pty.created"
location?: LocationRef
data: { info: Pty }
@@ -1610,7 +1608,7 @@ export type PtyCreated = {
export type PtyUpdated = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "pty.updated"
location?: LocationRef
data: { info: Pty }
@@ -1627,7 +1625,7 @@ export type QuestionV2Info = {
export type QuestionV2Replied = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "question.v2.replied"
location?: LocationRef
data: { sessionID: string; requestID: string; answers: Array<QuestionV2Answer> }
@@ -1701,7 +1699,7 @@ export type FormMultiselectField1 = {
export type SessionStatus2 = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.status"
location?: LocationRef
data: { sessionID: string; status: SessionStatus }
@@ -1718,7 +1716,7 @@ export type QuestionInfo = {
export type QuestionReplied = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "question.replied"
location?: LocationRef
data: { sessionID: string; requestID: string; answers: Array<QuestionAnswer> }
@@ -1747,7 +1745,7 @@ export type SessionInfo = {
export type SessionRevertStaged = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.revert.staged"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -1775,7 +1773,7 @@ export type SessionPendingUserData1 = {
text: string
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
}
export type SessionMessageToolStateRunning = {
@@ -1805,7 +1803,7 @@ export type SessionMessageToolStateError = {
export type SessionToolProgress = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.tool.progress"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -1813,7 +1811,7 @@ export type SessionToolProgress = {
sessionID: string
assistantMessageID: string
callID: string
structured: { [x: string]: unknown }
structured: { [x: string]: any }
content: Array<LLMToolContent>
}
}
@@ -1821,7 +1819,7 @@ export type SessionToolProgress = {
export type SessionToolSuccess = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.tool.success"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -1829,9 +1827,9 @@ export type SessionToolSuccess = {
sessionID: string
assistantMessageID: string
callID: string
structured: { [x: string]: unknown }
structured: { [x: string]: any }
content: Array<LLMToolContent>
result?: unknown
result?: any
executed: boolean
resultState?: SessionMessageProviderState6
}
@@ -1868,6 +1866,12 @@ export type IntegrationOAuthMethod = {
prompts?: Array<IntegrationTextPrompt | IntegrationSelectPrompt>
}
export type IntegrationKeyMethod = {
type: "key"
label?: string
prompts?: Array<IntegrationTextPrompt | IntegrationSelectPrompt>
}
export type FormField =
| FormStringField
| FormNumberField
@@ -1881,7 +1885,7 @@ export type FormState = { status: "pending" } | { status: "answered"; answer: Fo
export type FormReplied = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "form.replied"
location?: LocationRef
data: { id: string; sessionID: string; answer: FormAnswer }
@@ -1907,7 +1911,7 @@ export type FilePartSource = FileSource | SymbolSource | ResourceSource
export type QuestionV2Asked = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "question.v2.asked"
location?: LocationRef
data: { id: string; sessionID: string; questions: Array<QuestionV2Info>; tool?: QuestionV2Tool }
@@ -1931,12 +1935,20 @@ export type FormField1 =
export type QuestionAsked = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "question.asked"
location?: LocationRef
data: { id: string; sessionID: string; questions: Array<QuestionInfo>; tool?: QuestionTool | undefined }
}
export type ReferenceInfo = {
name: string
path: string
description?: string
hidden?: boolean
source: ReferenceSource
}
export type AgentInfo = {
id: string
name: string
@@ -2041,12 +2053,19 @@ export type SessionMessageAssistant = {
retry?: SessionMessageAssistantRetry
}
export type IntegrationInfo = {
id: string
name: string
methods: Array<IntegrationMethod>
connections: Array<ConnectionInfo>
}
export type FormInfo = { id: string; sessionID: string; title: string; metadata?: FormMetadata; fields: FormFields }
export type SessionCreated = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.created"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -2056,7 +2075,7 @@ export type SessionCreated = {
export type SessionUpdated = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.updated"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -2066,7 +2085,7 @@ export type SessionUpdated = {
export type SessionDeleted1 = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.deleted"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -2076,7 +2095,7 @@ export type SessionDeleted1 = {
export type MessageUpdated = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "message.updated"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -2098,7 +2117,7 @@ export type FormInfo1 = { id: string; sessionID: string; title: string; metadata
export type SessionInputAdmitted = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "session.input.admitted"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -2121,7 +2140,7 @@ export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted
export type FormCreated = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "form.created"
location?: LocationRef
data: { form: FormInfo1 }
@@ -2202,7 +2221,7 @@ export type Part =
export type MessagePartUpdated = {
id: string
created: number
metadata?: { [x: string]: unknown }
metadata?: { [x: string]: any }
type: "message.part.updated"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
@@ -3241,7 +3260,7 @@ export type IntegrationListInput = {
export type IntegrationListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<{ id: string; name: string; methods: Array<IntegrationMethod>; connections: Array<ConnectionInfo> }>
data: Array<IntegrationInfo>
}
export type IntegrationGetInput = {
@@ -3253,7 +3272,7 @@ export type IntegrationGetInput = {
export type IntegrationGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: { id: string; name: string; methods: Array<IntegrationMethod>; connections: Array<ConnectionInfo> } | null
data: IntegrationInfo | null
}
export type IntegrationConnectKeyInput = {
@@ -3261,8 +3280,21 @@ export type IntegrationConnectKeyInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly key: { readonly key: string; readonly label?: string | undefined }["key"]
readonly label?: { readonly key: string; readonly label?: string | undefined }["label"]
readonly key: {
readonly key: string
readonly inputs?: { readonly [x: string]: string } | undefined
readonly label?: string | undefined
}["key"]
readonly inputs?: {
readonly key: string
readonly inputs?: { readonly [x: string]: string } | undefined
readonly label?: string | undefined
}["inputs"]
readonly label?: {
readonly key: string
readonly inputs?: { readonly [x: string]: string } | undefined
readonly label?: string | undefined
}["label"]
}
export type IntegrationConnectKeyOutput = void
@@ -3331,24 +3363,24 @@ export type IntegrationAttemptCancelInput = {
export type IntegrationAttemptCancelOutput = void
export type ServerMcpListInput = {
export type McpListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ServerMcpListOutput = {
export type McpListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<McpServer>
}
export type ServerMcpResourceCatalogInput = {
export type McpResourceCatalogInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ServerMcpResourceCatalogOutput = {
export type McpResourceCatalogOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: McpResourceCatalog
}
@@ -4539,7 +4571,7 @@ export type ShellListInput = {
export type ShellListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<Shell1>
data: Array<ShellInfo1>
}
export type ShellCreateInput = {
@@ -4574,7 +4606,7 @@ export type ShellCreateInput = {
export type ShellCreateOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Shell1
data: ShellInfo1
}
export type ShellGetInput = {
@@ -4586,7 +4618,7 @@ export type ShellGetInput = {
export type ShellGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Shell1
data: ShellInfo1
}
export type ShellTimeoutInput = {
@@ -4599,7 +4631,7 @@ export type ShellTimeoutInput = {
export type ShellTimeoutOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Shell1
data: ShellInfo1
}
export type ShellOutputInput = {
@@ -4673,7 +4705,7 @@ export type ReferenceListInput = {
export type ReferenceListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<{ name: string; path: string; description?: string; hidden?: boolean; source: ReferenceSource }>
data: Array<ReferenceInfo>
}
export type ProjectCopyCreateInput = {
+37
View File
@@ -284,6 +284,43 @@ test("event.subscribe terminates on malformed Promise SSE data", async () => {
})
})
test("event.subscribe accepts a fragmented SSE event below the size limit", async () => {
const event = { id: "evt_large", type: "test.large", data: { output: "x".repeat(12 * 1024 * 1024) } }
const encoded = new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`)
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
new Response(
new ReadableStream({
start(controller) {
for (let offset = 0; offset < encoded.length; offset += 64 * 1024) {
controller.enqueue(encoded.slice(offset, offset + 64 * 1024))
}
controller.close()
},
}),
{ headers: { "content-type": "text/event-stream" } },
),
})
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).resolves.toEqual({ done: false, value: event })
})
test("event.subscribe rejects an SSE event above the size limit", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
new Response(`data: ${JSON.stringify({ output: "x".repeat(16 * 1024 * 1024) })}`, {
headers: { "content-type": "text/event-stream" },
}),
})
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({
name: "ClientError",
reason: "SseEventTooLarge",
})
})
test("session methods use the public HTTP contract", async () => {
const requests: Array<{ url: string; init?: RequestInit }> = []
const client = OpenCode.make({
+92 -261
View File
@@ -1,37 +1,40 @@
# @opencode-ai/codemode
Effect-native confined code execution over explicit, schema-described tools.
This is our take on code mode. Programs are written in a lightweight, JavaScript-like DSL and run in the package's
own interpreter. They never execute as actual JavaScript, so there is no runtime to escape into. The interpreter
itself can reach nothing; every effect a program has goes through a tool you explicitly supplied. The tradeoff is a
bounded language rather than full JavaScript: the [interpreter support checklist](./interpreter-support.md) documents
exactly what is supported.
CodeMode lets a model write a small JavaScript program that can call only the tools supplied by the host. The program can sequence calls, transform plain data, branch, loop, and run independent calls in parallel without receiving ambient filesystem, process, network, module, or application authority.
[Cloudflare's post](https://blog.cloudflare.com/code-mode/) introduced the idea. Their implementation executes
generated code in isolate sandboxes. We took a lighter route: a pure interpreter that runs wherever your application
runs, no sandbox required.
The package is currently private to this workspace. Its API is designed around one-shot and reusable execution:
## How it differs from JavaScript
```ts
// One execution
yield * CodeMode.execute({ tools, code })
The deliberate differences:
// A reusable runtime
const runtime = CodeMode.make({ tools, limits })
yield * runtime.execute(code)
```
- **No ambient authority.** No `fetch`, `process`, filesystem, timers, or host globals - only the allowlisted standard
library and the `tools` tree.
- **No dynamic code.** No `eval`, `Function`, or module loading.
- **Plain-data boundaries.** Tool arguments and program results are JSON-like data. Dates become ISO strings, RegExp,
Map, and Set serialize as `{}`, and promises, functions, and runtime references cannot cross the boundary.
- **Eager, supervised promises.** Tool calls and async functions start immediately when called. Whatever is still
running when the program returns is interrupted - race losers and fire-and-forget calls alike - so a program must
await every call whose completion matters. Rejections that settle un-awaited become `warnings` on the result instead
of crashing the run.
- **REPL-style results.** An omitted `return` yields the final top-level expression; `undefined` normalizes to `null`.
## Install
Within this workspace:
```json
{
"dependencies": {
"@opencode-ai/codemode": "workspace:*"
}
}
```
Hosts interact with CodeMode through `effect` (tool `run` implementations, `Effect`-typed results), so they should depend on `effect` themselves.
Beyond these, the language is a growing subset rather than a divergent one: unsupported syntax returns an
`UnsupportedSyntax` diagnostic with a source location, and current gaps (for example thenable assimilation, classes,
generators, and full sparse-array parity) are tracked as unchecked items in the
[interpreter support checklist](./interpreter-support.md).
## Quick Start
Define tools with Effect Schema, then place them in the object tree exposed to programs as `tools`:
The package is workspace-private (`"@opencode-ai/codemode": "workspace:*"`). Hosts interact with it through `effect`
and should depend on `effect` themselves. Define tools with Effect Schema, then place them in the object tree exposed
to programs as `tools`:
```ts
import { CodeMode, Tool } from "@opencode-ai/codemode"
@@ -60,69 +63,53 @@ const result =
`)
```
`result` is always a `CodeMode.Result`. Program, validation, limit, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption.
Successful result values are JSON-safe data. An explicit `return` produces the program result; when it is omitted, the final executable top-level expression is returned as a model-friendly REPL convenience. Otherwise reaching the end produces `null`. Returned `undefined` and nested `undefined` values are normalized to `null` as well.
`result` is always a `CodeMode.Result`. Program, validation, limit, and tool failures are returned as diagnostics
rather than failing the Effect; host interruption remains interruption.
## API
### `Tool.make`
```ts
const tool = Tool.make({
description,
input, // Effect Schema (validating) or JSON Schema (render-only)
output, // optional; same choice
run,
})
```
`input` and `output` each accept a validating Effect Schema or a render-only JSON Schema document. Effect Schema input
is decoded before `run` is invoked; an Effect Schema `output` is decoded and copied before the program sees it. JSON
Schemas only shape the model-visible signature. Without `output` the signature advertises `Promise<unknown>`.
Descriptions and schemas are model-visible contract; keep authorization in `run`.
`input` and `output` each accept a validating Effect Schema or a render-only JSON Schema document (the natural shape for adapter-provided tools whose schemas arrive as JSON Schema, e.g. MCP definitions). Effect Schema input is decoded before `run` is invoked, and `run` returns the encoded representation of an Effect Schema `output`, which CodeMode decodes and copies before exposing it to the program. JSON Schemas only shape the model-visible signature; values pass through unvalidated (they still cross the plain-data boundary).
### `CodeMode.execute` and `CodeMode.make`
`output` is optional. Without it the tool's signature advertises `Promise<unknown>` and the host result is exposed as-is.
The description and schemas are part of the model-visible tool contract. Keep descriptions concrete and put authorization in `run` or in the service it calls.
Public tool types are grouped under the same namespace: `Tool.Definition`, `Tool.Options`, `Tool.SchemaType`, and `Tool.JsonSchema`.
### `CodeMode.execute`
Use `CodeMode.execute` for a single execution:
`CodeMode.execute({ ...options, code })` runs once and is equivalent to `CodeMode.make(options).execute(code)`. A
runtime from `make` reuses the tool set and policy:
```ts
const result =
yield *
CodeMode.execute({
tools: { orders: { lookup: lookupOrder } },
code: `return await tools.orders.lookup({ id: "order_42" })`,
limits: { maxToolCalls: 10 },
onToolCallStart: (call) => Effect.logDebug("CodeMode tool started", call),
onToolCallEnd: (call) => Effect.logDebug("CodeMode tool settled", call),
})
```
The Effect environment is inferred from the supplied tools. CodeMode does not erase service requirements introduced by tool implementations.
### `CodeMode.make`
Use `CodeMode.make` when the tool set and execution policy are reused:
```ts
const runtime = CodeMode.make({
tools: { orders: { lookup: lookupOrder } },
limits: { timeoutMs: 30_000 },
})
const runtime = CodeMode.make({ tools, limits: { timeoutMs: 30_000 } })
runtime.catalog() // structured tool descriptions
runtime.instructions() // model-facing syntax and tool guide
runtime.execute(source) // CodeMode.Result
```
`CodeMode.Input`, `CodeMode.Result`, `CodeMode.Success`, `CodeMode.Failure`, `CodeMode.Diagnostic`, and `CodeMode.DiagnosticKind` are both Effect schemas and their inferred TypeScript types. Hosts can combine `CodeMode.Input` and `CodeMode.Result` with `runtime.instructions()` and `runtime.execute()` when constructing a framework-specific agent tool.
The Effect environment is inferred from the supplied tools; service requirements are not erased. Optional
`onToolCallStart` / `onToolCallEnd` hooks observe admitted calls with decoded input, outcome, and duration; both are
Effect-returning and must not fail.
All other CodeMode types use the same namespace: `CodeMode.Options`, `CodeMode.ExecuteOptions`, `CodeMode.Runtime`, `CodeMode.ExecutionLimits`, `CodeMode.DiscoveryOptions`, `CodeMode.DataValue`, `CodeMode.ToolDescription`, and the `CodeMode.ToolCall*` observation types.
### OpenAPI tools
### Results
`OpenAPI.fromSpec` turns an OpenAPI 3.x document into a tool subtree - one tool per operation, namespaced by dotted
`operationId`:
```ts
const api = OpenAPI.fromSpec({ spec, auth: { resolve } })
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. See the option docstrings in
`src/openapi/types.ts` for full semantics.
## Outputs
Every execution returns a `CodeMode.Result`:
```ts
type Result = Success | Failure
@@ -145,152 +132,11 @@ interface Failure {
}
```
`toolCalls` contains the names of calls admitted by the runtime in call order. It is retained on failure so hosts can audit partial execution without exposing inputs or host failures. A successful execution may also contain `warnings`: runtime-authored, non-fatal diagnostics alongside a valid value - unhandled rejections from promises that failed, un-awaited, before the program returned, or background work interrupted by the timeout after the program returned. Anything still running when the program returns is interrupted - race losers and fire-and-forget calls alike - so a program must await every call whose completion matters. Failure has an `error`; success may have `warnings`; program-authored console output stays in `logs`. Keeping the value on an unhandled rejection is a deliberate divergence from Node's crash-on-unhandled-rejection default: the computed value and the background failure are independently useful to the model, so the result carries both. When warnings are cut by `maxOutputBytes`, a final `Truncated` diagnostic marks the omission in-band, and `truncated` marks any result, warning, or log truncation (see Execution Limits).
`value` is JSON-safe data. `warnings` are non-fatal diagnostics alongside a valid value (un-awaited rejections,
timeout cleanup after the return). `logs` holds program console output, `truncated` marks any output-budget cut, and
`toolCalls` lists admitted calls in order - retained on failure for auditing.
### Tool-call hooks
`onToolCallStart` receives `{ index, name, input }` after input decoding and before tool execution. The input is decoded host-side data and may include values produced by schema transformations; applications should avoid logging sensitive tool arguments indiscriminately.
`onToolCallEnd` receives `{ index, name, input, durationMs, outcome, message? }` when an admitted call settles. `outcome` is `"success"` or `"failure"`; `message` is the model-safe failure message and is present only on failure. Interrupted calls (for example when the execution timeout fires) do not produce an end event. Both hooks are Effect-returning and must not fail.
### OpenAPI tools
`OpenAPI.fromSpec` turns an OpenAPI 3.x document into a tool subtree - one tool per operation. Dotted `operationId` values form namespaces such as `v2.session.get`. Missing IDs receive a flat method/path fallback such as `getUsersById`; names are sanitized and deduplicated. The host places the subtree under a key in its `tools` tree; that key is the model-visible namespace.
```ts
import { CodeMode, OpenAPI } from "@opencode-ai/codemode"
import { Effect } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
const api = OpenAPI.fromSpec({
spec: await Bun.file("openapi.json").json(), // parsed document (no YAML)
auth: {
resolve: ({ name, scopes, operation }) =>
name === "BearerAuth" ? Effect.succeed({ type: "bearer", token }) : Effect.succeed(undefined),
},
})
const runtime = CodeMode.make({ tools: { opencode: api.tools } })
const result = await Effect.runPromise(runtime.execute(code).pipe(Effect.provide(FetchHttpClient.layer)))
```
`fromSpec` is synchronous and returns `{ tools, skipped }`. The initial adapter supports query `form`/`deepObject`, path/header `simple`, JSON request bodies, JSON responses, and text responses; unsupported parameter encodings, non-JSON request bodies, binary responses, and streaming operations land in `skipped` instead of producing broken tools. Operation and path servers take precedence over document servers unless `baseUrl` explicitly overrides all of them. Tool inputs flatten path, query, header, and closed object-body fields into one model-facing object while retaining their HTTP locations internally. Cross-location name collisions receive a location prefix such as `path_id` and `query_id`; composed, nullable, dictionary, conditionally-required, and non-object JSON bodies remain under `body`. Auth is never model-visible. Responses are limited to 50 MiB, and non-2xx responses become safe tool failures carrying the status and a size-capped body summary. Deferred capabilities are tracked in `src/openapi/TODO.md`.
Supported bearer, basic, header, and query authentication follows OpenAPI `security` semantics and is resolved host-side via `auth.resolve` - credential storage, OAuth flows, and token refresh never enter the compiler. Cookie authentication alternatives are discarded; an operation is skipped when it has no supported alternative. See the option docstrings in `src/openapi/types.ts` for the full semantics. Generated tools require `HttpClient.HttpClient` (from `effect/unstable/http`) in the Effect environment - provide `FetchHttpClient.layer` or a custom/test client layer at execution. The supplied client owns redirect policy; credentialed hosts should reject redirects or strip credentials when the origin changes.
## Discovery
The agent-tool instructions use a budgeted catalog. Every tool namespace is always listed with its tool count regardless of budget, and as many complete, JSDoc-annotated tool signatures (each with a one-line description) as fit an estimated-token budget are inlined. Schema field descriptions and tags are part of each signature's measured cost. Selection is round-robin across namespaces for fairness: in each round (namespaces alphabetical), every namespace still holding un-inlined tools attempts to place its next-cheapest signature against the shared budget, and a namespace whose next signature does not fit drops out while the others keep going - so every namespace gets some representation before any namespace gets everything. The instructions state exactly how comprehensive the list is, both overall (`COMPLETE list` vs `PARTIAL - N of M shown`) and per namespace (`(3 tools)`, `(3 tools, 1 shown)`, `(3 tools, none shown)`).
The catalog-entry budget defaults to 2,000 estimated tokens (characters / 4, the same heuristic OpenCode uses). It applies only to full tool entries shown in the catalog; fixed instructions and namespace summaries are not counted. Override it when constructing a runtime:
```ts
const runtime = CodeMode.make({
tools,
discovery: { catalogBudget: 6_000 },
})
```
The budget must be a non-negative safe integer.
The runtime search tool is always registered - including when the catalog is fully inlined - so a speculative `tools.$codemode.search` call never fails as an unknown tool. It is only advertised in the instructions when the inlined list is partial:
```ts
const matches = await tools.$codemode.search({
query: "order status",
namespace: "orders", // optional: scope to one top-level namespace
limit: 10,
offset: 0,
})
```
`search` performs deterministic, additive field-weighted matching. The query is tokenized (camelCase boundaries split; every non-alphanumeric character is a separator; empties and `*` are dropped), and each term scores every tool: exact path or path-segment match (20), path substring (8), description substring (4), and searchable-text substring (2). Each term also carries naive singular variants (trailing `s`/`es` stripped), and a field check passes when the term or any variant matches - so a plural query term (`issues`) still finds a tool whose text only says `issue`, without changing the weights. The searchable text also includes the input schema's property names and their description strings, so a query naming a parameter finds its tool, and substring matching means partial words match. Scores sum across terms; matches are sorted by score (ties broken alphabetically by path), then sliced from the zero-based `offset` (default 0) to the configured `limit` (default 10). `remaining` counts matches after the current page. `next` is `{ offset }` when another page exists and `null` on the final page; spread it into the original request to preserve its query, namespace, and limit.
```ts
const request = { query: "order status", namespace: "orders", limit: 10 }
const page = await tools.$codemode.search(request)
const nextPage = page.next ? await tools.$codemode.search({ ...request, ...page.next }) : undefined
```
Each result contains the path, description, and the same generated TypeScript signature used by the inline catalog, so no second lookup is needed. Signatures use the JSDoc-annotated multiline form: each described input/output field carries its schema `description` as a `/** ... */` comment, and constraints TypeScript cannot express ride along as tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`).
```ts
tools.github.list_issues(input: {
/** Repository owner */
owner: string,
/** Cursor from the previous response's pageInfo */
after?: string,
/**
* Results per page
* @default 30
*/
perPage?: number,
}): Promise<unknown>
```
Result paths are rendered as JavaScript expressions rooted at `tools` (`tools.orders.lookup`, or `tools.context7["resolve-library-id"]` for non-identifier segments), so each `path` is directly usable as the call site. An empty query browses the catalog alphabetically by path; combined with `namespace` (`{ query: "", namespace: "orders" }`) it lists everything in that namespace. A query that names one tool path exactly (canonical path, `tools.`-prefixed path, or rendered JavaScript expression) is treated as a lookup and returns that tool alone.
The instructions are structured markdown, ordered so the workflow sits at the top and the catalog at the bottom: a `## Workflow` section with numbered steps (find a tool via search when the catalog is partial, or pick from the inlined list when it is complete; call the exact path as-is; return only the needed fields), a `## Rules` section holding only guidance the workflow does not already cover (only listed/search-result Code Mode tools and internal runtime tools exist inside `tools`; filter and aggregate collections in code; narrow `Promise<unknown>` results at runtime; run independent calls through `Promise.all`; enumerate `tools` with `Object.keys`/`for...in`; browse a namespace and paginate search results when search is advertised), a short `## Language` section that identifies the runtime as a restricted JavaScript orchestration language and names its major unavailable capabilities, and the budgeted `## Available tools` catalog. Example call forms use explicit `<namespace>.<tool>`/`<field>` placeholders - never a real or fabricated tool name.
A host cannot define its own `$codemode` top-level namespace.
## Supported Programs
CodeMode executes a deliberately bounded JavaScript subset. See the
[interpreter support checklist](./interpreter-support.md) for the complete, checkable language and standard-library
matrix, known semantic gaps, and intentional exclusions.
At a high level, it supports:
- Plain data, property access and assignment, destructuring, functions, conditionals, loops, spread, optional chaining,
and structured error handling.
- Allowlisted Array, String, Number, Object, Math, JSON, console, Date, RegExp, Map, Set, URL, and URLSearchParams APIs.
- Eager supervised tool promises, direct `await`, and the supported `Promise` combinators for concurrent work.
- Live standard-library values inside the sandbox and predictable JSON-like serialization at tool/result boundaries.
- Actionable diagnostics for unsupported syntax, invalid data, tool failures, limits, and execution failures.
It does not expose ambient host authority or arbitrary JavaScript execution. Unsupported syntax returns an
`UnsupportedSyntax` diagnostic with a source location when available.
CodeMode is an orchestration language, not a general JavaScript runtime.
## Execution Limits
The limits are exactly three knobs:
| Limit | Default | Bounds |
| ---------------- | -------------------: | ---------------------------------------------------- |
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
| `maxOutputBytes` | none - no truncation | Retained result value and logs; warnings separately. |
No limit has a default, on purpose: execution budgets are host policy, not library policy - a host that wants a bound sets one; a host that can interrupt the execution fiber (as OpenCode does on user cancel) may set no timeout, and a host with its own tool-output truncation (as OpenCode has) may leave `maxOutputBytes` unset. A host with neither should set `maxOutputBytes`, or oversized results silently flood model context.
Pass only the overrides you need:
```ts
const runtime = CodeMode.make({
tools,
limits: {
maxToolCalls: 20,
timeoutMs: 60_000,
},
})
```
Limits are safe integers. `timeoutMs` must be at least `1`; the others may be `0`. Invalid configuration throws a `RangeError` when `CodeMode.make` or `CodeMode.execute` is called. An explicitly `undefined` value is the same as leaving the limit unset.
`maxOutputBytes` is a payload budget, not a strict byte cap on the final rendered tool message. It counts the serialized result value and retained log lines; warning diagnostics are bounded by a separate budget of the same size, so a large value never silences runtime diagnostics. Fixed truncation notices and framing added by a host when it renders the structured result are additional and may make the final message exceed the configured number.
Exceeding a configured `maxOutputBytes` never fails the execution. An oversized result value is replaced by its truncated serialized text plus an explanatory marker, logs are kept within the remaining budget, warnings are kept within their own budget, omitted entries receive a summary marker, and the result carries `truncated: true`.
When configured, the timeout interrupts in-flight tool Effects, including eagerly started calls the program has not awaited (their fibers are supervised by the execution). The interpreter yields cooperatively between steps, so the timeout also interrupts pure busy loops (`while (true) {}`) - no separate work budget exists. Tool implementations remain responsible for making their external operations interruptible or independently bounded: the timeout bounds when interruption begins, not when the result is delivered, which waits for tool interruption cleanup to finish. If the timeout fires after the program has already returned a valid value - while the runtime is interrupting leftover work and waiting for its cleanup - the result stays successful: the computed value is returned with a `TimeoutExceeded` warning instead of being discarded.
Two interpreter internals are fixed constants rather than knobs: at most 8 tool calls run concurrently, and values crossing a data boundary may nest at most 32 levels deep (deeper values fail as `InvalidDataValue`, which reads better than a native stack-overflow error). Neither is part of the public contract.
## Diagnostics
Failures are data:
Failure `error` and success `warnings` share one diagnostic vocabulary:
| Kind | Meaning |
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
@@ -306,58 +152,45 @@ Failures are data:
| `ExecutionFailure` | The program threw or another execution error occurred. |
| `Truncated` | Warning-only marker: additional warnings were omitted by `maxOutputBytes`. |
Unknown host failures, defects, invalid outputs, and copying failures are sanitized. To return a safe operational refusal, fail with `toolError`:
Unknown host failures, defects, and invalid outputs are sanitized. `toolError("safe message")` is the explicit channel
for a model-visible refusal; its optional cause never crosses the boundary.
```ts
import { toolError } from "@opencode-ai/codemode"
## Discovery
run: ({ id }) => (authorized(id) ? loadOrder(id) : Effect.fail(toolError("Order is unavailable")))
```
The generated instructions inline a budgeted catalog (default 2,000 estimated tokens, override with
`discovery: { catalogBudget }`): every namespace is always listed with its tool count, signatures are selected
round-robin so every namespace gets representation, and the instructions state whether the list is complete or
partial. Programs also get a global `search(...)` built-in - always available, advertised when the list is partial:
synchronous, deterministic field-weighted substring matching that returns directly callable paths with full
signatures, supports namespace scoping and pagination, and treats an empty query as browsing and an exact path as
lookup. Search counts as an admitted tool call.
Only the supplied message is model-visible. The optional cause is never returned in `CodeMode.Result`; hosts should perform any required internal logging before crossing this boundary.
## Execution Limits
## Authority Boundary
| Limit | Default | Bounds |
| ---------------- | -------------------: | ---------------------------------------------------- |
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
| `maxOutputBytes` | none - no truncation | Retained result value and logs; warnings separately. |
CodeMode confines programs to the supplied tool tree, but it does not decide what those tools may do.
No limit has a default, on purpose: execution budgets are host policy. A host without its own truncation or
interruption should set `maxOutputBytes` and `timeoutMs`. Limits are safe integers; invalid configuration throws a
`RangeError` at construction. Exceeding `maxOutputBytes` never fails the execution - oversized output is truncated
with an in-band marker. The timeout interrupts in-flight tool fibers and pure busy loops alike; a value the program
already returned survives a cleanup timeout as a success with a `TimeoutExceeded` warning. Two internals are fixed
constants, not knobs: at most 8 concurrent tool calls, and 32 levels of data nesting at boundaries.
The host owns:
## Boundaries and Non-Goals
- Authentication and authorization.
- Tool selection and immutable scope.
- Credentials and network clients.
- Persistence, idempotency, approval, and durable side effects.
- Logging and redaction policy.
The host owns authentication, authorization, tool selection, credentials, persistence, approval, and logging policy.
CodeMode owns interpretation, schema and plain-data boundaries, resource limits, diagnostics, and discovery. A program
can only exercise authority already present in the supplied tools - do not expose a broad tool and expect the prompt
to restrict it.
CodeMode owns:
- Parsing and interpreting the supported subset without `eval`.
- Schema boundaries around tool calls.
- Plain-data copying and blocked prototype members.
- Resource limits, call accounting, and normalized diagnostics.
- Model-facing tool discovery and instructions.
A program cannot gain authority through prose or generated code. It can only exercise authority already present in the supplied tools. Do not expose a broad tool and expect the prompt to restrict it.
## Laws
The public contract is guided by these equivalences:
- `CodeMode.execute({ ...options, code })` is equivalent to `CodeMode.make(options).execute(code)`.
- A tool implementation is not invoked unless its input has decoded successfully.
- A tool result is not visible to the program unless its output has decoded and crossed the plain-data boundary successfully.
- Unknown host failures do not become model-visible diagnostics; `ToolError` is the explicit safe-message channel.
- Host interruption remains interruption rather than a `CodeMode.Failure`.
## Non-Goals
- Generic permission prompts or approval workflows.
- Durable pause/resume, replay, or storage adapters.
- Exactly-once external side effects.
- Application authorization or product policy.
- A filesystem or process sandbox for arbitrary JavaScript.
- Compatibility with the full JavaScript language or npm ecosystem.
Applications that need approval or durable consequences should model those above CodeMode and expose only the currently authorized tools.
Non-goals: permission prompts and approval workflows, durable pause/resume or replay, exactly-once side effects,
application authorization policy, sandboxing arbitrary JavaScript, and compatibility with the full language or npm
ecosystem. Applications that need approval or durable consequences should model those above CodeMode and expose only
the currently authorized tools.
## Testing
@@ -367,5 +200,3 @@ From the package directory:
bun test
bun run typecheck
```
The direct suite covers public projections, discovery, schema boundaries, diagnostic sanitization, resource limits, tool-call observation, and interruption.
+6 -5
View File
@@ -32,7 +32,7 @@ CodeMode is an orchestration language, not a general JavaScript runtime or an ap
The generic runtime lives in `packages/codemode` and is host-neutral:
1. The host builds a tree of `Tool.make(...)` definitions and calls `CodeMode.make(...)` or `CodeMode.execute(...)`.
2. CodeMode generates model instructions, a budgeted inline catalog, and the internal `$codemode.search` tool.
2. CodeMode generates model instructions, a budgeted inline catalog, and the global `search(...)` built-in.
3. TypeScript syntax is transpiled away, Acorn parses the resulting JavaScript, and an owned tree-walking interpreter
executes it without `eval`.
4. Tool inputs and outputs cross schema and plain-data boundaries before they become visible on either side.
@@ -46,13 +46,14 @@ advertised as `Promise<unknown>`.
### Discovery and model workflow
The model sees a token-budgeted catalog. Every namespace remains visible, and complete signatures are selected
round-robin across namespaces so one large namespace cannot starve the others. `$codemode.search` is always callable
and is advertised when the inline catalog is partial.
round-robin across namespaces so one large namespace cannot starve the others. The global `search(...)` built-in is
always callable - synchronously, counted as an admitted tool call - and is advertised when the inline catalog is
partial.
The intended workflow is:
1. Pick an exact signature from the inline catalog, or return `$codemode.search(...)` results and use a selected path
in the next execution.
1. Pick an exact signature from the inline catalog, or return `search(...)` results and use a selected path in the
next execution.
2. Call the exact returned path without guessing or normalizing segments.
3. Narrow `Promise<unknown>` results before reading fields.
4. Start independent calls together and await them with `Promise.all`.
+2
View File
@@ -22,6 +22,8 @@ ultimate source of truth.
- [x] JSON-like host boundaries with `undefined` and non-finite numbers normalized to `null`.
- [x] Live Date, RegExp, Map, Set, URL, and URLSearchParams values inside the sandbox.
- [x] Tool calls through the host-provided `tools` tree only.
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
shadowable by program declarations like other globals.
- [x] Cooperative timeout, tool-call accounting, output bounding, and a maximum of eight concurrent tool calls.
- [ ] Full JavaScript or TypeScript compatibility. CodeMode is a bounded orchestration language.
-2
View File
@@ -143,7 +143,6 @@ export const execute = <const Tools extends Record<string, unknown>>(
options: ExecuteOptions<Tools>,
): Effect.Effect<Result, never, Services<Tools>> => {
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
ToolRuntime.assertValidTools(tools)
return executeWithLimits(options, resolveExecutionLimits(options.limits), ToolRuntime.searchIndex(tools))
}
@@ -152,7 +151,6 @@ export const make = <const Tools extends Record<string, unknown> = {}>(
options: Options<Tools> = {} as Options<Tools>,
): Runtime<Services<Tools>> => {
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
ToolRuntime.assertValidTools(tools)
const limits = resolveExecutionLimits(options.limits)
const prepared = ToolRuntime.prepare(tools, options.discovery?.catalogBudget)
@@ -114,6 +114,10 @@ export class UriFunction {
constructor(readonly name: "encodeURI" | "encodeURIComponent" | "decodeURI" | "decodeURIComponent") {}
}
// The global `search` built-in: synchronous tool discovery that shares the tool admission
// pipeline (budget, audit, hooks) without living in the `tools` tree.
export class SearchFunction {}
export class ProgramThrow {
constructor(readonly value: unknown) {}
}
+23 -4
View File
@@ -49,6 +49,7 @@ import {
PromiseNamespace,
ProgramThrow,
type ProgramNode,
SearchFunction,
type StatementResult,
sourceLocation,
supportedSyntaxMessage,
@@ -290,6 +291,7 @@ const isRuntimeReference = (value: unknown): boolean =>
value instanceof SandboxPromise ||
value instanceof CoercionFunction ||
value instanceof UriFunction ||
value instanceof SearchFunction ||
value instanceof PromiseCapabilityFunction ||
value instanceof ErrorConstructorReference ||
isSandboxValue(value)
@@ -338,7 +340,7 @@ const typeofValue = (value: unknown): string => {
value instanceof ErrorConstructorReference
)
return "function"
if (value instanceof UriFunction) return "function"
if (value instanceof UriFunction || value instanceof SearchFunction) return "function"
if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object"
if (value instanceof GlobalNamespace) {
return value.name === "Math" || value.name === "JSON" || value.name === "console" ? "object" : "function"
@@ -738,6 +740,8 @@ class PromiseRuntime<R> {
class Interpreter<R> {
private scopes: Array<Map<string, Binding>>
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
// The built-in `search` global, threaded from ToolRuntime.make like invokeTool.
private readonly invokeSearch: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
// Enumerable namespace/tool names at a node of the host tool tree, threaded from
// ToolRuntime.make like invokeTool: the interpreter never holds the tree itself.
private readonly toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
@@ -748,6 +752,7 @@ class Interpreter<R> {
constructor(
invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
invokeSearch: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
promises: PromiseRuntime<R>,
logs: Array<string> = [],
@@ -756,11 +761,13 @@ class Interpreter<R> {
const globalScope = new Map<string, Binding>()
this.scopes = [globalScope]
this.invokeTool = invokeTool
this.invokeSearch = invokeSearch
this.toolKeys = toolKeys
this.logs = logs
this.callPermits = callPermits
this.promises = promises
globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
globalScope.set("search", { mutable: false, value: new SearchFunction() })
globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() })
globalScope.set("undefined", { mutable: false, value: undefined })
globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") })
@@ -2085,6 +2092,11 @@ class Interpreter<R> {
if (callable instanceof UriFunction) {
return invokeUriFunction(callable, args, node)
}
if (callable instanceof SearchFunction) {
// The built-in search is synchronous in-memory matching: the call returns its
// result directly (await still works, as with any plain value).
return yield* self.invokeSearch(args)
}
// `Error("msg")` without `new` constructs an error exactly like `new Error("msg")`, as in JS.
if (callable instanceof ErrorConstructorReference) {
return constructErrorValue(callable.name, args, node)
@@ -2106,7 +2118,7 @@ class Interpreter<R> {
return boundedData(this.enumerableKeys(ref)!, "Object.keys result")
}
throw new InterpreterRuntimeError(
`Object.${name}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or tools.$codemode.search({ query }) for signatures.`,
`Object.${name}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or search({ query }) for signatures.`,
node,
"InvalidDataValue",
)
@@ -2493,7 +2505,14 @@ class Interpreter<R> {
}
private invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
const invocation = new Interpreter(this.invokeTool, this.toolKeys, this.promises, this.logs, this.callPermits)
const invocation = new Interpreter(
this.invokeTool,
this.invokeSearch,
this.toolKeys,
this.promises,
this.logs,
this.callPermits,
)
invocation.scopes = [...fn.capturedScopes, new Map<string, Binding>()]
const run = Effect.gen(function* () {
// Seed every parameter name into the scope as a TDZ slot first, so a default that
@@ -3666,7 +3685,7 @@ export const executeWithLimits = <const Tools extends Record<string, unknown>>(
Effect.gen(function* () {
const program = parseProgram(options.code)
const promises = new PromiseRuntime<Services<Tools>>(scope)
const interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.keys, promises, logs)
const interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.search, tools.keys, promises, logs)
const value = yield* interpreter.run(program)
// Validate the result first so an invalid value is a fatal completion that closes
// the promise scope directly instead of taking the normal-completion path.
+64 -57
View File
@@ -82,7 +82,6 @@ export type ToolDescription = {
export type SafeObject = Record<string, unknown>
const reservedNamespace = "$codemode"
const defaultCatalogBudget = 2_000
const defaultSearchLimit = 10
const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
@@ -452,7 +451,12 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition =>
}),
})
const searchDescription = describeDefinition(`${reservedNamespace}.search`, makeSearchTool([]))
// The built-in `search` is a synchronous global function, not a tool-tree entry, so its
// advertised signature is rendered by hand instead of through `describeDefinition`.
const searchSignature = (() => {
const definition = makeSearchTool([])
return `search(input: ${inputTypeScript(definition, true)}): ${outputTypeScript(definition, true)}`
})()
const catalogLine = (tool: ToolDescription) => {
// Keep the tool description concise; the full schema documentation remains in the signature.
@@ -479,12 +483,6 @@ const toSearchEntry = <R>(path: string, definition: Definition<R>, description:
export const searchIndex = <R>(tools: HostTools<R>): ReadonlyArray<SearchEntry> =>
visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description))
export const assertValidTools = <R>(tools: HostTools<R>): void => {
if (Object.hasOwn(tools, reservedNamespace)) {
throw new Error(`Tool namespace '${reservedNamespace}' is reserved for CodeMode discovery tools.`)
}
}
/**
* Budgeted catalog: every namespace is always listed with its tool count; full call
* signatures are inlined against the `catalogBudget` (estimated tokens,
@@ -557,8 +555,8 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
empty
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime."
: complete
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed below and internal runtime tools; surrounding agent tools are not available."
: "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed or searchable below and internal runtime tools; surrounding agent tools are not available.",
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed below; surrounding agent tools are not available."
: "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed or searchable below; surrounding agent tools are not available.",
...(empty
? []
: ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]),
@@ -579,7 +577,7 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
"3. Return only the fields you need from structured results; narrow unknown results before reading fields, and avoid returning large raw payloads.",
]
: [
'1. If needed, discover tools: `return await tools.$codemode.search({ query: "<intent + key nouns>" })`.',
'1. If needed, discover tools with the built-in search function: `return search({ query: "<intent + key nouns>" })`.',
"2. In the next execution, copy a returned path exactly, call it, and return only the needed fields.",
]),
]
@@ -591,8 +589,8 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
"## Rules",
"",
complete
? "- Only Code Mode tools listed here and internal runtime tools are available; surrounding agent tools are not implicitly exposed."
: "- Only Code Mode tools listed here or returned by `tools.$codemode.search` and internal runtime tools are available; surrounding agent tools are not implicitly exposed.",
? "- Only Code Mode tools listed here are available; surrounding agent tools are not implicitly exposed."
: "- Only Code Mode tools listed here or returned by the built-in `search` function are available; surrounding agent tools are not implicitly exposed.",
"- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.",
"- A result typed `Promise<unknown>` may be structured data or text. Before reading fields, check that it is a non-null object and not an array; otherwise handle the returned text or primitive directly.",
'- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`, or use `tools.<namespace>["tool-name"](item)` when the listed signature uses bracket notation.',
@@ -601,7 +599,7 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
...(complete
? []
: [
'- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.',
'- Browse one namespace: `search({ query: "", namespace: "<name>" })`.',
"- If search returns `next`, repeat the same search with `offset: next.offset`.",
]),
]
@@ -623,7 +621,7 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
toolSection.push(
complete
? "## Available tools (COMPLETE list - every tool is shown below with its full call signature)"
: `## Available tools (PARTIAL - ${totalShown} of ${described.length} shown; find the rest with tools.$codemode.search)`,
: `## Available tools (PARTIAL - ${totalShown} of ${described.length} shown; find the rest with search(...))`,
"",
)
for (const [namespace, group] of ordered) {
@@ -641,7 +639,7 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool))
}
if (!complete) {
toolSection.push("", "Search returns complete callable signatures:", `- ${searchDescription.signature}`)
toolSection.push("", "Search returns complete callable signatures:", `- ${searchSignature}`)
}
}
@@ -670,7 +668,7 @@ const namespaceKeys = <R>(tools: HostTools<R>, path: ReadonlyArray<string>): Rea
!Object.hasOwn(value, segment)
) {
throw new ToolRuntimeError("UnknownTool", `Unknown tool namespace '${path.join(".")}'.`, [
"Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools.",
"Object.keys(tools) lists the available namespaces; search({ query }) finds described tools.",
])
}
value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
@@ -690,7 +688,7 @@ const resolve = <R>(tools: HostTools<R>, path: ReadonlyArray<string>): HostTool<
!Object.hasOwn(value, segment)
) {
throw new ToolRuntimeError("UnknownTool", `Unknown tool '${path.join(".")}'.`, [
"Use tools.$codemode.search({ query }) to find available described tools.",
"Use search({ query }) to find available described tools.",
])
}
value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
@@ -707,6 +705,11 @@ export type ToolRuntime<R = never> = {
readonly root: ToolReference
readonly calls: Array<ToolCall>
readonly invoke: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
/**
* The built-in `search` global: a synchronous discovery call that shares the tool
* admission pipeline (budget, audit, hooks) without living in the `tools` tree.
*/
readonly search: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
/** Enumerable namespace/tool names at one node of the callable tool tree; see `namespaceKeys`. */
readonly keys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
}
@@ -719,10 +722,7 @@ export const make = <R>(
hooks?: ToolCallHooks<R>,
): ToolRuntime<R> => {
const calls: Array<ToolCall> = []
const callableTools = {
...tools,
[reservedNamespace]: { search: makeSearchTool(searchIndex) },
}
const searchTool = makeSearchTool(searchIndex)
// Wraps the settling portion of a tool call so onToolCallEnd observes success and failure
// symmetrically. Interruption (e.g. the execution timeout) fires neither outcome.
@@ -758,52 +758,59 @@ export const make = <R>(
calls.push(call)
}
const recordAndObserve = (name: string, input: unknown) =>
Effect.sync(() => {
recordCall({ name })
return calls.length - 1
}).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void))
const invokeDefinition = (name: string, tool: Definition<R>, externalArgs: Array<unknown>) =>
Effect.gen(function* () {
if (externalArgs.length !== 1)
throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`)
const input = yield* Effect.try({
try: () => decodeToolInput(tool, externalArgs[0]),
catch: (cause) =>
new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`),
})
const index = yield* recordAndObserve(name, input)
return yield* observeEnd(
Effect.gen(function* () {
const raw = yield* runHost(Effect.suspend(() => tool.run(input)))
const result = yield* Effect.try({
try: () => decodeToolOutput(tool, raw),
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
})
return yield* decodeOutput(result, name)
}),
{ index, name, input },
)
})
return {
root: new ToolReference([]),
calls,
keys: (path) => namespaceKeys(callableTools, path),
keys: (path) => namespaceKeys(tools, path),
search: (args) =>
Effect.suspend(() =>
invokeDefinition(
"search",
searchTool,
args.map((arg) => copyOut(copyIn(arg, "Arguments for tool 'search'"))),
),
),
invoke: (path, args) =>
Effect.gen(function* () {
const name = path.join(".")
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`)))
const call = { name }
const recordAndObserve = (input: unknown) =>
Effect.sync(() => {
recordCall(call)
return calls.length - 1
}).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void))
const tool = resolve(callableTools, path)
let describedInput: unknown
if (isDefinition(tool)) {
if (externalArgs.length !== 1)
throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`)
describedInput = yield* Effect.try({
try: () => decodeToolInput(tool, externalArgs[0]),
catch: (cause) =>
new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`),
})
}
const input = isDefinition(tool) ? describedInput : externalArgs
const index = yield* recordAndObserve(input)
const currentCall = { index, name, input }
if (isDefinition(tool)) {
return yield* observeEnd(
Effect.gen(function* () {
const raw = yield* runHost(Effect.suspend(() => tool.run(describedInput)))
const result = yield* Effect.try({
try: () => decodeToolOutput(tool, raw),
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
})
return yield* decodeOutput(result, name)
}),
currentCall,
)
}
const tool = resolve(tools, path)
if (isDefinition(tool)) return yield* invokeDefinition(name, tool, externalArgs)
const index = yield* recordAndObserve(name, externalArgs)
return yield* observeEnd(
Effect.gen(function* () {
return yield* decodeOutput(yield* runHost(Effect.suspend(() => tool(...externalArgs))), name)
}),
currentCall,
{ index, name, input: externalArgs },
)
}),
}
+63 -76
View File
@@ -541,11 +541,11 @@ describe("CodeMode public contract", () => {
" - tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}> // Look up an order by ID",
)
// A fully inlined catalog does not advertise search in the instructions...
expect(runtime.instructions()).not.toMatch(/\$codemode/)
expect(runtime.instructions()).not.toContain("search(")
// ...but the search tool stays registered, so a speculative call still works with the
// ...but the search built-in stays available, so a speculative call still works with the
// same signature as the inline catalog.
const result = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "order" })`))
const result = await Effect.runPromise(runtime.execute(`return search({ query: "order" })`))
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.value).toStrictEqual({
@@ -583,9 +583,7 @@ describe("CodeMode public contract", () => {
'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise<string>',
)
const search = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ query: "resolve library id" })`),
)
const search = await Effect.runPromise(runtime.execute(`return search({ query: "resolve library id" })`))
expect(search.ok).toBe(true)
if (search.ok) {
expect(search.value).toStrictEqual({
@@ -608,7 +606,7 @@ describe("CodeMode public contract", () => {
if (call.ok) expect(call.value).toBe("/resolved/TypeScript")
const exact = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ query: 'tools.context7["resolve-library-id"]' })`),
runtime.execute(`return search({ query: 'tools.context7["resolve-library-id"]' })`),
)
expect(exact.ok).toBe(true)
if (exact.ok) expect(exact.value).toMatchObject({ remaining: 0, next: null })
@@ -632,7 +630,7 @@ describe("CodeMode public contract", () => {
expect(instructions).toContain("Do not infer or normalize tool names")
expect(instructions).toContain("bracket notation and quotes are part of the path")
expect(instructions).toContain("surrounding agent tools are not available")
expect(instructions).toContain("Only Code Mode tools listed here and internal runtime tools")
expect(instructions).toContain("Only Code Mode tools listed here are available")
// Placeholders use generic namespace/tool/field names only - no fabricated real tools
// and no real catalog tools cherry-picked into example lines.
expect(instructions).toContain("`const result = await tools.<namespace>.<tool>(input)`")
@@ -651,15 +649,11 @@ describe("CodeMode public contract", () => {
// PARTIAL: the workflow starts with search (with query-style guidance that is clearly
// a query string, never a tool name) and the browse-namespace rule appears.
expect(partial).toContain(
'1. If needed, discover tools: `return await tools.$codemode.search({ query: "<intent + key nouns>" })`.',
'1. If needed, discover tools with the built-in search function: `return search({ query: "<intent + key nouns>" })`.',
)
expect(partial).toContain("In the next execution, copy a returned path exactly")
expect(partial).toContain(
"Only Code Mode tools listed here or returned by `tools.$codemode.search` and internal runtime tools",
)
expect(partial).toContain(
'- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.',
)
expect(partial).toContain("Only Code Mode tools listed here or returned by the built-in `search` function")
expect(partial).toContain('- Browse one namespace: `search({ query: "", namespace: "<name>" })`.')
expect(partial).toContain("repeat the same search with `offset: next.offset`")
expect(partial).toContain(" limit?: number,\n offset?: number,")
expect(partial).not.toContain("total_count")
@@ -696,7 +690,7 @@ describe("CodeMode public contract", () => {
expect(instructions).toContain("## Available tools")
expect(instructions).not.toContain("## Workflow")
expect(instructions).not.toContain("## Rules")
expect(instructions).not.toMatch(/\$codemode/)
expect(instructions).not.toContain("search(")
})
test("uses one ranked search returning complete definitions for large catalogs", async () => {
@@ -716,17 +710,15 @@ describe("CodeMode public contract", () => {
tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } },
discovery: { catalogBudget: 0 },
})
expect(runtime.instructions()).toContain(
"Available tools (PARTIAL - 0 of 3 shown; find the rest with tools.$codemode.search)",
)
expect(runtime.instructions()).toContain("Available tools (PARTIAL - 0 of 3 shown; find the rest with search(...))")
expect(runtime.instructions()).toContain("- thread (2 tools, none shown)")
expect(runtime.instructions()).toContain("- orders (1 tool, none shown)")
expect(runtime.instructions()).toMatch(/\$codemode\.search/)
expect(runtime.instructions()).toContain("Search returns complete callable signatures:\n- search(input: {")
expect(runtime.instructions()).not.toMatch(/tools\.thread\.uploadFile\(input/)
const result = await Effect.runPromise(
runtime.execute(`
return await tools.$codemode.search({
return search({
query: "send message attachment upload file to current Discord thread",
limit: 2
})
@@ -750,14 +742,14 @@ describe("CodeMode public contract", () => {
remaining: 0,
next: null,
})
expect(result.toolCalls).toStrictEqual([{ name: "$codemode.search" }])
expect(result.toolCalls).toStrictEqual([{ name: "search" }])
const variants = await Effect.runPromise(
runtime.execute(`
return await Promise.all([
tools.$codemode.search({ query: "file" }),
tools.$codemode.search({ query: "image" })
])
return [
search({ query: "file" }),
search({ query: "image" })
]
`),
)
expect(variants.ok).toBe(true)
@@ -769,12 +761,35 @@ describe("CodeMode public contract", () => {
"tools.thread.generateImage",
)
}
})
const removed = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.describe({ path: "thread.uploadFile" })`),
)
expect(removed.ok).toBe(false)
if (!removed.ok) expect(removed.error.kind).toBe("UnknownTool")
test("search is a counted tool call: it burns maxToolCalls and fires the hooks", async () => {
const started: Array<string> = []
const ended: Array<string> = []
const limited = CodeMode.make({
tools,
limits: { maxToolCalls: 1 },
onToolCallStart: (call) => Effect.sync(() => void started.push(call.name)),
onToolCallEnd: (call) => Effect.sync(() => void ended.push(`${call.name}:${call.outcome}`)),
})
const result = await Effect.runPromise(limited.execute(`search({}); return search({})`))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error.kind).toBe("ToolCallLimitExceeded")
expect(started).toEqual(["search"])
expect(ended).toEqual(["search:success"])
})
test("search is an opaque, shadowable global like other built-ins", async () => {
const runtime = CodeMode.make({ tools })
expect(await Effect.runPromise(runtime.execute(`return typeof search`))).toMatchObject({ value: "function" })
// A program-level declaration shadows the global, as JS module scope does.
const shadowed = await Effect.runPromise(runtime.execute(`const search = () => "local"; return search()`))
expect(shadowed.ok).toBe(true)
if (shadowed.ok) expect(shadowed.value).toBe("local")
// The reference itself cannot cross the data boundary.
const escaped = await Effect.runPromise(runtime.execute(`return { search }`))
expect(escaped.ok).toBe(false)
if (!escaped.ok) expect(escaped.error.kind).toBe("InvalidDataValue")
})
test("search defaults to 10 results and resolves exact tool paths", async () => {
@@ -791,7 +806,7 @@ describe("CodeMode public contract", () => {
},
})
const browse = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({})`))
const browse = await Effect.runPromise(runtime.execute(`return search({})`))
expect(browse.ok).toBe(true)
if (browse.ok) {
const value = browse.value as {
@@ -805,9 +820,7 @@ describe("CodeMode public contract", () => {
}
for (const query of ["many.tool13", "tools.many.tool13"]) {
const exact = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`),
)
const exact = await Effect.runPromise(runtime.execute(`return search({ query: ${JSON.stringify(query)} })`))
expect(exact.ok).toBe(true)
if (exact.ok) {
expect(exact.value).toStrictEqual({
@@ -841,9 +854,7 @@ describe("CodeMode public contract", () => {
})
// Empty query + namespace browses just that namespace, alphabetical by path.
const browse = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ query: "", namespace: "github" })`),
)
const browse = await Effect.runPromise(runtime.execute(`return search({ query: "", namespace: "github" })`))
expect(browse.ok).toBe(true)
if (browse.ok) {
const value = browse.value as { items: Array<{ path: string }>; remaining: number }
@@ -855,9 +866,7 @@ describe("CodeMode public contract", () => {
}
// A query + namespace ranks within that namespace only.
const scoped = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: "linear" })`),
)
const scoped = await Effect.runPromise(runtime.execute(`return search({ query: "issues", namespace: "linear" })`))
expect(scoped.ok).toBe(true)
if (scoped.ok) {
const value = scoped.value as { items: Array<{ path: string }>; remaining: number }
@@ -865,9 +874,7 @@ describe("CodeMode public contract", () => {
expect(value.items[0]?.path).toBe("tools.linear.list_issues")
}
const invalid = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: 7 })`),
)
const invalid = await Effect.runPromise(runtime.execute(`return search({ query: "issues", namespace: 7 })`))
expect(invalid.ok).toBe(false)
if (!invalid.ok) expect(invalid.error.kind).toBe("InvalidToolInput")
})
@@ -892,9 +899,7 @@ describe("CodeMode public contract", () => {
// "attachment" appears in neither path nor description - only in the input schema's
// property names, which the searchable text includes.
const byParameter = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ query: "attachment" })`),
)
const byParameter = await Effect.runPromise(runtime.execute(`return search({ query: "attachment" })`))
expect(byParameter.ok).toBe(true)
if (byParameter.ok) {
const value = byParameter.value as { items: Array<{ path: string }>; remaining: number }
@@ -903,9 +908,7 @@ describe("CodeMode public contract", () => {
}
// Substring matching: a partial word ("docum") still hits the description.
const bySubstring = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ query: "docum" })`),
)
const bySubstring = await Effect.runPromise(runtime.execute(`return search({ query: "docum" })`))
expect(bySubstring.ok).toBe(true)
if (bySubstring.ok) {
const value = bySubstring.value as { items: Array<{ path: string }>; remaining: number }
@@ -932,9 +935,7 @@ describe("CodeMode public contract", () => {
})
// "issues" still finds the singular-only tool (term OR singular(term) per field)...
const plural = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: "tracker" })`),
)
const plural = await Effect.runPromise(runtime.execute(`return search({ query: "issues", namespace: "tracker" })`))
expect(plural.ok).toBe(true)
if (plural.ok) {
const value = plural.value as { items: Array<{ path: string }>; remaining: number }
@@ -943,7 +944,7 @@ describe("CodeMode public contract", () => {
}
// ...while a true "issues" path match still outranks the singular-only description match.
const ranked = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "issues" })`))
const ranked = await Effect.runPromise(runtime.execute(`return search({ query: "issues" })`))
expect(ranked.ok).toBe(true)
if (ranked.ok) {
const value = ranked.value as { items: Array<{ path: string }>; remaining: number }
@@ -970,7 +971,7 @@ describe("CodeMode public contract", () => {
alpha: { beta: simple("Middle"), aardvark: simple("First") },
},
})
const browse = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({})`))
const browse = await Effect.runPromise(runtime.execute(`return search({})`))
expect(browse.ok).toBe(true)
if (browse.ok) {
const value = browse.value as { items: Array<{ path: string }>; remaining: number; next: unknown }
@@ -983,9 +984,7 @@ describe("CodeMode public contract", () => {
expect(value.next).toBeNull()
}
const middle = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ limit: 1, offset: 1 })`),
)
const middle = await Effect.runPromise(runtime.execute(`return search({ limit: 1, offset: 1 })`))
expect(middle.ok).toBe(true)
if (middle.ok) {
expect(middle.value).toMatchObject({
@@ -995,9 +994,7 @@ describe("CodeMode public contract", () => {
})
}
const exhausted = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ limit: 1, offset: 3 })`),
)
const exhausted = await Effect.runPromise(runtime.execute(`return search({ limit: 1, offset: 3 })`))
expect(exhausted.ok).toBe(true)
if (exhausted.ok) expect(exhausted.value).toStrictEqual({ items: [], remaining: 0, next: null })
})
@@ -1028,16 +1025,14 @@ describe("CodeMode public contract", () => {
})
const instructions = runtime.instructions()
expect(instructions).toContain(
"Available tools (PARTIAL - 2 of 3 shown; find the rest with tools.$codemode.search)",
)
expect(instructions).toContain("Available tools (PARTIAL - 2 of 3 shown; find the rest with search(...))")
expect(instructions).toContain("- alpha (2 tools, 1 shown)")
expect(instructions).toContain(" - tools.alpha.cheap(input: {\n q: string,\n}): Promise<string> // Cheap")
expect(instructions).not.toContain("tools.alpha.expensive(")
// Fully shown namespaces read cleanly (no "shown" annotation).
expect(instructions).toContain("- beta (1 tool)")
expect(instructions).toContain(" - tools.beta.cheap(input: {\n q: string,\n}): Promise<string> // Cheap")
expect(instructions).toMatch(/\$codemode\.search/)
expect(instructions).toContain("Search returns complete callable signatures:\n- search(input: {")
})
test("charges inline JSDoc against the catalog token budget", () => {
@@ -1058,9 +1053,7 @@ describe("CodeMode public contract", () => {
})
expect(runtime.catalog()[0]?.signature).toContain("/** A detailed identifier description.")
expect(runtime.instructions()).toContain(
"Available tools (PARTIAL - 0 of 1 shown; find the rest with tools.$codemode.search)",
)
expect(runtime.instructions()).toContain("Available tools (PARTIAL - 0 of 1 shown; find the rest with search(...))")
expect(runtime.instructions()).not.toContain("tools.records.lookup(input:")
})
@@ -1138,7 +1131,7 @@ describe("CodeMode public contract", () => {
CodeMode.make({
tools,
discovery: { catalogBudget: 0 },
}).execute(`return await tools.$codemode.search({ query: "order", limit: 0.5 })`),
}).execute(`return search({ query: "order", limit: 0.5 })`),
)
expect(result.ok).toBe(false)
if (result.ok) return
@@ -1146,9 +1139,7 @@ describe("CodeMode public contract", () => {
for (const offset of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1, "1"]) {
const invalidOffset = await Effect.runPromise(
CodeMode.make({ tools }).execute(
`return await tools.$codemode.search({ query: "order", offset: ${JSON.stringify(offset)} })`,
),
CodeMode.make({ tools }).execute(`return search({ query: "order", offset: ${JSON.stringify(offset)} })`),
)
expect(invalidOffset.ok).toBe(false)
if (!invalidOffset.ok) expect(invalidOffset.error.kind).toBe("InvalidToolInput")
@@ -1199,8 +1190,4 @@ describe("CodeMode public contract", () => {
}
expect(elapsedMs).toBeLessThan(3_000)
})
test("reserves the discovery namespace", () => {
expect(() => CodeMode.make({ tools: { $codemode: { lookup } } })).toThrow(/reserved for CodeMode discovery tools/)
})
})
+5 -5
View File
@@ -41,7 +41,7 @@ describe("Object.keys over tool references", () => {
const namespaces = Object.keys(tools)
return { namespaces, count: namespaces.length }
`),
).toEqual({ namespaces: ["github", "memory", "playwright", "$codemode"], count: 4 })
).toEqual({ namespaces: ["github", "memory", "playwright"], count: 3 })
})
test("enumerates tool names at a nested namespace", async () => {
@@ -52,8 +52,8 @@ describe("Object.keys over tool references", () => {
expect(await value(`return Object.keys(tools.github.list_issues)`)).toEqual([])
})
test("the internal discovery namespace enumerates its callable surface", async () => {
expect(await value(`return Object.keys(tools.$codemode)`)).toEqual(["search"])
test("search is a global built-in function", async () => {
expect(await value(`return typeof search`)).toBe("function")
})
test("an unknown namespace is an UnknownTool error pointing at the discovery idioms", async () => {
@@ -68,7 +68,7 @@ describe("Object.keys over tool references", () => {
const failure = await error(`return Object.${method}(tools)`)
expect(failure.kind).toBe("InvalidDataValue")
expect(failure.message).toContain(
`Object.${method}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or tools.$codemode.search({ query }) for signatures.`,
`Object.${method}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or search({ query }) for signatures.`,
)
}
const nested = await error(`return Object.entries(tools.github)`)
@@ -146,7 +146,7 @@ describe("for...in", () => {
}
return names
`),
).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate", "$codemode.search"])
).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate"])
})
test("unsupported values fail with a hint at for...of and Object.keys", async () => {
+1 -1
View File
@@ -377,7 +377,7 @@ describe("OpenAPI.fromSpec", () => {
runtime
.execute(
`
return await tools.$codemode.search({ query: "global health", namespace: "opencode", limit: 1 })
return search({ query: "global health", namespace: "opencode", limit: 1 })
`,
)
.pipe(Effect.provide(layer)),
+2 -6
View File
@@ -342,9 +342,7 @@ describe("JSDoc signatures in catalogs and search results", () => {
const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } })
const search = async (query: string) => {
const result = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`),
)
const result = await Effect.runPromise(runtime.execute(`return search({ query: ${JSON.stringify(query)} })`))
expect(result.ok).toBe(true)
if (!result.ok) throw new Error("search failed")
return result.value as { items: Array<{ path: string; signature: string }>; remaining: number }
@@ -436,9 +434,7 @@ describe("non-identifier tool paths", () => {
})
test("search results return callable bracket-notation paths and signatures", async () => {
const result = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ query: "resolve library" })`),
)
const result = await Effect.runPromise(runtime.execute(`return search({ query: "resolve library" })`))
expect(result.ok).toBe(true)
if (!result.ok) throw new Error("search failed")
+1 -27
View File
@@ -1,6 +1,6 @@
export * as EventV2 from "./event"
import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"
import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Event } from "@opencode-ai/schema/event"
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
import type { EventLog } from "@opencode-ai/schema/event-log"
@@ -89,11 +89,6 @@ const decodeSerializedEvent = (event: SerializedEvent): Payload => {
}
}
export class SubscriberOverflowError extends Schema.TaggedErrorClass<SubscriberOverflowError>()(
"EventV2.SubscriberOverflow",
{ capacity: Schema.Int },
) {}
export const versionedType = Event.versionedType
export const durable = Event.durable
export const ephemeral = Event.ephemeral
@@ -167,27 +162,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
export const liveBounded = (
events: Interface,
options: { readonly capacity: number; readonly accept?: (event: Payload) => boolean },
) =>
Effect.gen(function* () {
const queue = yield* Queue.dropping<Payload, SubscriberOverflowError>(options.capacity)
const unsubscribe = yield* events.listen((event) =>
options.accept && !options.accept(event)
? Effect.void
: Queue.offer(queue, event).pipe(
Effect.flatMap((accepted) =>
accepted
? Effect.void
: Queue.fail(queue, new SubscriberOverflowError({ capacity: options.capacity })).pipe(Effect.asVoid),
),
),
)
yield* Effect.addFinalizer(() => unsubscribe.pipe(Effect.andThen(Queue.shutdown(queue)), Effect.asVoid))
return Stream.fromQueue(queue)
})
export interface LayerOptions {
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
/** Maximum durable rows read per page while replaying or tailing an aggregate log. */
+10 -1
View File
@@ -128,6 +128,8 @@ export const fffLayer = Layer.effect(
Fff.create({
basePath: location.directory,
aiMode: true,
disableMmapCache: true,
disableContentIndexing: true,
}),
catch: (cause) => cause,
}).pipe(
@@ -230,6 +232,13 @@ export const fffLayer = Layer.effect(
}),
)
const layer = Layer.unwrap(Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)))
const layer = Layer.unwrap(
Effect.gen(function* () {
if (Flag.OPENCODE_DISABLE_FFF || !Fff.available()) return ripgrepLayer
const location = yield* Location.Service
// Non-VCS locations can contain many repositories, so avoid eagerly content-indexing the entire aggregate tree.
return location.vcs ? fffLayer : ripgrepLayer
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Ripgrep.node] })
+22 -11
View File
@@ -85,6 +85,7 @@ export interface OAuthImplementation {
export interface KeyImplementation {
readonly integrationID: ID
readonly method: KeyMethod
readonly authorize?: (key: string, inputs: Inputs) => Effect.Effect<Credential.Key, unknown>
}
export interface EnvImplementation {
@@ -119,6 +120,7 @@ type Entry = {
ref: Types.DeepMutable<Ref>
methods: Types.DeepMutable<Method>[]
implementations: Map<MethodID, Types.DeepMutable<OAuthImplementation>>
key?: Types.DeepMutable<KeyImplementation>
}
type Data = {
@@ -156,6 +158,8 @@ export interface Interface extends State.Transformable<Draft> {
readonly integrationID: ID
/** Secret entered by the user. */
readonly key: string
/** Answers to the method's optional prompts. */
readonly inputs?: Inputs
/** User-facing label for the stored credential. */
readonly label?: string
}) => Effect.Effect<void, AuthorizationError>
@@ -237,6 +241,7 @@ const layer = Layer.effect(
ref: { id, name: id },
methods: [],
implementations: new Map(),
key: undefined,
}
if (!draft.integrations.has(id)) draft.integrations.set(id, current)
update(current.ref)
@@ -253,6 +258,7 @@ const layer = Layer.effect(
},
methods: [],
implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(),
key: undefined,
}
if (!draft.integrations.has(implementation.integrationID)) {
draft.integrations.set(implementation.integrationID, current)
@@ -270,6 +276,9 @@ const layer = Layer.effect(
implementation as Types.DeepMutable<OAuthImplementation>,
)
}
if (implementation.method.type === "key") {
current.key = implementation as Types.DeepMutable<KeyImplementation>
}
},
remove: (integrationID, method) => {
const current = draft.integrations.get(integrationID)
@@ -281,6 +290,7 @@ const layer = Layer.effect(
})
if (index !== -1) current.methods.splice(index, 1)
if (method.type === "oauth") current.implementations.delete(method.id)
if (method.type === "key") current.key = undefined
},
},
}),
@@ -303,7 +313,7 @@ const layer = Layer.effect(
}
const project = (entry: Entry, connections: IntegrationConnection.Info[]) =>
new Info({
Info.make({
id: entry.ref.id,
name: entry.ref.name,
methods: entry.methods,
@@ -365,10 +375,10 @@ const layer = Layer.effect(
? { status: "complete", time: attempt.time, removeAt: settledAt + terminalRetention }
: {
status: "failed",
message: message(persistence.cause),
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
message: message(persistence.cause),
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
// Persisting attempts cannot be cancelled, expired, or claimed again.
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
@@ -438,15 +448,16 @@ const layer = Layer.effect(
return value
}),
key: Effect.fn("Integration.connection.key")(function* (input) {
const method = state
.get()
.integrations.get(input.integrationID)
?.methods.some((method) => method.type === "key")
if (!method) return yield* Effect.die(new Error(`Key method not found: ${input.integrationID}`))
const entry = state.get().integrations.get(input.integrationID)
const method = entry?.methods.some((method) => method.type === "key")
if (!entry || !method) return yield* Effect.die(new Error(`Key method not found: ${input.integrationID}`))
const value = entry.key?.authorize
? yield* authorize(entry.key.authorize(input.key, input.inputs ?? {}))
: Credential.Key.make({ type: "key", key: input.key })
yield* credentials.create({
integrationID: input.integrationID,
label: input.label,
value: Credential.Key.make({ type: "key", key: input.key }),
value,
})
yield* events.publish(Event.ConnectionUpdated, { integrationID: input.integrationID })
yield* events.publish(Event.Updated, {})
+26 -10
View File
@@ -1,6 +1,11 @@
export * as PluginHost from "./host"
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import type {
IntegrationInputs,
IntegrationKeyMethodRegistration,
IntegrationOAuthMethodRegistration,
} from "@opencode-ai/plugin/v2/effect/integration"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Effect, Schema, Stream } from "effect"
import { AgentV2 } from "../agent"
@@ -171,6 +176,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
integration.connection.key({
integrationID: Integration.ID.make(input.integrationID),
key: input.key,
inputs: input.inputs,
label: input.label,
}),
oauth: (input) =>
@@ -207,14 +213,15 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
method: {
list: (id) => mutable(draft.method.list(Integration.ID.make(id))),
update: (input) => {
if ("authorize" in input) {
const methodID = Integration.MethodID.make(input.method.id)
const refresh = input.refresh
if (input.method.type === "oauth") {
const oauth = input as IntegrationOAuthMethodRegistration
const methodID = Integration.MethodID.make(oauth.method.id)
const refresh = oauth.refresh
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: { ...input.method, id: methodID },
authorize: (inputs) =>
input.authorize(inputs).pipe(
integrationID: Integration.ID.make(oauth.integrationID),
method: { ...oauth.method, id: methodID },
authorize: (inputs: IntegrationInputs) =>
oauth.authorize(inputs).pipe(
Effect.map((authorization) => {
if (authorization.mode === "auto") {
return {
@@ -256,7 +263,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
),
}
: {}),
...(input.label ? { label: input.label } : {}),
...(oauth.label ? { label: oauth.label } : {}),
})
return
}
@@ -267,9 +274,18 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
})
return
}
const registration = input as IntegrationKeyMethodRegistration
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: { type: "key", label: input.method.label },
integrationID: Integration.ID.make(registration.integrationID),
method: { type: "key", label: registration.method.label, prompts: registration.method.prompts },
...(registration.authorize
? {
authorize: (key, inputs) =>
registration.authorize!(key, inputs).pipe(
Effect.map((credential) => Schema.decodeUnknownSync(Credential.Key)(credential)),
),
}
: {}),
})
},
remove: (id, method) =>
+4
View File
@@ -7,6 +7,7 @@ import { CloudflareAIGatewayPlugin } from "./provider/cloudflare-ai-gateway"
import { CloudflareWorkersAIPlugin } from "./provider/cloudflare-workers-ai"
import { CoherePlugin } from "./provider/cohere"
import { DeepInfraPlugin } from "./provider/deepinfra"
import { DigitalOceanPlugin } from "./provider/digitalocean"
import { DynamicProviderPlugin } from "./provider/dynamic"
import { GatewayPlugin } from "./provider/gateway"
import { GithubCopilotPlugin } from "./provider/github-copilot"
@@ -24,6 +25,7 @@ import { OpenAICompatiblePlugin } from "./provider/openai-compatible"
import { OpencodePlugin } from "./provider/opencode"
import { OpenRouterPlugin } from "./provider/openrouter"
import { PerplexityPlugin } from "./provider/perplexity"
import { PoePlugin } from "./provider/poe"
import { SapAICorePlugin } from "./provider/sap-ai-core"
import { TogetherAIPlugin } from "./provider/togetherai"
import { VercelPlugin } from "./provider/vercel"
@@ -43,6 +45,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
CloudflareWorkersAIPlugin,
CoherePlugin,
DeepInfraPlugin,
DigitalOceanPlugin,
GatewayPlugin,
GithubCopilotPlugin,
GitLabPlugin,
@@ -60,6 +63,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
OpenAIPlugin,
OpenRouterPlugin,
PerplexityPlugin,
PoePlugin,
SapAICorePlugin,
TogetherAIPlugin,
VercelPlugin,
@@ -1,5 +1,7 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Credential } from "../../credential"
import { Integration } from "../../integration"
import { ProviderV2 } from "../../provider"
function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
@@ -13,6 +15,34 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
export const AzurePlugin = define({
id: "opencode.provider.azure",
effect: Effect.fn(function* (ctx) {
const integration = yield* Integration.Service
yield* integration.transform((draft) => {
draft.method.update({
integrationID: Integration.ID.make("azure"),
method: {
type: "key",
label: "API key",
prompts: process.env.AZURE_RESOURCE_NAME
? []
: [
{
type: "text",
key: "resourceName",
message: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
},
],
},
authorize: (key, inputs) =>
Effect.succeed(
Credential.Key.make({
type: "key",
key,
...(inputs.resourceName ? { metadata: { resourceName: inputs.resourceName } } : {}),
}),
),
})
})
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (!ProviderV2.isAISDK(item.provider.package)) continue
@@ -2,10 +2,54 @@ import os from "os"
import { InstallationVersion } from "../../installation/version"
import { Effect, Option, Schema } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Credential } from "../../credential"
import { Integration } from "../../integration"
const integrationID = Integration.ID.make("cloudflare-ai-gateway")
export const CloudflareAIGatewayPlugin = define({
id: "opencode.provider.cloudflare-ai-gateway",
effect: Effect.fn(function* (ctx) {
const integration = yield* Integration.Service
yield* integration.transform((draft) => {
draft.method.update({
integrationID,
method: {
type: "key",
label: "Gateway API token",
prompts: [
...(process.env.CLOUDFLARE_ACCOUNT_ID
? []
: [
{
type: "text" as const,
key: "accountId",
message: "Enter your Cloudflare Account ID",
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
},
]),
...(process.env.CLOUDFLARE_GATEWAY_ID
? []
: [
{
type: "text" as const,
key: "gatewayId",
message: "Enter your Cloudflare AI Gateway ID",
placeholder: "e.g. my-gateway",
},
]),
],
},
authorize: (key, inputs) =>
Effect.succeed(
Credential.Key.make({
type: "key",
key,
...(Object.keys(inputs).length ? { metadata: inputs } : {}),
}),
),
})
})
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
@@ -2,13 +2,44 @@ import os from "os"
import { InstallationVersion } from "../../installation/version"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Credential } from "../../credential"
import { Integration } from "../../integration"
import { ProviderV2 } from "../../provider"
const providerID = ProviderV2.ID.make("cloudflare-workers-ai")
const integrationID = Integration.ID.make("cloudflare-workers-ai")
export const CloudflareWorkersAIPlugin = define({
id: "opencode.provider.cloudflare-workers-ai",
effect: Effect.fn(function* (ctx) {
const integration = yield* Integration.Service
yield* integration.transform((draft) => {
draft.method.update({
integrationID,
method: {
type: "key",
label: "API key",
prompts: process.env.CLOUDFLARE_ACCOUNT_ID
? []
: [
{
type: "text",
key: "accountId",
message: "Enter your Cloudflare Account ID",
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
},
],
},
authorize: (key, inputs) =>
Effect.succeed(
Credential.Key.make({
type: "key",
key,
...(inputs.accountId ? { metadata: { accountId: inputs.accountId } } : {}),
}),
),
})
})
yield* ctx.catalog.transform((evt) => {
const item = evt.provider.get(providerID)
if (!item) return
@@ -0,0 +1,199 @@
import { createServer } from "node:http"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
import { Credential } from "../../credential"
import { EventV2 } from "../../event"
import { InstallationVersion } from "../../installation/version"
import { Integration } from "../../integration"
import { ModelV2 } from "../../model"
import { OauthCallbackPage } from "../../oauth/page"
import { ProviderV2 } from "../../provider"
import type { PluginInternal } from "../internal"
const clientID = "b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82"
const authorizeURL = "https://cloud.digitalocean.com/v1/oauth/authorize"
const genaiURL = "https://api.digitalocean.com/v2/gen-ai"
const inferenceURL = "https://inference.do-ai.run/v1"
const callbackPort = 1456
const callbackPath = "/auth/callback"
const tokenPath = "/auth/token"
const scopes = "genai:read inference:query"
const refreshInterval = 5 * 60 * 1000
const integrationID = Integration.ID.make("digitalocean")
const methodID = Integration.MethodID.make("implicit")
const Token = Schema.Struct({
access_token: Schema.String,
expires_in: Schema.NumberFromString,
state: Schema.String,
})
const Router = Schema.Struct({
name: Schema.String,
uuid: Schema.optional(Schema.String),
description: Schema.optional(Schema.String),
})
type Router = typeof Router.Type
const RouterResponse = Schema.Struct({ model_routers: Schema.optional(Schema.Array(Router)) })
const oauth = {
integrationID,
method: {
id: methodID,
type: "oauth",
label: "Login with DigitalOcean",
},
authorize: () =>
Effect.gen(function* () {
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex")
const token = yield* Deferred.make<typeof Token.Type, Error>()
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
if (request.method === "GET" && url.pathname === callbackPath) {
response
.writeHead(200, { "Content-Type": "text/html" })
.end(OauthCallbackPage.bootstrap({ tokenPath, provider: "DigitalOcean" }))
return
}
if (request.method !== "POST" || url.pathname !== tokenPath) {
response.writeHead(404).end("Not found")
return
}
const chunks: Buffer[] = []
request.on("data", (chunk: Buffer) => chunks.push(chunk))
request.on("end", () => {
const decoded = Schema.decodeUnknownOption(Schema.fromJsonString(Token))(Buffer.concat(chunks).toString())
if (Option.isNone(decoded) || decoded.value.state !== state) {
const error = Option.isNone(decoded) ? "Invalid OAuth callback" : "Invalid OAuth state"
Effect.runFork(Deferred.fail(token, new Error(error)))
response.writeHead(400, { "Content-Type": "application/json" }).end(JSON.stringify({ error }))
return
}
Effect.runFork(Deferred.succeed(token, decoded.value))
response.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ ok: true }))
})
})
yield* Effect.callback<void, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error)))
server.listen(callbackPort, "localhost", () => resume(Effect.void))
})
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
const redirect = `http://localhost:${callbackPort}${callbackPath}`
return {
mode: "auto" as const,
url: `${authorizeURL}?${new URLSearchParams({
response_type: "token",
client_id: clientID,
redirect_uri: redirect,
scope: scopes,
state,
})}`,
instructions:
"Sign in to DigitalOcean in your browser. OpenCode will use the resulting token for inference and load your Inference Routers.",
callback: Deferred.await(token).pipe(
Effect.flatMap((value) =>
listRouters(value.access_token).pipe(
Effect.catch((cause) =>
Effect.logWarning("failed to sync DigitalOcean inference routers", { cause }).pipe(Effect.as([])),
),
Effect.map((routers) =>
Credential.OAuth.make({
type: "oauth",
methodID,
refresh: value.access_token,
access: value.access_token,
expires: Date.now() + value.expires_in * 1000,
metadata: { routers, routersFetchedAt: Date.now() },
}),
),
),
),
),
}
}),
} satisfies IntegrationOAuthMethodRegistration
export const DigitalOceanPlugin = define({
id: "opencode.provider.digitalocean",
effect: Effect.fn(function* (ctx) {
const events = yield* EventV2.Service
const loading = Semaphore.makeUnsafe(1)
const loaded: { routers: readonly Router[] } = { routers: [] }
const load = Effect.fn("DigitalOceanPlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active(integrationID)
const saved = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
: undefined
if (saved?.type !== "oauth") {
loaded.routers = []
return
}
const cached = Schema.decodeUnknownOption(Schema.Array(Router))(saved.metadata?.routers)
loaded.routers = cached._tag === "Some" ? cached.value : []
const fetchedAt = saved.metadata?.routersFetchedAt
if (typeof fetchedAt === "number" && Date.now() - fetchedAt <= refreshInterval) return
if (saved.expires <= Date.now()) return
const routers = yield* listRouters(saved.access).pipe(
Effect.catch((cause) =>
Effect.logWarning("failed to refresh DigitalOcean inference routers", { cause }).pipe(Effect.as(undefined)),
),
)
if (!routers) return
loaded.routers = routers
})
yield* ctx.integration.transform((draft) => {
draft.update(integrationID, (integration) => {
integration.name = "DigitalOcean"
})
draft.method.update(oauth)
draft.method.update({
integrationID,
method: { type: "key", label: "Paste Model Access Key" },
})
})
yield* ctx.catalog.transform((catalog) => {
if (!catalog.provider.get(ProviderV2.ID.make(integrationID))) return
for (const router of loaded.routers) {
const id = ModelV2.ID.make(`router:${router.name}`)
catalog.model.update(ProviderV2.ID.make(integrationID), id, (model) => {
model.modelID = id
model.name = router.name
model.family = ModelV2.Family.make("digitalocean-inference-routers")
model.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
model.settings = ProviderV2.mergeOverlay(model.settings, { baseURL: inferenceURL })
model.capabilities = { tools: true, input: ["text"], output: ["text"] }
model.limit = { context: 128_000, output: 8_192 }
})
}
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
Stream.filter((event) => event.data.integrationID === integrationID),
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh()
}),
} satisfies PluginInternal.InternalPlugin)
function listRouters(access: string) {
return Effect.tryPromise({
try: async (signal) => {
const response = await fetch(`${genaiURL}/models/routers`, {
headers: {
Authorization: `Bearer ${access}`,
Accept: "application/json",
"User-Agent": `opencode/${InstallationVersion}`,
},
signal,
})
if (!response.ok) throw new Error(`DigitalOcean router request failed: ${response.status}`)
const body = Schema.decodeUnknownSync(RouterResponse)(await response.json())
return body.model_routers ?? []
},
catch: (cause) => cause,
})
}
+218 -1
View File
@@ -1,12 +1,153 @@
import { createServer } from "node:http"
import os from "os"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { InstallationVersion } from "../../installation/version"
import { Effect } from "effect"
import { Deferred, Effect, Schema } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Credential } from "../../credential"
import { Integration } from "../../integration"
import { OauthCallbackPage } from "../../oauth/page"
import { ProviderV2 } from "../../provider"
const defaultInstanceUrl = "https://gitlab.com"
const bundledClientID = "1d89f9fdb23ee96d4e603201f6861dab6e143c5c3c00469a018a2d94bdc03d4e"
const callbackHost = "127.0.0.1"
const callbackPort = 8080
const callbackPath = "/callback"
const redirectURI = `http://${callbackHost}:${callbackPort}${callbackPath}`
const methodID = Integration.MethodID.make("oauth")
const Token = Schema.Struct({
access_token: Schema.String,
refresh_token: Schema.optional(Schema.String),
expires_in: Schema.optional(Schema.Number),
})
type Token = typeof Token.Type
const oauth = {
integrationID: Integration.ID.make("gitlab"),
method: {
id: methodID,
type: "oauth",
label: "GitLab OAuth",
prompts: [
{
type: "text",
key: "instanceUrl",
message: "GitLab instance URL",
placeholder: defaultInstanceUrl,
},
],
},
authorize: (inputs) =>
Effect.gen(function* () {
const instanceUrl = normalizeInstanceUrl(inputs.instanceUrl)
const pkce = yield* Effect.promise(generatePKCE)
const state = randomString(32)
const code = yield* Deferred.make<string, Error>()
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", redirectURI)
if (url.pathname !== callbackPath) {
response.writeHead(404).end("Not found")
return
}
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
const value = url.searchParams.get("code")
if (error) {
Effect.runFork(Deferred.fail(code, new Error(error)))
response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(error, { provider: "GitLab" }))
return
}
if (!value || url.searchParams.get("state") !== state) {
const message = value ? "Invalid OAuth state" : "Missing authorization code"
Effect.runFork(Deferred.fail(code, new Error(message)))
response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(message, { provider: "GitLab" }))
return
}
Effect.runFork(Deferred.succeed(code, value))
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "GitLab" }))
})
yield* Effect.callback<void, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error)))
server.listen(callbackPort, callbackHost, () => resume(Effect.void))
})
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
return {
mode: "auto" as const,
url: `${instanceUrl}/oauth/authorize?${new URLSearchParams({
client_id: clientID(),
redirect_uri: redirectURI,
response_type: "code",
state,
scope: "api",
code_challenge: pkce.challenge,
code_challenge_method: "S256",
})}`,
instructions: "Complete authorization in your browser. This window will close automatically.",
callback: Deferred.await(code).pipe(
Effect.flatMap((value) =>
token(instanceUrl, {
grant_type: "authorization_code",
code: value,
redirect_uri: redirectURI,
client_id: clientID(),
code_verifier: pkce.verifier,
}),
),
Effect.flatMap((value) => credential(value, instanceUrl)),
),
}
}),
refresh: (value) => {
const instanceUrl = normalizeMetadataInstanceUrl(value.metadata?.instanceUrl)
return token(instanceUrl, {
grant_type: "refresh_token",
refresh_token: value.refresh,
client_id: clientID(),
}).pipe(Effect.flatMap((next) => credential(next, instanceUrl, next.refresh_token ?? value.refresh)))
},
label: (value) => (typeof value.metadata?.instanceUrl === "string" ? value.metadata.instanceUrl : undefined),
} satisfies IntegrationOAuthMethodRegistration
export const GitLabPlugin = define({
id: "opencode.provider.gitlab",
effect: Effect.fn(function* (ctx) {
yield* ctx.integration.transform((draft) => {
draft.method.update(oauth)
draft.method.update({
integrationID: Integration.ID.make("gitlab"),
method: {
type: "key",
label: "GitLab Personal Access Token",
prompts: [
{
type: "text",
key: "instanceUrl",
message: "GitLab instance URL",
placeholder: defaultInstanceUrl,
},
],
},
authorize: (key, inputs) =>
Effect.gen(function* () {
const instanceUrl = normalizeInstanceUrl(inputs.instanceUrl)
const response = yield* send(`${instanceUrl}/api/v4/user`, {
headers: { Authorization: `Bearer ${key}` },
})
if (!response.ok)
return yield* Effect.fail(new Error(`GitLab token validation failed (${response.status})`))
return Credential.Key.make({ type: "key", key, metadata: { instanceUrl } })
}),
})
draft.method.update({
integrationID: Integration.ID.make("gitlab"),
method: { type: "env", names: ["GITLAB_TOKEN"] },
})
})
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
@@ -63,3 +204,79 @@ export const GitLabPlugin = define({
)
}),
})
function clientID() {
return process.env.GITLAB_OAUTH_CLIENT_ID ?? bundledClientID
}
function normalizeInstanceUrl(value?: string) {
const input = value?.trim() || process.env.GITLAB_INSTANCE_URL || defaultInstanceUrl
return normalizeURL(input)
}
function normalizeMetadataInstanceUrl(value: unknown) {
if (typeof value !== "string" || !value) throw new Error("GitLab OAuth credential is missing instanceUrl metadata")
return normalizeURL(value)
}
function normalizeURL(value: string) {
const url = new URL(value)
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("GitLab instance URL must use http or https")
}
return `${url.protocol}//${url.host}`
}
function token(instanceUrl: string, body: Record<string, string>) {
return send(`${instanceUrl}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
body: new URLSearchParams(body).toString(),
}).pipe(
Effect.flatMap((response) => {
if (response.ok) {
return Effect.promise(() => response.json()).pipe(Effect.map(Schema.decodeUnknownSync(Token)))
}
return Effect.promise(() => response.text()).pipe(
Effect.flatMap((detail) =>
Effect.fail(new Error(`GitLab token request failed (${response.status})${detail ? `: ${detail}` : ""}`)),
),
)
}),
)
}
function send(url: string, init: RequestInit) {
return Effect.tryPromise({
try: (signal) => fetch(url, { ...init, signal }),
catch: (cause) => cause,
})
}
function credential(tokens: Token, instanceUrl: string, currentRefresh?: string) {
const refresh = tokens.refresh_token ?? currentRefresh
if (!refresh) return Effect.fail(new Error("GitLab token response is missing refresh_token"))
return Effect.succeed(
Credential.OAuth.make({
type: "oauth",
methodID,
access: tokens.access_token,
refresh,
expires: Date.now() + (tokens.expires_in ?? 7200) * 1000,
metadata: { instanceUrl },
}),
)
}
async function generatePKCE() {
const verifier = randomString(64)
const challenge = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString(
"base64url",
)
return { verifier, challenge }
}
function randomString(length: number) {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
return Array.from(crypto.getRandomValues(new Uint8Array(length)), (byte) => chars[byte % chars.length]).join("")
}
+141
View File
@@ -0,0 +1,141 @@
import { createServer } from "node:http"
import type { AddressInfo } from "node:net"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Deferred, Effect, Schema } from "effect"
import { Credential } from "../../credential"
import { Integration } from "../../integration"
import { OauthCallbackPage } from "../../oauth/page"
const clientID = "client_728290227fc048cc9262091a1ea197ea"
const authorizationEndpoint = "https://poe.com/oauth/authorize"
const tokenEndpoint = "https://api.poe.com/token"
const callbackPath = "/callback"
const integrationID = Integration.ID.make("poe")
const methodID = Integration.MethodID.make("browser")
const Token = Schema.Struct({
api_key: Schema.String,
api_key_expires_in: Schema.optional(Schema.NullOr(Schema.Number)),
})
const oauth = {
integrationID,
method: {
id: methodID,
type: "oauth",
label: "Login with Poe (browser)",
},
authorize: () =>
Effect.gen(function* () {
const verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
const challenge = Buffer.from(
yield* Effect.promise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))),
).toString("base64url")
const code = yield* Deferred.make<string, Error>()
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1")
if (url.pathname !== callbackPath) {
response.writeHead(404).end("Not found")
return
}
const error = url.searchParams.get("error")
if (error) {
const description = url.searchParams.get("error_description") ?? error
Effect.runFork(Deferred.fail(code, new Error(`OAuth authorization failed: ${error} - ${description}`)))
response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(description, { provider: "Poe" }))
return
}
const value = url.searchParams.get("code")
if (!value) {
Effect.runFork(Deferred.fail(code, new Error("OAuth callback missing authorization code")))
response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error("Missing authorization code", { provider: "Poe" }))
return
}
Effect.runFork(Deferred.succeed(code, value))
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "Poe" }))
})
yield* Effect.callback<void, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error)))
server.listen(0, "127.0.0.1", () => resume(Effect.void))
})
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
server.closeAllConnections()
server.close()
}),
)
const address = server.address() as AddressInfo
const redirectURI = `http://127.0.0.1:${address.port}${callbackPath}`
return {
mode: "auto" as const,
url: `${authorizationEndpoint}?${new URLSearchParams({
response_type: "code",
client_id: clientID,
scope: "apikey:create",
code_challenge: challenge,
code_challenge_method: "S256",
redirect_uri: redirectURI,
})}`,
instructions: "Complete authorization in your browser. This window will close automatically.",
callback: Deferred.await(code).pipe(
Effect.flatMap((value) =>
Effect.tryPromise({
try: (signal) =>
fetch(tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: value,
code_verifier: verifier,
client_id: clientID,
redirect_uri: redirectURI,
}).toString(),
signal,
}),
catch: (cause) => cause,
}),
),
Effect.flatMap((response) => {
if (response.ok)
return Effect.promise(() => response.json()).pipe(Effect.map(Schema.decodeUnknownSync(Token)))
return Effect.promise(() => response.text()).pipe(
Effect.flatMap((detail) =>
Effect.fail(new Error(`Poe token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`)),
),
)
}),
Effect.map((token) =>
Credential.OAuth.make({
type: "oauth",
methodID,
access: token.api_key,
refresh: token.api_key,
expires:
token.api_key_expires_in == null
? Number.MAX_SAFE_INTEGER
: Date.now() + token.api_key_expires_in * 1000,
}),
),
),
}
}),
} satisfies IntegrationOAuthMethodRegistration
export const PoePlugin = define({
id: "opencode.provider.poe",
effect: Effect.fn(function* (ctx) {
yield* ctx.integration.transform((draft) => {
draft.update(integrationID, (integration) => {
integration.name = "Poe"
})
draft.method.update(oauth)
draft.method.update({ integrationID, method: { type: "key", label: "Manually enter API Key" } })
})
}),
})
@@ -1,12 +1,46 @@
import { Effect } from "effect"
import { createServer } from "node:http"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Deferred, Effect, Schema } from "effect"
import { Credential } from "../../credential"
import { InstallationVersion } from "../../installation/version"
import { Integration } from "../../integration"
import { OauthCallbackPage } from "../../oauth/page"
import { ProviderV2 } from "../../provider"
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
const integrationID = Integration.ID.make("snowflake-cortex")
const browserMethodID = Integration.MethodID.make("snowflake-browser")
const clientID = "LOCAL_APPLICATION"
const callbackHost = "127.0.0.1"
type TokenResponse = {
access_token: string
refresh_token?: string
expires_in?: number
}
const TokenResponse = Schema.Struct({
access_token: Schema.String,
refresh_token: Schema.optional(Schema.String),
expires_in: Schema.optional(Schema.Number),
})
export function oauthScope(role: string | undefined) {
if (!role) return "refresh_token"
return /^[-_A-Za-z0-9]+$/.test(role)
? `refresh_token session:role:${role}`
: `refresh_token session:role-encoded:${encodeURIComponent(role)}`
}
// Exported for testing: intercepts Cortex-specific request/response quirks.
export function cortexFetch(upstream: FetchLike = fetch) {
return async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
const headers = new Headers(url instanceof Request ? url.headers : undefined)
if (init?.headers) new Headers(init.headers).forEach((value, key) => headers.set(key, value))
headers.set("User-Agent", `opencode/${InstallationVersion}`)
init = { ...init, headers }
if (init?.body && typeof init.body === "string") {
try {
const body = JSON.parse(init.body)
@@ -67,10 +101,24 @@ export function cortexFetch(upstream: FetchLike = fetch) {
export const SnowflakeCortexPlugin = define({
id: "opencode.provider.snowflake-cortex",
effect: Effect.fn(function* (ctx) {
yield* ctx.integration.transform((draft) => {
draft.method.update(browser)
draft.method.update({
integrationID: "snowflake-cortex",
method: { type: "key", label: "Paste PAT or bearer token manually" },
})
draft.method.update({
integrationID: "snowflake-cortex",
method: { type: "env", names: ["SNOWFLAKE_CORTEX_TOKEN", "SNOWFLAKE_CORTEX_PAT"] },
})
})
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return
const account = normalizeAccount(
process.env.SNOWFLAKE_ACCOUNT ?? (typeof evt.options.account === "string" ? evt.options.account : ""),
)
const token =
process.env.SNOWFLAKE_CORTEX_TOKEN ??
process.env.SNOWFLAKE_CORTEX_PAT ??
@@ -78,6 +126,7 @@ export const SnowflakeCortexPlugin = define({
(typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined)
const upstream = typeof evt.options.fetch === "function" ? (evt.options.fetch as FetchLike) : undefined
if (evt.options.includeUsage !== false) evt.options.includeUsage = true
if (account) evt.options.baseURL = `https://${account}.snowflakecomputing.com/api/v2/cortex/v1`
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
evt.sdk = mod.createOpenAICompatible({
...evt.options,
@@ -88,3 +137,180 @@ export const SnowflakeCortexPlugin = define({
)
}),
})
const accountPrompt = {
type: "text" as const,
key: "account",
message: "Snowflake Account Identifier",
placeholder: "myorg-myaccount",
}
const browser = {
integrationID,
method: {
id: browserMethodID,
type: "oauth",
label: "Login with Snowflake (External Browser)",
prompts: [
accountPrompt,
{
type: "text",
key: "role",
message: "Snowflake Role (optional)",
placeholder: "PUBLIC",
},
],
},
authorize: (inputs) =>
Effect.gen(function* () {
const account = normalizeAccount(inputs.account ?? "")
if (!account) return yield* Effect.fail(new Error("Snowflake account is required"))
const pkce = yield* Effect.promise(generatePKCE)
const state = randomString(64)
const code = yield* Deferred.make<string, Error>()
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", `http://${callbackHost}`)
if (url.pathname !== "/") {
response.writeHead(404).end("Not found")
return
}
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
const value = url.searchParams.get("code")
if (error) {
Effect.runFork(Deferred.fail(code, new Error(error)))
response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(error, { provider: "Snowflake" }))
return
}
if (!value || url.searchParams.get("state") !== state) {
const message = value ? "Invalid state - potential CSRF attack" : "Missing authorization code"
Effect.runFork(Deferred.fail(code, new Error(message)))
response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
return
}
Effect.runFork(Deferred.succeed(code, value))
response
.writeHead(200, { "Content-Type": "text/html" })
.end(OauthCallbackPage.success({ provider: "Snowflake" }))
})
const port = yield* Effect.callback<number, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error)))
server.listen(0, callbackHost, () => {
const address = server.address()
if (!address || typeof address === "string") {
resume(Effect.fail(new Error("Unable to resolve Snowflake OAuth callback port")))
return
}
resume(Effect.succeed(address.port))
})
})
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
const redirect = `http://${callbackHost}:${port}/`
const role = inputs.role?.trim() || undefined
const url = `https://${account}.snowflakecomputing.com/oauth/authorize?${new URLSearchParams({
client_id: clientID,
response_type: "code",
redirect_uri: redirect,
scope: oauthScope(role),
state,
code_challenge: pkce.challenge,
code_challenge_method: "S256",
})}`
return {
mode: "auto" as const,
url,
instructions:
"Complete Snowflake sign-in in your browser. OpenCode will capture the OAuth callback and store the bearer token automatically.",
callback: Deferred.await(code).pipe(
Effect.flatMap((value) =>
token(account, {
grant_type: "authorization_code",
code: value,
redirect_uri: redirect,
client_id: clientID,
code_verifier: pkce.verifier,
}),
),
Effect.flatMap((value) =>
value.refresh_token
? Effect.succeed(credential(value, account, value.refresh_token))
: Effect.fail(
new Error(
"Snowflake token response did not include refresh_token. Ensure integration issues refresh tokens and scope includes refresh_token.",
),
),
),
),
}
}),
refresh: (value) => {
const account = typeof value.metadata?.account === "string" ? normalizeAccount(value.metadata.account) : ""
if (!account) return Effect.fail(new Error("Snowflake OAuth credential is missing account metadata"))
return token(account, {
grant_type: "refresh_token",
refresh_token: value.refresh,
client_id: clientID,
}).pipe(Effect.map((next) => credential(next, account, next.refresh_token ?? value.refresh)))
},
label: (value) => (typeof value.metadata?.account === "string" ? value.metadata.account : undefined),
} satisfies IntegrationOAuthMethodRegistration
function token(account: string, body: Record<string, string>) {
return Effect.tryPromise({
try: async (signal) => {
const response = await fetch(`https://${account}.snowflakecomputing.com/oauth/token-request`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
Authorization: `Basic ${Buffer.from(`${clientID}:${clientID}`).toString("base64")}`,
"User-Agent": `opencode/${InstallationVersion}`,
},
body: new URLSearchParams(body).toString(),
signal,
})
if (!response.ok) {
const detail = await response.text().catch(() => "")
throw new Error(`Snowflake token request failed (${response.status})${detail ? `: ${detail}` : ""}`)
}
return Schema.decodeUnknownSync(TokenResponse)(await response.json())
},
catch: (cause) => cause,
})
}
function credential(tokens: TokenResponse, account: string, refresh: string) {
return Credential.OAuth.make({
type: "oauth",
methodID: browserMethodID,
access: tokens.access_token,
refresh,
expires: Date.now() + (tokens.expires_in ?? 600) * 1000,
metadata: { account },
})
}
function normalizeAccount(input: string) {
return input
.trim()
.replace(/^https?:\/\//, "")
.replace(/\.snowflakecomputing\.com\/?$/, "")
.replace(/\/+$/, "")
}
async function generatePKCE() {
const verifier = randomString(64)
const challenge = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString(
"base64url",
)
return { verifier, challenge }
}
function randomString(length: number) {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
return Array.from(crypto.getRandomValues(new Uint8Array(length)), (byte) => chars[byte % chars.length]).join("")
}
+301 -1
View File
@@ -1,10 +1,146 @@
import { Effect } from "effect"
import { createServer } from "node:http"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Clock, Deferred, Effect, Option, Schema } from "effect"
import { Credential } from "../../credential"
import { InstallationVersion } from "../../installation/version"
import { Integration } from "../../integration"
import { OauthCallbackPage } from "../../oauth/page"
import { ProviderV2 } from "../../provider"
const clientID = "b1a00492-073a-47ea-816f-4c329264a828"
const issuer = "https://auth.x.ai/oauth2"
const deviceGrant = "urn:ietf:params:oauth:grant-type:device_code"
const scope = "openid profile email offline_access grok-cli:access api:access"
const callbackHost = "127.0.0.1"
const callbackPort = 56121
const callbackPath = "/callback"
const redirectURI = `http://${callbackHost}:${callbackPort}${callbackPath}`
const pollingSafetyMargin = 3000
const browserMethodID = Integration.MethodID.make("browser")
const deviceMethodID = Integration.MethodID.make("device")
type Pkce = {
verifier: string
challenge: string
}
const Token = Schema.Struct({
access_token: Schema.String,
refresh_token: Schema.optional(Schema.String),
expires_in: Schema.optional(Schema.Number),
})
type Token = typeof Token.Type
const Device = Schema.Struct({
device_code: Schema.String,
user_code: Schema.String,
verification_uri: Schema.String,
verification_uri_complete: Schema.optional(Schema.String),
expires_in: Schema.optional(Schema.Number),
interval: Schema.optional(Schema.Number),
})
const DeviceError = Schema.Struct({
error: Schema.optional(Schema.String),
error_description: Schema.optional(Schema.String),
})
const decodeDeviceError = Schema.decodeUnknownOption(Schema.fromJsonString(DeviceError))
const browser = {
integrationID: Integration.ID.make("xai"),
method: {
id: browserMethodID,
type: "oauth",
label: "xAI Grok OAuth (SuperGrok Subscription)",
},
authorize: () =>
Effect.gen(function* () {
const pkce = yield* Effect.promise(generatePKCE)
const state = randomString(32)
const code = yield* Deferred.make<string, Error>()
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", redirectURI)
if (url.pathname !== callbackPath) {
response.writeHead(404).end("Not found")
return
}
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
const value = url.searchParams.get("code")
if (error) {
Effect.runFork(Deferred.fail(code, new Error(error)))
response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(error, { provider: "xAI" }))
return
}
if (!value || url.searchParams.get("state") !== state) {
const message = value ? "Invalid OAuth state" : "Missing authorization code"
Effect.runFork(Deferred.fail(code, new Error(message)))
response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(message, { provider: "xAI" }))
return
}
Effect.runFork(Deferred.succeed(code, value))
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "xAI" }))
})
yield* Effect.callback<void, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error)))
server.listen(callbackPort, callbackHost, () => resume(Effect.void))
})
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
return {
mode: "auto" as const,
url: authorizeURL(pkce, state, randomString(32)),
instructions: "Complete authorization in your browser. This window will close automatically.",
callback: Deferred.await(code).pipe(
Effect.flatMap((value) => exchange(value, pkce)),
Effect.flatMap((tokens) => credential(browserMethodID, tokens)),
),
}
}),
refresh: (value) => refresh(browserMethodID, Credential.OAuth.make({ ...value, methodID: browserMethodID })),
} satisfies IntegrationOAuthMethodRegistration
const device = {
integrationID: Integration.ID.make("xai"),
method: {
id: deviceMethodID,
type: "oauth",
label: "xAI Grok OAuth (Headless / Remote / VPS)",
},
authorize: () =>
request(
`${issuer}/device/code`,
{
method: "POST",
headers: headers(),
body: new URLSearchParams({ client_id: clientID, scope }).toString(),
},
Device,
).pipe(
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
export const XAIPlugin = define({
id: "opencode.provider.xai",
effect: Effect.fn(function* (ctx) {
yield* ctx.integration.transform((draft) => {
draft.update("xai", (integration) => {
integration.name = "xAI"
})
draft.method.update(browser)
draft.method.update(device)
draft.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
})
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
@@ -22,3 +158,167 @@ export const XAIPlugin = define({
)
}),
})
function exchange(code: string, pkce: Pkce) {
return request(
`${issuer}/token`,
{
method: "POST",
headers: headers(),
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectURI,
client_id: clientID,
code_verifier: pkce.verifier,
}).toString(),
},
Token,
)
}
function refresh(methodID: Integration.MethodID, value: Credential.OAuth) {
return request(
`${issuer}/token`,
{
method: "POST",
headers: headers(),
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: value.refresh,
client_id: clientID,
}).toString(),
},
Token,
).pipe(Effect.flatMap((tokens) => credential(methodID, tokens, value.refresh, value.metadata)))
}
function poll(device: typeof Device.Type): Effect.Effect<Token, unknown> {
return Effect.gen(function* () {
const started = yield* Clock.currentTimeMillis
const expires = started + positiveSeconds(device.expires_in, 300) * 1000
const loop = (interval: number): Effect.Effect<Token, unknown> =>
Effect.gen(function* () {
if ((yield* Clock.currentTimeMillis) >= expires) {
return yield* Effect.fail(new Error("xAI device authorization timed out"))
}
const response = yield* send(`${issuer}/token`, {
method: "POST",
headers: headers(),
body: new URLSearchParams({
grant_type: deviceGrant,
client_id: clientID,
device_code: device.device_code,
}).toString(),
})
if (response.ok) return yield* decode(response, Token)
const error = yield* Effect.promise(() => response.text()).pipe(
Effect.map((body) => Option.getOrUndefined(decodeDeviceError(body))),
Effect.catch(() => Effect.succeed(undefined)),
)
if (error?.error === "authorization_pending") {
return yield* Effect.sleep(interval + pollingSafetyMargin).pipe(Effect.andThen(loop(interval)))
}
if (error?.error === "slow_down") {
const next = interval + 5000
return yield* Effect.sleep(next + pollingSafetyMargin).pipe(Effect.andThen(loop(next)))
}
if (error?.error === "access_denied" || error?.error === "authorization_denied") {
return yield* Effect.fail(new Error("xAI device authorization was denied"))
}
if (error?.error === "expired_token") {
return yield* Effect.fail(new Error("xAI device code expired - please re-run login"))
}
const detail = error?.error_description ?? error?.error
return yield* Effect.fail(
new Error(`xAI device token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`),
)
})
return yield* loop(Math.max(positiveSeconds(device.interval, 5) * 1000, 1000))
})
}
function request<S extends Schema.Decoder<unknown>>(url: string, init: RequestInit, schema: S) {
return send(url, init).pipe(
Effect.flatMap((response) => {
if (response.ok) return decode(response, schema)
return Effect.promise(() => response.text()).pipe(
Effect.flatMap((detail) =>
Effect.fail(new Error(`xAI request failed (${response.status})${detail ? `: ${detail}` : ""}`)),
),
)
}),
)
}
function send(url: string, init: RequestInit) {
return Effect.tryPromise({
try: (signal) => fetch(url, { ...init, signal }),
catch: (cause) => cause,
})
}
function decode<S extends Schema.Decoder<unknown>>(response: Response, schema: S) {
return Effect.promise(() => response.json()).pipe(Effect.map(Schema.decodeUnknownSync(schema)))
}
function credential(
methodID: Integration.MethodID,
tokens: Token,
currentRefresh?: string,
metadata?: Readonly<Record<string, unknown>>,
) {
const refresh = tokens.refresh_token ?? currentRefresh
if (!refresh) return Effect.fail(new Error("xAI token response is missing refresh_token"))
return Effect.succeed(
Credential.OAuth.make({
type: "oauth",
methodID,
refresh,
access: tokens.access_token,
expires: Date.now() + positiveSeconds(tokens.expires_in, 3600) * 1000,
metadata,
}),
)
}
function headers() {
return {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"User-Agent": `opencode/${InstallationVersion}`,
}
}
function positiveSeconds(value: unknown, fallback: number) {
const seconds = Number(value)
return Number.isFinite(seconds) && seconds > 0 ? seconds : fallback
}
async function generatePKCE(): Promise<Pkce> {
const verifier = randomString(64)
const challenge = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString(
"base64url",
)
return { verifier, challenge }
}
function randomString(length: number) {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
return Array.from(crypto.getRandomValues(new Uint8Array(length)), (byte) => chars[byte % chars.length]).join("")
}
function authorizeURL(pkce: Pkce, state: string, nonce: string) {
return `${issuer}/authorize?${new URLSearchParams({
response_type: "code",
client_id: clientID,
redirect_uri: redirectURI,
scope,
code_challenge: pkce.challenge,
code_challenge_method: "S256",
state,
nonce,
plan: "generic",
referrer: "opencode",
})}`
}
+2 -2
View File
@@ -64,7 +64,7 @@ const layer = Layer.effect(
if (source.type === "local") {
materialized.set(
name,
new Info({
Info.make({
name,
path: source.path,
...(source.description === undefined ? {} : { description: source.description }),
@@ -88,7 +88,7 @@ const layer = Layer.effect(
seen.set(target, source.branch)
materialized.set(
name,
new Info({
Info.make({
name,
path: AbsolutePath.make(target),
...(source.description === undefined ? {} : { description: source.description }),
@@ -220,8 +220,31 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
yield* flushFragments()
})
const failTools = Effect.fnUntraced(function* (error: SessionError.Error, mode: "all" | "hosted" | "uncalled") {
let failed = false
for (const [callID, tool] of tools) {
if (
tool.settled ||
(mode === "hosted" && !tool.providerExecuted) ||
(mode === "uncalled" && tool.called)
)
continue
tool.settled = true
failed = true
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
error,
executed: tool.providerExecuted,
})
}
return failed
})
const failAssistant = Effect.fnUntraced(function* (error: SessionError.Error, replace = false) {
yield* flush()
yield* failTools(error, "uncalled")
yield* startAssistant()
if (replace || stepFailure === undefined) stepFailure = error
})
@@ -245,20 +268,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
error: SessionError.Error,
hostedOnly = false,
) {
let failed = false
for (const [callID, tool] of tools) {
if (tool.settled || (hostedOnly && !tool.providerExecuted)) continue
tool.settled = true
failed = true
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
error,
executed: tool.providerExecuted,
})
}
return failed
return yield* failTools(error, hostedOnly ? "hosted" : "all")
})
const assistantMessageIDForTool = (callID: string) => {
+28 -9
View File
@@ -12,8 +12,8 @@ import { Tool } from "./tool"
export const name = "subagent"
const NO_TEXT = "Subagent completed without a text response."
const BACKGROUND_STARTED =
"The subagent is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll, or proactively check on its progress."
const backgroundStarted = (sessionID: SessionSchema.ID) =>
`The subagent is working in the background (id: ${sessionID}). You will be notified automatically when it finishes. DO NOT sleep, poll, or proactively check on its progress.`
export const Input = Schema.Struct({
agent: Schema.String.annotate({ description: "The configured agent to run as the subagent" }),
@@ -65,6 +65,7 @@ export const Plugin = {
const injectCompletion = Effect.fn("SubagentTool.injectCompletion")(function* (
parentID: SessionSchema.ID,
childID: SessionSchema.ID,
agent: string,
description: string,
state: "completed" | "error" | "cancelled",
text: string,
@@ -72,22 +73,32 @@ export const Plugin = {
yield* runtime.session.synthetic({
sessionID: parentID,
text: `<subagent id="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
description,
metadata: { source: "subagent", childID, agent, state },
})
})
const notifyWhenDone = Effect.fn("SubagentTool.notifyWhenDone")(function* (
parentID: SessionSchema.ID,
childID: SessionSchema.ID,
agent: string,
description: string,
) {
yield* runtime.job.wait({ id: childID }).pipe(
Effect.flatMap((result) => {
if (result.info?.status === "completed")
return injectCompletion(parentID, childID, description, "completed", result.info.output ?? NO_TEXT)
return injectCompletion(parentID, childID, agent, description, "completed", result.info.output ?? NO_TEXT)
if (result.info?.status === "error")
return injectCompletion(parentID, childID, description, "error", result.info.error ?? "Subagent failed")
return injectCompletion(
parentID,
childID,
agent,
description,
"error",
result.info.error ?? "Subagent failed",
)
if (result.info?.status === "cancelled")
return injectCompletion(parentID, childID, description, "cancelled", "Subagent cancelled")
return injectCompletion(parentID, childID, agent, description, "cancelled", "Subagent cancelled")
return Effect.void
}),
Effect.forkIn(scope, { startImmediately: true }),
@@ -167,8 +178,12 @@ export const Plugin = {
if (background) {
yield* runtime.job.background(info.id)
yield* notifyWhenDone(context.sessionID, child.id, input.description)
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description)
return {
sessionID: child.id,
status: "running" as const,
output: backgroundStarted(child.id),
}
}
const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe(
@@ -179,8 +194,12 @@ export const Plugin = {
),
)
if (result?.type === "backgrounded") {
yield* notifyWhenDone(context.sessionID, child.id, input.description)
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description)
return {
sessionID: child.id,
status: "running" as const,
output: backgroundStarted(child.id),
}
}
if (result?.info.status === "error")
return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
-47
View File
@@ -2,8 +2,6 @@ import { describe, expect } from "bun:test"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Ref, Schema, Stream } from "effect"
import { EventV2 } from "@opencode-ai/core/event"
import { Event } from "@opencode-ai/schema/event"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Session } from "@opencode-ai/schema/session"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { SessionV1 } from "@opencode-ai/schema/session-v1"
@@ -363,51 +361,6 @@ describe("EventV2", () => {
}),
)
it.effect("ends only an overflowing bounded subscriber without blocking other listeners", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const consuming = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const slowStream = yield* EventV2.liveBounded(events, { capacity: 1 })
const fastStream = yield* EventV2.liveBounded(events, { capacity: 8 })
const slow = yield* slowStream.pipe(
Stream.runForEach(() => Deferred.succeed(consuming, undefined).pipe(Effect.andThen(Deferred.await(release)))),
Effect.forkScoped,
)
const fast = yield* fastStream.pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped)
yield* events.publish(Message, { text: "one" })
yield* Deferred.await(consuming)
yield* events.publish(Message, { text: "two" })
yield* events.publish(Message, { text: "overflow" })
const last = yield* events.publish(Message, { text: "still delivered" })
yield* Deferred.succeed(release, undefined)
const slowExit = yield* Fiber.await(slow)
expect(Exit.findErrorOption(slowExit).pipe(Option.getOrUndefined)).toBeInstanceOf(EventV2.SubscriberOverflowError)
expect(Array.from(yield* Fiber.join(fast))).toEqual([
expect.objectContaining({ data: { text: "one" } }),
expect.objectContaining({ data: { text: "two" } }),
expect.objectContaining({ data: { text: "overflow" } }),
last,
])
}),
)
it.effect("filters internal events before they enter a bounded server stream", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const stream = yield* EventV2.liveBounded(events, { capacity: 1, accept: EventManifest.isServer })
const received = yield* stream.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* events.publish(McpEvent.ToolsChanged, { server: "one" })
yield* events.publish(McpEvent.ToolsChanged, { server: "two" })
const published = yield* events.publish(McpEvent.StatusChanged, { server: "example" })
expect(Array.from(yield* Fiber.join(received))).toEqual([published])
}),
)
it.effect("preserves observer interruption", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
+1 -1
View File
@@ -40,7 +40,7 @@ describe("Integration", () => {
.transform((editor) => editor.update(openai, (integration) => (integration.name = "OpenAI")))
.pipe(Scope.provide(scope))
expect(yield* integrations.get(openai)).toEqual(
new Integration.Info({ id: openai, name: "OpenAI", methods: [], connections: [] }),
Integration.Info.make({ id: openai, name: "OpenAI", methods: [], connections: [] }),
)
yield* Scope.close(scope, Exit.void)
+22 -10
View File
@@ -218,14 +218,16 @@ export function integrationHost(integration: Integration.Interface): PluginConte
method: {
list: (id) => draft.method.list(Integration.ID.make(id)).map(method),
update: (input) => {
if ("authorize" in input) {
const methodID = Integration.MethodID.make(input.method.id)
const refresh = input.refresh
if (input.method.type === "oauth") {
const oauth =
input as import("@opencode-ai/plugin/v2/effect/integration").IntegrationOAuthMethodRegistration
const methodID = Integration.MethodID.make(oauth.method.id)
const refresh = oauth.refresh
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: { ...input.method, id: methodID },
authorize: (inputs) =>
input.authorize(inputs).pipe(
integrationID: Integration.ID.make(oauth.integrationID),
method: { ...oauth.method, id: methodID },
authorize: (inputs: import("@opencode-ai/plugin/v2/effect/integration").IntegrationInputs) =>
oauth.authorize(inputs).pipe(
Effect.map((authorization) => {
if (authorization.mode === "auto") {
return {
@@ -267,7 +269,7 @@ export function integrationHost(integration: Integration.Interface): PluginConte
),
}
: {}),
...(input.label ? { label: input.label } : {}),
...(oauth.label ? { label: oauth.label } : {}),
})
return
}
@@ -278,9 +280,19 @@ export function integrationHost(integration: Integration.Interface): PluginConte
})
return
}
const registration =
input as import("@opencode-ai/plugin/v2/effect/integration").IntegrationKeyMethodRegistration
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: input.method,
integrationID: Integration.ID.make(registration.integrationID),
method: registration.method,
...(registration.authorize
? {
authorize: (key, inputs) =>
registration.authorize!(key, inputs).pipe(
Effect.map((credential) => Credential.Key.make(credential)),
),
}
: {}),
})
},
remove: (id, item) => draft.method.remove(Integration.ID.make(id), internalMethod(item)),
+1 -1
View File
@@ -214,7 +214,7 @@ describe("ModelsDevPlugin", () => {
}),
)
expect(yield* integrations.list()).toEqual([
new Integration.Info({
Integration.Info.make({
id: Integration.ID.make("acme"),
name: "Acme",
methods: [
@@ -3,6 +3,7 @@ import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
@@ -60,6 +61,55 @@ function fakeSelectorSdk(calls: string[]) {
}
describe("AzurePlugin", () => {
it.effect("registers an API key method and stores prompted resource metadata", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
yield* addPlugin()
expect((yield* integrations.get(Integration.ID.make("azure")))?.methods).toEqual([
{
type: "key",
label: "API key",
prompts: [
{
type: "text",
key: "resourceName",
message: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
},
],
},
])
yield* integrations.connection.key({
integrationID: Integration.ID.make("azure"),
key: "secret",
inputs: { resourceName: "my-models" },
})
const connection = required(yield* integrations.connection.active(Integration.ID.make("azure")))
expect(yield* integrations.connection.resolve(connection)).toMatchObject({
type: "key",
key: "secret",
metadata: { resourceName: "my-models" },
})
}),
),
)
it.effect("omits the resource prompt when Azure resource env is configured", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
yield* addPlugin()
expect((yield* integrations.get(Integration.ID.make("azure")))?.methods).toContainEqual({
type: "key",
label: "API key",
prompts: [],
})
}),
),
)
it.effect("resolves resourceName from env", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () {
@@ -1,6 +1,7 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
@@ -102,6 +103,69 @@ mock.module("ai-gateway-provider/providers/unified", () => ({
}))
describe("CloudflareAIGatewayPlugin", () => {
it.effect("registers a gateway token method and stores prompted gateway metadata", () =>
withEnv(
cloudflareEnv({
CLOUDFLARE_ACCOUNT_ID: undefined,
CLOUDFLARE_GATEWAY_ID: undefined,
CLOUDFLARE_API_TOKEN: undefined,
}),
() =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
yield* addPlugin()
const integrationID = Integration.ID.make("cloudflare-ai-gateway")
expect((yield* integrations.get(integrationID))?.methods).toEqual([
{
type: "key",
label: "Gateway API token",
prompts: [
{
type: "text",
key: "accountId",
message: "Enter your Cloudflare Account ID",
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
},
{
type: "text",
key: "gatewayId",
message: "Enter your Cloudflare AI Gateway ID",
placeholder: "e.g. my-gateway",
},
],
},
])
yield* integrations.connection.key({
integrationID,
key: "secret",
inputs: { accountId: "acct", gatewayId: "gateway" },
})
const connection = yield* integrations.connection.active(integrationID)
if (!connection) throw new Error("Expected connection")
expect(yield* integrations.connection.resolve(connection)).toMatchObject({
type: "key",
key: "secret",
metadata: { accountId: "acct", gatewayId: "gateway" },
})
}),
),
)
it.effect("omits gateway prompts backed by Cloudflare env", () =>
withEnv(cloudflareEnv(), () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
yield* addPlugin()
expect((yield* integrations.get(Integration.ID.make("cloudflare-ai-gateway")))?.methods).toContainEqual({
type: "key",
label: "Gateway API token",
prompts: [],
})
}),
),
)
it.effect("requires account, gateway, and token before creating the unified SDK", () =>
withEnv(
{
@@ -2,6 +2,7 @@ import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
@@ -79,6 +80,56 @@ function cloudflareHeaders(sdk: unknown, modelID = "@cf/model") {
}
describe("CloudflareWorkersAIPlugin", () => {
it.effect("registers an API key method and stores prompted account metadata", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined }, () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
yield* addPlugin()
const integrationID = Integration.ID.make("cloudflare-workers-ai")
expect((yield* integrations.get(integrationID))?.methods).toEqual([
{
type: "key",
label: "API key",
prompts: [
{
type: "text",
key: "accountId",
message: "Enter your Cloudflare Account ID",
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
},
],
},
])
yield* integrations.connection.key({
integrationID,
key: "secret",
inputs: { accountId: "acct" },
})
const connection = required(yield* integrations.connection.active(integrationID))
expect(yield* integrations.connection.resolve(connection)).toMatchObject({
type: "key",
key: "secret",
metadata: { accountId: "acct" },
})
}),
),
)
it.effect("omits the account prompt when Cloudflare account env is configured", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
yield* addPlugin()
expect((yield* integrations.get(Integration.ID.make("cloudflare-workers-ai")))?.methods).toContainEqual({
type: "key",
label: "API key",
prompts: [],
})
}),
),
)
it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
@@ -0,0 +1,95 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { DigitalOceanPlugin } from "@opencode-ai/core/plugin/provider/digitalocean"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const integrationID = Integration.ID.make("digitalocean")
const providerID = ProviderV2.ID.make("digitalocean")
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin)
yield* DigitalOceanPlugin.effect(host)
})
describe("DigitalOceanPlugin", () => {
it.effect("registers implicit OAuth and manual model access keys", () =>
Effect.gen(function* () {
yield* addPlugin()
expect((yield* (yield* Integration.Service).get(integrationID))?.methods).toEqual([
{
id: Integration.MethodID.make("implicit"),
type: "oauth",
label: "Login with DigitalOcean",
},
{ type: "key", label: "Paste Model Access Key" },
])
}),
)
it.effect("adds cached inference routers to the DigitalOcean catalog", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const credentials = yield* Credential.Service
yield* catalog.transform((draft) => {
draft.provider.update(providerID, (provider) => {
provider.name = "DigitalOcean"
})
})
yield* credentials.create({
integrationID,
value: Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("implicit"),
refresh: "token",
access: "token",
expires: Date.now() + 60_000,
metadata: {
routers: [
{ name: "production", uuid: "router-1" },
{ name: "support", description: "Support router" },
],
routersFetchedAt: Date.now(),
},
}),
})
yield* addPlugin()
const model = yield* catalog.model.get(providerID, ModelV2.ID.make("router:production"))
expect(model).toMatchObject({
id: "router:production",
modelID: "router:production",
providerID: "digitalocean",
name: "production",
family: "digitalocean-inference-routers",
package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: "https://inference.do-ai.run/v1" },
capabilities: { tools: true, input: ["text"], output: ["text"] },
limit: { context: 128_000, output: 8_192 },
})
expect(yield* catalog.model.get(providerID, ModelV2.ID.make("router:support"))).toBeDefined()
}),
)
it.effect("stores a manually registered model access key", () =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
yield* integrations.connection.key({ integrationID, key: "model-access-key" })
expect((yield* (yield* Credential.Service).list(integrationID))[0]?.value).toEqual({
type: "key",
key: "model-access-key",
})
}),
)
})
@@ -1,4 +1,6 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
@@ -41,6 +43,20 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
)
}
function eventually<A>(
effect: Effect.Effect<A>,
predicate: (value: A) => boolean,
remaining = 1000,
): Effect.Effect<A, Error> {
return Effect.gen(function* () {
const value = yield* effect
if (predicate(value)) return value
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
yield* Effect.promise(() => Bun.sleep(1))
return yield* eventually(effect, predicate, remaining - 1)
})
}
void mock.module("gitlab-ai-provider", () => ({
VERSION: "test-version",
createGitLab: (options: Record<string, unknown>) => {
@@ -55,6 +71,155 @@ void mock.module("gitlab-ai-provider", () => ({
}))
describe("GitLabPlugin", () => {
it.effect("registers OAuth, PAT, and environment methods", () =>
Effect.gen(function* () {
yield* addPlugin()
expect((yield* (yield* Integration.Service).get(Integration.ID.make("gitlab")))?.methods).toEqual([
{
id: Integration.MethodID.make("oauth"),
type: "oauth",
label: "GitLab OAuth",
prompts: [
{
type: "text",
key: "instanceUrl",
message: "GitLab instance URL",
placeholder: "https://gitlab.com",
},
],
},
{
type: "key",
label: "GitLab Personal Access Token",
prompts: [
{
type: "text",
key: "instanceUrl",
message: "GitLab instance URL",
placeholder: "https://gitlab.com",
},
],
},
{ type: "env", names: ["GITLAB_TOKEN"] },
])
}),
)
it.effect("validates PATs and stores normalized instance URL metadata", () =>
Effect.gen(function* () {
yield* addPlugin()
const original = globalThis.fetch
const calls: [string, RequestInit | undefined][] = []
globalThis.fetch = Object.assign(
async (input: string | URL | Request, init?: RequestInit) => {
calls.push([String(input), init])
return new Response(JSON.stringify({ id: 1 }), { status: 200 })
},
{ preconnect: original.preconnect },
)
yield* Effect.addFinalizer(() => Effect.sync(() => (globalThis.fetch = original)))
const integrations = yield* Integration.Service
yield* integrations.connection.key({
integrationID: Integration.ID.make("gitlab"),
key: "glpat-test",
inputs: { instanceUrl: "https://gitlab.example/path/" },
})
expect(calls).toHaveLength(1)
expect(calls[0]?.[0]).toBe("https://gitlab.example/api/v4/user")
expect(calls[0]?.[1]?.headers).toEqual({ Authorization: "Bearer glpat-test" })
expect((yield* (yield* Credential.Service).list(Integration.ID.make("gitlab")))[0]?.value).toEqual({
type: "key",
key: "glpat-test",
metadata: { instanceUrl: "https://gitlab.example" },
})
}),
)
it.effect("rejects invalid PAT instance URLs before validation", () =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
const exit = yield* integrations.connection
.key({
integrationID: Integration.ID.make("gitlab"),
key: "glpat-test",
inputs: { instanceUrl: "file:///tmp/gitlab" },
})
.pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
expect(yield* (yield* Credential.Service).list(Integration.ID.make("gitlab"))).toHaveLength(0)
}),
)
it.effect("completes OAuth PKCE and refreshes with instance metadata", () =>
withEnv({ GITLAB_OAUTH_CLIENT_ID: "test-client" }, () =>
Effect.gen(function* () {
yield* addPlugin()
const original = globalThis.fetch
const tokenBodies: URLSearchParams[] = []
globalThis.fetch = Object.assign(
async (input: string | URL | Request, init?: RequestInit) => {
if (String(input).startsWith("http://127.0.0.1:8080/")) return original(input, init)
tokenBodies.push(new URLSearchParams(String(init?.body)))
return Response.json({
access_token: tokenBodies.length === 1 ? "access" : "refreshed-access",
refresh_token: tokenBodies.length === 1 ? "refresh" : "rotated-refresh",
expires_in: 1,
})
},
{ preconnect: original.preconnect },
)
yield* Effect.addFinalizer(() => Effect.sync(() => (globalThis.fetch = original)))
const integrations = yield* Integration.Service
const attempt = yield* integrations.connection.oauth({
integrationID: Integration.ID.make("gitlab"),
methodID: Integration.MethodID.make("oauth"),
inputs: { instanceUrl: "http://gitlab.example/path" },
})
const authorize = new URL(attempt.url)
expect(authorize.origin).toBe("http://gitlab.example")
expect(authorize.pathname).toBe("/oauth/authorize")
expect(authorize.searchParams.get("client_id")).toBe("test-client")
expect(authorize.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:8080/callback")
expect(authorize.searchParams.get("code_challenge_method")).toBe("S256")
expect(authorize.searchParams.get("code_challenge")).toBeTruthy()
yield* Effect.promise(() =>
fetch(`http://127.0.0.1:8080/callback?code=test-code&state=${authorize.searchParams.get("state")}`),
)
yield* eventually(integrations.attempt.status(attempt.attemptID), (status) => status.status === "complete")
const saved = (yield* (yield* Credential.Service).list(Integration.ID.make("gitlab")))[0]
expect(saved?.value).toEqual({
type: "oauth",
methodID: Integration.MethodID.make("oauth"),
access: "access",
refresh: "refresh",
expires: expect.any(Number),
metadata: { instanceUrl: "http://gitlab.example" },
})
expect(tokenBodies[0]?.get("grant_type")).toBe("authorization_code")
expect(tokenBodies[0]?.get("code_verifier")).toBeTruthy()
if (saved?.value.type !== "oauth") throw new Error("Expected OAuth credential")
yield* (yield* Credential.Service).update(saved.id, { value: { ...saved.value, expires: 0 } })
const resolved = yield* integrations.connection.resolve({
type: "credential",
id: saved!.id,
label: saved!.label,
})
expect(resolved).toMatchObject({
type: "oauth",
access: "refreshed-access",
refresh: "rotated-refresh",
metadata: { instanceUrl: "http://gitlab.example" },
})
expect(tokenBodies[1]?.get("grant_type")).toBe("refresh_token")
expect(tokenBodies[1]?.get("refresh_token")).toBe("refresh")
}),
),
)
it.effect("creates SDKs with legacy default instance URL, token env, headers, and feature flags", () =>
withEnv(
{
@@ -0,0 +1,66 @@
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PoePlugin } from "@opencode-ai/core/plugin/provider/poe"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const integrationID = Integration.ID.make("poe")
const methodID = Integration.MethodID.make("browser")
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin)
yield* PoePlugin.effect(host)
})
describe("PoePlugin", () => {
it.effect("registers browser OAuth and manual API key methods", () =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
const integration = yield* integrations.get(integrationID)
expect(integration?.name).toBe("Poe")
expect(integration?.methods).toEqual([
{ id: methodID, type: "oauth", label: "Login with Poe (browser)" },
{ type: "key", label: "Manually enter API Key" },
])
}),
)
it.effect("stores manually entered Poe API keys", () =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
yield* integrations.connection.key({ integrationID, key: "poe-test" })
expect((yield* credentials.list(integrationID))[0]?.value).toEqual({ type: "key", key: "poe-test" })
}),
)
it.effect("starts a PKCE browser authorization on an ephemeral loopback server", () =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
const url = new URL(attempt.url)
const redirect = new URL(url.searchParams.get("redirect_uri") ?? "")
expect(url.origin + url.pathname).toBe("https://poe.com/oauth/authorize")
expect(url.searchParams.get("response_type")).toBe("code")
expect(url.searchParams.get("client_id")).toBe("client_728290227fc048cc9262091a1ea197ea")
expect(url.searchParams.get("scope")).toBe("apikey:create")
expect(url.searchParams.get("code_challenge_method")).toBe("S256")
expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/)
expect(redirect.hostname).toBe("127.0.0.1")
expect(redirect.pathname).toBe("/callback")
expect(Number(redirect.port)).toBeGreaterThan(0)
yield* integrations.attempt.cancel(attempt.attemptID)
}),
)
})
@@ -1,10 +1,11 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { Integration } from "@opencode-ai/core/integration"
import { describe, expect, it as bun_it } from "bun:test"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex"
import { SnowflakeCortexPlugin, cortexFetch, oauthScope } from "@opencode-ai/core/plugin/provider/snowflake-cortex"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
@@ -41,6 +42,61 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
}
describe("SnowflakeCortexPlugin", () => {
it.effect("registers browser OAuth, key, and environment methods", () =>
Effect.gen(function* () {
yield* addPlugin()
expect((yield* (yield* Integration.Service).get(Integration.ID.make("snowflake-cortex")))?.methods).toEqual([
{
id: Integration.MethodID.make("snowflake-browser"),
type: "oauth",
label: "Login with Snowflake (External Browser)",
prompts: [
{
type: "text",
key: "account",
message: "Snowflake Account Identifier",
placeholder: "myorg-myaccount",
},
{
type: "text",
key: "role",
message: "Snowflake Role (optional)",
placeholder: "PUBLIC",
},
],
},
{ type: "key", label: "Paste PAT or bearer token manually" },
{ type: "env", names: ["SNOWFLAKE_CORTEX_TOKEN", "SNOWFLAKE_CORTEX_PAT"] },
])
}),
)
it.effect("uses account metadata to derive the Cortex endpoint", () =>
withEnv({ SNOWFLAKE_ACCOUNT: undefined }, () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/openai-compatible",
options: { account: "https://myorg-myaccount.snowflakecomputing.com/", apiKey: "test-pat" },
})
expect(result.options.baseURL).toBe("https://myorg-myaccount.snowflakecomputing.com/api/v2/cortex/v1")
}),
),
)
it.effect("uses Snowflake-compatible OAuth scopes", () =>
Effect.sync(() => {
expect(oauthScope(undefined)).toBe("refresh_token")
expect(oauthScope("PUBLIC")).toBe("refresh_token session:role:PUBLIC")
expect(oauthScope("AUTH SNOWFLAKE")).toBe("refresh_token session:role-encoded:AUTH%20SNOWFLAKE")
}),
)
it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () =>
Effect.sync(() => {
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.snowflake-cortex")
@@ -194,6 +250,7 @@ describe("cortexFetch", () => {
const body = JSON.parse(captured[0].body as string)
expect(body.max_completion_tokens).toBe(1024)
expect(body.max_tokens).toBeUndefined()
expect(new Headers(captured[0].headers).get("User-Agent")).toMatch(/^opencode\//)
})
bun_it("preserves body when max_tokens is absent", async () => {
+37 -1
View File
@@ -1,4 +1,6 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
@@ -16,7 +18,8 @@ const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* XAIPlugin.effect(host)
const integration = yield* Integration.Service
yield* XAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integration))
})
function fakeSelectorSdk(calls: string[]) {
@@ -33,6 +36,39 @@ function fakeSelectorSdk(calls: string[]) {
}
describe("XAIPlugin", () => {
it.effect("registers browser OAuth, device OAuth, and API key methods", () =>
Effect.gen(function* () {
yield* addPlugin()
const integration = yield* (yield* Integration.Service).get(Integration.ID.make("xai"))
expect(integration?.name).toBe("xAI")
expect(integration?.methods).toEqual([
{
id: Integration.MethodID.make("browser"),
type: "oauth",
label: "xAI Grok OAuth (SuperGrok Subscription)",
},
{
id: Integration.MethodID.make("device"),
type: "oauth",
label: "xAI Grok OAuth (Headless / Remote / VPS)",
},
{ type: "key", label: "Manually enter API Key" },
])
}),
)
it.effect("stores API keys through the registered key method", () =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
yield* integrations.connection.key({ integrationID: Integration.ID.make("xai"), key: "xai-test" })
expect((yield* (yield* Credential.Service).list(Integration.ID.make("xai")))[0]?.value).toEqual({
type: "key",
key: "xai-test",
})
}),
)
it.effect("creates an xAI SDK only for @ai-sdk/xai", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
@@ -26,7 +26,7 @@ describe("ReferenceGuidance", () => {
Layer.mock(Reference.Service, {
list: () =>
Effect.succeed([
new Reference.Info({
Reference.Info.make({
name: "docs",
path: AbsolutePath.make("/docs"),
description: "Use for product documentation",
@@ -62,7 +62,7 @@ describe("ReferenceGuidance", () => {
Layer.mock(Reference.Service, {
list: () =>
Effect.succeed([
new Reference.Info({
Reference.Info.make({
name: "docs",
path: AbsolutePath.make("/docs"),
source: Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") }),
@@ -76,7 +76,7 @@ describe("ReferenceGuidance", () => {
it.effect("announces added and removed references as deltas", () => {
const reference = (name: string, description: string) =>
new Reference.Info({
Reference.Info.make({
name,
path: AbsolutePath.make(`/${name}`),
description,
+3 -3
View File
@@ -29,7 +29,7 @@ describe("Reference", () => {
yield* references.transform((editor) => editor.add("docs", source)).pipe(Scope.provide(scope))
expect(yield* references.list()).toEqual([
new Reference.Info({ name: "docs", path, description: "Use for API documentation", hidden: true, source }),
Reference.Info.make({ name: "docs", path, description: "Use for API documentation", hidden: true, source }),
])
yield* Scope.close(scope, Exit.void)
@@ -45,7 +45,7 @@ describe("Reference", () => {
yield* references.transform((editor) => editor.add("sdk", source))
expect(yield* references.list()).toEqual([
new Reference.Info({
Reference.Info.make({
name: "sdk",
path: AbsolutePath.make(Repository.cachePath(Global.Path.repos, repository)),
source,
@@ -66,7 +66,7 @@ describe("Reference", () => {
yield* references.transform((editor) => editor.add("sdk", source))
expect(yield* references.list()).toEqual([
new Reference.Info({
Reference.Info.make({
name: "sdk",
path: AbsolutePath.make(Repository.cachePath(Global.Path.repos, repository)),
description: "Use for SDK implementation details",
+52 -1
View File
@@ -6,6 +6,7 @@ import {
Model,
ToolFailure,
TransportReason,
InvalidProviderOutputReason,
InvalidRequestReason,
RateLimitReason,
type LLMClientShape,
@@ -716,7 +717,18 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) =>
type: "assistant",
finish: "error",
error: { type: "provider.transport", message: "Provider unavailable" },
content: [fixture.expectedContent],
content: [
kind === "tool input"
? {
type: "tool",
id: fragmentID(kind, "partial"),
state: {
status: "error",
error: { type: "provider.transport", message: "Provider unavailable" },
},
}
: fixture.expectedContent,
],
},
])
expect(requests).toHaveLength(1)
@@ -3876,6 +3888,45 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("settles malformed streamed tool input before the provider failure", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Call a malformed tool")
const failure = new LLMError({
module: "test",
method: "stream",
reason: new InvalidProviderOutputReason({ message: "Invalid JSON input for tool call echo" }),
})
responseStream = Stream.fromIterable([
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }),
LLMEvent.toolInputDelta({ id: "call-malformed", name: "echo", text: '{"text":"partial' }),
]).pipe(Stream.concat(Stream.fail(failure)))
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
const assistant = requireAssistant(yield* session.context(sessionID))
response = reply.stop()
yield* admit(session, "Continue")
yield* session.resume(sessionID)
expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([
{ type: "session.step.started.1" },
{
type: "session.tool.failed.1",
data: {
callID: "call-malformed",
error: { type: "provider.invalid-output", message: "Invalid JSON input for tool call echo" },
},
},
{
type: "session.step.failed.1",
data: { error: { type: "provider.invalid-output", message: "Invalid JSON input for tool call echo" } },
},
])
}),
)
it.effect("does not continue automatically after a provider error follows a local tool call", () =>
Effect.gen(function* () {
const session = yield* setup
+13 -1
View File
@@ -281,10 +281,22 @@ describe("SubagentTool", () => {
},
})
const childID = outputSessionID(settled.output?.structured)
expect(settled.output?.structured).toMatchObject({ status: "running" })
expect(settled.output?.structured).toMatchObject({
status: "running",
output: expect.stringContaining(`id: ${childID}`),
})
const admission = Array.from(yield* Fiber.join(admitted))[0]
expect(admission?.data.input.data.text).toContain(`<subagent id="${childID}" state="completed"`)
expect(admission?.data.input.data).toMatchObject({
description: "background review",
metadata: {
source: "subagent",
childID,
agent: "reviewer",
state: "completed",
},
})
const database = yield* Database.Service
yield* SessionPending.promoteSteers(database.db, events, parent.id)
const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
File diff suppressed because one or more lines are too long
@@ -1097,6 +1097,27 @@ describe("HttpApiCodegen.generate", () => {
expect(output.operations[0]?.success).toBe("stream")
})
test("emits opaque Promise SSE fields as any", () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("subscribe", "/event", {
success: HttpApiSchema.StreamSse({
data: Schema.Struct({
metadata: Schema.Record(Schema.String, Schema.Unknown),
label: Schema.Literal("unknown"),
}),
}),
}),
),
),
)
const types = output.files.find((file) => file.path === "types.ts")?.content
expect(types).toContain('readonly "metadata": { readonly [x: string]: any }')
expect(types).toContain('readonly "label": "unknown"')
})
test("preserves annotated stream response statuses", () => {
const output = compile(
api(
+1 -1
View File
@@ -2,7 +2,7 @@
import { Config } from "@/config/config"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { TuiConfig } from "@opencode-ai/tui/config"
import { TuiConfig } from "@opencode-ai/tui/config/v1"
import { Schema } from "effect"
type JsonSchema = Record<string, unknown>
@@ -1,4 +1,4 @@
import { TuiConfig } from "@opencode-ai/tui/config"
import { TuiConfig } from "@opencode-ai/tui/config/v1"
import { isRecord } from "@opencode-ai/tui/util/record"
import { Filesystem } from "@/util/filesystem"
import { Schema } from "effect"
+1 -1
View File
@@ -2,7 +2,7 @@ import path from "path"
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
import { unique } from "remeda"
import { Option, Schema } from "effect"
import { TuiConfig } from "@opencode-ai/tui/config"
import { TuiConfig } from "@opencode-ai/tui/config/v1"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem"
+2 -2
View File
@@ -15,14 +15,14 @@ import { Global } from "@opencode-ai/core/global"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { CurrentWorkingDirectory } from "./tui-cwd"
import { ConfigPlugin } from "@/config/plugin"
import { TuiKeybind } from "@opencode-ai/tui/config/keybind"
import { TuiKeybind } from "@opencode-ai/tui/config/v1/keybind"
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { Filesystem } from "@/util/filesystem"
import { ConfigVariable } from "@/config/variable"
import { Npm } from "@opencode-ai/core/npm"
import { FormatError, FormatUnknownError } from "@/cli/error"
import { TuiConfig } from "@opencode-ai/tui/config"
import { TuiConfig } from "@opencode-ai/tui/config/v1"
export const Info = TuiConfig.Info
export type Info = TuiConfig.Info
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { AudioPlayOptions, AudioSound } from "@opentui/core"
import { createTuiAttention } from "@opencode-ai/tui/attention"
import type { TuiConfig } from "@opencode-ai/tui/config"
import type { TuiConfig } from "@opencode-ai/tui/config/v1"
type FocusEvent = "focus" | "blur"
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client/promise"
import type { Resolved } from "@opencode-ai/tui/config"
import type { Resolved } from "@opencode-ai/tui/config/v1"
import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@opencode-ai/cli/mini/runtime.boot"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
@@ -1,8 +1,8 @@
import { spyOn } from "bun:test"
import path from "path"
import { resolve, type Info, type Resolved } from "@opencode-ai/tui/config"
import { resolve, type Info, type Resolved } from "@opencode-ai/tui/config/v1"
import { TuiConfig } from "../../src/config/tui"
import { TuiKeybind } from "@opencode-ai/tui/config/keybind"
import { TuiKeybind } from "@opencode-ai/tui/config/v1/keybind"
type PluginSpec = string | [string, Record<string, unknown>]
type PluginOrigin = {
+1
View File
@@ -15,6 +15,7 @@
"./tui": "./src/tui.ts",
"./v2/effect": "./src/v2/effect/index.ts",
"./v2/effect/*": "./src/v2/effect/*.ts",
"./v2/tui/*": "./src/v2/tui/*.ts",
"./v2": "./src/v2/promise/index.ts",
"./v2/*": "./src/v2/promise/*.ts"
},
+14 -8
View File
@@ -1,5 +1,6 @@
import type {
ConnectionInfo,
CredentialKey,
CredentialOAuth,
CredentialValue,
IntegrationEnvMethod,
@@ -13,6 +14,8 @@ import type { IntegrationApi } from "@opencode-ai/client/effect/api"
import type { Effect, Scope } from "effect"
import type { Transform } from "./registration.js"
export type { IntegrationInputs }
export type IntegrationOAuthAuthorization = {
readonly url: string
readonly instructions: string
@@ -33,16 +36,19 @@ export type IntegrationOAuthMethodRegistration = {
readonly refresh?: (credential: CredentialOAuth) => Effect.Effect<CredentialOAuth, unknown>
readonly label?: (credential: CredentialOAuth) => string | undefined
}
export type IntegrationKeyMethodRegistration = {
readonly integrationID: string
readonly method: IntegrationKeyMethod
readonly authorize?: (key: string, inputs: IntegrationInputs) => Effect.Effect<CredentialKey, unknown>
}
export type IntegrationEnvMethodRegistration = {
readonly integrationID: string
readonly method: IntegrationEnvMethod
}
export type IntegrationMethodRegistration =
| IntegrationOAuthMethodRegistration
| {
readonly integrationID: string
readonly method: IntegrationKeyMethod
}
| {
readonly integrationID: string
readonly method: IntegrationEnvMethod
}
| IntegrationKeyMethodRegistration
| IntegrationEnvMethodRegistration
export interface IntegrationDraft {
list(): readonly IntegrationRef[]
+112
View File
@@ -0,0 +1,112 @@
import type {
AgentInfo,
CommandInfo,
FormInfo,
IntegrationInfo,
LocationRef,
McpResource,
McpServer,
ModelInfo,
OpenCodeClient,
OpenCodeEvent,
PermissionSavedInfo,
PermissionV2Request,
ProviderV2Info,
ReferenceInfo,
SessionInfo,
SessionMessageInfo,
SessionPendingInfo,
ShellInfo,
SkillInfo,
} from "@opencode-ai/client"
import type { JSX } from "@opentui/solid"
interface LocationCollection<Value> {
list(location?: LocationRef): Value[] | undefined
refresh(location?: LocationRef): Promise<void>
}
export interface Data {
readonly on: <Type extends OpenCodeEvent["type"]>(
type: Type,
handler: (event: Extract<OpenCodeEvent, { type: Type }>) => void,
) => () => void
readonly listen: (handler: (event: { details: OpenCodeEvent }) => void) => () => void
readonly session: {
list(): SessionInfo[]
get(sessionID: string): SessionInfo | undefined
root(sessionID: string): string
family(sessionID: string): string[]
cost(sessionID: string): number
status(sessionID: string): "idle" | "running"
readonly pending: {
list(sessionID: string): SessionPendingInfo[]
refresh(sessionID: string): Promise<void>
}
refresh(sessionID: string): Promise<void>
readonly message: {
list(sessionID: string): SessionMessageInfo[]
get(sessionID: string, messageID: string): SessionMessageInfo | undefined
refresh(sessionID: string): Promise<void>
}
readonly permission: {
list(sessionID: string): PermissionV2Request[] | undefined
refresh(sessionID: string): Promise<void>
}
readonly form: {
list(sessionID: string, location?: LocationRef): Array<FormInfo & { readonly location?: LocationRef }> | undefined
refresh(sessionID: string, location?: LocationRef): Promise<void>
}
}
readonly project: {
readonly permission: {
list(projectID: string): PermissionSavedInfo[] | undefined
refresh(projectID: string): Promise<void>
}
}
readonly shell: {
list(location?: LocationRef): ShellInfo[]
get(id: string): ShellInfo | undefined
refresh(location?: LocationRef): Promise<void>
}
readonly location: {
default(): LocationRef
refresh(location?: LocationRef): Promise<void>
readonly agent: LocationCollection<AgentInfo>
readonly command: LocationCollection<CommandInfo>
readonly integration: LocationCollection<IntegrationInfo>
readonly mcp: {
readonly server: LocationCollection<McpServer>
readonly resource: LocationCollection<McpResource>
}
readonly model: LocationCollection<ModelInfo>
readonly provider: LocationCollection<ProviderV2Info>
readonly reference: LocationCollection<ReferenceInfo>
readonly skill: LocationCollection<SkillInfo>
}
}
export interface RouteDefinition {
readonly name: string
readonly render: (input: { readonly params: any }) => JSX.Element
}
export interface Route {
register(definition: RouteDefinition): () => void
navigate(input: { readonly name: string; readonly params?: any }): void
current(): {
readonly name: string
readonly params: any
}
}
export interface UI {
readonly route: Route
}
export interface Context {
readonly options: Record<string, any>
readonly client: OpenCodeClient
readonly data: Data
readonly ui: UI
}
+1
View File
@@ -54,6 +54,7 @@ export const groupNames = {
"server.event": "event",
"server.pty": "pty",
"server.shell": "shell",
"server.mcp": "mcp",
"server.question": "question",
"server.reference": "reference",
"server.project": "project",
@@ -43,6 +43,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
query: LocationQuery,
payload: Schema.Struct({
key: Schema.String,
inputs: Schema.optional(Inputs),
label: Schema.optional(Schema.String),
}),
success: HttpApiSchema.NoContent,
+4 -2
View File
@@ -60,6 +60,7 @@ export interface KeyMethod extends Schema.Schema.Type<typeof KeyMethod> {}
export const KeyMethod = Schema.Struct({
type: Schema.Literal("key"),
label: optional(Schema.String),
prompts: optional(Schema.Array(Prompt)),
}).annotate({ identifier: "Integration.KeyMethod" })
export interface EnvMethod extends Schema.Schema.Type<typeof EnvMethod> {}
@@ -92,12 +93,13 @@ export const Ref = Schema.Struct({
name: Schema.String,
}).annotate({ identifier: "Integration.Ref" })
export class Info extends Schema.Class<Info>("Integration.Info")({
export const Info = Schema.Struct({
id: ID,
name: Schema.String,
methods: Schema.Array(Method),
connections: Schema.Array(Connection.Info),
}) {}
}).annotate({ identifier: "Integration.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const AttemptID = Schema.String.pipe(
Schema.brand("Integration.AttemptID"),
+3 -2
View File
@@ -30,10 +30,11 @@ export const Source = Schema.Union([LocalSource, GitSource])
.annotate({ identifier: "Reference.Source" })
export type Source = typeof Source.Type
export class Info extends Schema.Class<Info>("Reference.Info")({
export const Info = Schema.Struct({
name: Schema.String,
path: AbsolutePath,
description: Schema.String.pipe(optional),
hidden: Schema.Boolean.pipe(optional),
source: Source,
}) {}
}).annotate({ identifier: "Reference.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
+5 -5
View File
@@ -23,8 +23,8 @@ export const Status = Schema.Literals(["running", "exited", "timeout", "killed"]
export type Status = typeof Status.Type
export const Time = Schema.Struct({
started: Schema.Number,
completed: optional(Schema.Number),
started: Schema.Finite,
completed: optional(Schema.Finite),
})
export interface Time extends Schema.Schema.Type<typeof Time> {}
@@ -42,15 +42,15 @@ export const Info = Schema.Struct({
// Absolute path of the file capturing combined stdout/stderr. Page through it via `output`.
file: Schema.String,
pid: optional(NonNegativeInt),
exit: optional(Schema.Number),
exit: optional(Schema.Finite),
// Always present; defaults to an empty object when the creator supplies no metadata.
metadata: Metadata,
time: Time,
}).annotate({ identifier: "Shell" })
}).annotate({ identifier: "Shell.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
const Created = ephemeral({ type: "shell.created", schema: { info: Info } })
const Exited = ephemeral({ type: "shell.exited", schema: { id: ID, exit: optional(Schema.Number), status: Status } })
const Exited = ephemeral({ type: "shell.exited", schema: { id: ID, exit: optional(Schema.Finite), status: Status } })
const Deleted = ephemeral({ type: "shell.deleted", schema: { id: ID } })
export const Event = { Created, Exited, Deleted, Definitions: inventory(Created, Exited, Deleted) }
+4
View File
@@ -6772,6 +6772,9 @@ export class Connect extends HeyApiClient {
workspace?: string | null
} | null
key?: string
inputs?: {
[key: string]: string
} | null
label?: string | null
},
options?: Options<never, ThrowOnError>,
@@ -6784,6 +6787,7 @@ export class Connect extends HeyApiClient {
{ in: "path", key: "integrationID" },
{ in: "query", key: "location" },
{ in: "body", key: "key" },
{ in: "body", key: "inputs" },
{ in: "body", key: "label" },
],
},
+80 -94
View File
@@ -598,24 +598,6 @@ export type Part =
| RetryPart
| CompactionPart
export type Shell = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
cwd: string
shell: string
file: string
pid?: number
exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
metadata: {
[key: string]: unknown
}
time: {
started: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
completed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
}
}
export type Pty = {
id: string
title: string
@@ -917,7 +899,7 @@ export type GlobalEvent = {
type: "session.shell.started"
properties: {
sessionID: string
shell: Shell
shell: ShellInfo
}
}
| {
@@ -925,7 +907,7 @@ export type GlobalEvent = {
type: "session.shell.ended"
properties: {
sessionID: string
shell: Shell
shell: ShellInfo
output: {
output: string
cursor: number
@@ -1358,7 +1340,7 @@ export type GlobalEvent = {
id: string
type: "shell.created"
properties: {
info: Shell
info: ShellInfo
}
}
| {
@@ -1366,7 +1348,7 @@ export type GlobalEvent = {
type: "shell.exited"
properties: {
id: string
exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
exit?: number
status: "running" | "exited" | "timeout" | "killed"
}
}
@@ -2890,24 +2872,6 @@ export type InstructionEntryValueTooLargeError = {
message: string
}
export type Shell1 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
cwd: string
shell: string
file: string
pid?: number
exit?: number | "NaN" | "Infinity" | "-Infinity"
metadata: {
[key: string]: unknown
}
time: {
started: number | "NaN" | "Infinity" | "-Infinity"
completed?: number | "NaN" | "Infinity" | "-Infinity"
}
}
export type SessionLogItem = SessionEventDurable | EventLogSynced
export type SessionLogItemStream = string
@@ -3154,24 +3118,6 @@ export type EffectHttpApiErrorForbidden = {
_tag: "Forbidden"
}
export type Shell2 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
cwd: string
shell: string
file: string
pid?: number
exit?: number | "NaN" | "Infinity" | "-Infinity"
metadata: {
[key: string]: unknown
}
time: {
started: number | "NaN" | "Infinity" | "-Infinity"
completed?: number | "NaN" | "Infinity" | "-Infinity"
}
}
export type EventTuiPromptAppend2 = {
id: string
type: "tui.prompt.append"
@@ -3398,6 +3344,24 @@ export type SessionStructuredError = {
message: string
}
export type ShellInfo = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
cwd: string
shell: string
file: string
pid?: number
exit?: number
metadata: {
[key: string]: unknown
}
time: {
started: number
completed?: number
}
}
export type SessionMessageProviderState = {
[key: string]: unknown
}
@@ -3962,7 +3926,7 @@ export type SyncEventSessionShellStarted = {
aggregateID: string
data: {
sessionID: string
shell: Shell
shell: ShellInfo
}
}
}
@@ -3977,7 +3941,7 @@ export type SyncEventSessionShellEnded = {
aggregateID: string
data: {
sessionID: string
shell: Shell
shell: ShellInfo
output: {
output: string
cursor: number
@@ -5083,7 +5047,7 @@ export type SessionShellStarted = {
location?: LocationRef
data: {
sessionID: string
shell: Shell1
shell: ShellInfo
}
}
@@ -5102,7 +5066,7 @@ export type SessionShellEnded = {
location?: LocationRef
data: {
sessionID: string
shell: Shell1
shell: ShellInfo
output: {
output: string
cursor: number
@@ -5730,6 +5694,7 @@ export type IntegrationOAuthMethod = {
export type IntegrationKeyMethod = {
type: "key"
label?: string
prompts?: Array<IntegrationTextPrompt | IntegrationSelectPrompt>
}
export type IntegrationEnvMethod = {
@@ -6461,7 +6426,7 @@ export type ShellCreated = {
type: "shell.created"
location?: LocationRef
data: {
info: Shell1
info: ShellInfo
}
}
@@ -6475,7 +6440,7 @@ export type ShellExited = {
location?: LocationRef
data: {
id: string
exit?: number | "NaN" | "Infinity" | "-Infinity"
exit?: number
status: "running" | "exited" | "timeout" | "killed"
}
}
@@ -7285,7 +7250,7 @@ export type EventSessionShellStarted = {
type: "session.shell.started"
properties: {
sessionID: string
shell: Shell2
shell: ShellInfo
}
}
@@ -7294,7 +7259,7 @@ export type EventSessionShellEnded = {
type: "session.shell.ended"
properties: {
sessionID: string
shell: Shell2
shell: ShellInfo
output: {
output: string
cursor: number
@@ -7772,7 +7737,7 @@ export type EventShellCreated = {
id: string
type: "shell.created"
properties: {
info: Shell2
info: ShellInfo
}
}
@@ -7781,7 +7746,7 @@ export type EventShellExited = {
type: "shell.exited"
properties: {
id: string
exit?: number | "NaN" | "Infinity" | "-Infinity"
exit?: number
status: "running" | "exited" | "timeout" | "killed"
}
}
@@ -8179,24 +8144,6 @@ export type UnknownErrorV2 = {
ref?: string | null
}
export type ShellV2 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
cwd: string
shell: string
file: string
pid?: number
exit?: number | "NaN" | "Infinity" | "-Infinity"
metadata: {
[key: string]: unknown
}
time: {
started: number | "NaN" | "Infinity" | "-Infinity"
completed?: number | "NaN" | "Infinity" | "-Infinity"
}
}
export type SessionMessagesResponseV2 = {
data: Array<SessionMessageInfo>
cursor: {
@@ -9333,6 +9280,24 @@ export type SessionSkillActivatedV2 = {
}
}
export type ShellInfoV2 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
cwd: string
shell: string
file: string
pid?: number
exit?: number
metadata: {
[key: string]: unknown
}
time: {
started: number
completed?: number
}
}
export type SessionShellStartedV2 = {
id: string
created: number
@@ -9348,7 +9313,7 @@ export type SessionShellStartedV2 = {
location?: LocationRefV2
data: {
sessionID: string
shell: ShellV2
shell: ShellInfoV2
}
}
@@ -9367,7 +9332,7 @@ export type SessionShellEndedV2 = {
location?: LocationRefV2
data: {
sessionID: string
shell: ShellV2
shell: ShellInfoV2
output: {
output: string
cursor: number
@@ -10514,7 +10479,7 @@ export type ShellCreatedV2 = {
type: "shell.created"
location?: LocationRefV2
data: {
info: ShellV2
info: ShellInfoV2
}
}
@@ -10528,7 +10493,7 @@ export type ShellExitedV2 = {
location?: LocationRefV2
data: {
id: string
exit?: number | "NaN" | "Infinity" | "-Infinity"
exit?: number
status: "running" | "exited" | "timeout" | "killed"
}
}
@@ -10986,6 +10951,24 @@ export type PtyTicketConnectTokenV2 = {
expires_in: number
}
export type ShellInfo1 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
cwd: string
shell: string
file: string
pid?: number
exit?: number
metadata: {
[key: string]: unknown
}
time: {
started: number
completed?: number
}
}
export type QuestionV2RequestV2 = {
id: string
sessionID: string
@@ -16783,6 +16766,9 @@ export type V2IntegrationGetResponse = V2IntegrationGetResponses[keyof V2Integra
export type V2IntegrationConnectKeyData = {
body: {
key: string
inputs?: {
[key: string]: string
} | null
label?: string | null
}
path: {
@@ -18318,7 +18304,7 @@ export type V2ShellListResponses = {
*/
200: {
location: LocationInfoV2
data: Array<ShellV2>
data: Array<ShellInfo1>
}
}
@@ -18362,7 +18348,7 @@ export type V2ShellCreateResponses = {
*/
200: {
location: LocationInfoV2
data: ShellV2
data: ShellInfo1
}
}
@@ -18445,7 +18431,7 @@ export type V2ShellGetResponses = {
*/
200: {
location: LocationInfoV2
data: ShellV2
data: ShellInfo1
}
}
@@ -18490,7 +18476,7 @@ export type V2ShellTimeoutResponses = {
*/
200: {
location: LocationInfoV2
data: ShellV2
data: ShellInfo1
}
}
+1
View File
@@ -9,6 +9,7 @@
"./*": "./src/*.ts"
},
"scripts": {
"test": "bun test --only-failures",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
+90
View File
@@ -0,0 +1,90 @@
export * as EventFeed from "./event-feed"
import { EventV2 } from "@opencode-ai/core/event"
import { isOpenCodeEvent, OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import { Cause, Context, Effect, Layer, Queue, Schema, Scope, Stream } from "effect"
export const SubscriberCapacity = 4_096
export class SubscriberOverflowError extends Schema.TaggedErrorClass<SubscriberOverflowError>()(
"EventFeed.SubscriberOverflow",
{ capacity: Schema.Int },
) {}
export class EncodingError extends Schema.TaggedErrorClass<EncodingError>()("EventFeed.EncodingError", {
eventID: EventV2.ID,
eventType: Schema.String,
cause: Schema.Defect(),
}) {}
export type Error = SubscriberOverflowError | EncodingError
export interface Interface {
readonly subscribe: Effect.Effect<Stream.Stream<string, Error>, never, Scope.Scope>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/server/EventFeed") {}
const encode = Schema.encodeUnknownSync(OpenCodeEvent)
export function frame(event: OpenCodeEvent) {
return `data: ${JSON.stringify(encode(event))}\n\n`
}
export const make = Effect.fn("EventFeed.make")(function* (
observe: (subscriber: EventV2.Subscriber) => Effect.Effect<EventV2.Unsubscribe>,
options?: { readonly capacity?: number; readonly encode?: (event: OpenCodeEvent) => string },
) {
const capacity = options?.capacity ?? SubscriberCapacity
const render = options?.encode ?? frame
const subscribers = new Set<Queue.Queue<string, Error>>()
const fail = (error: Error) =>
Effect.sync(() => {
const current = Array.from(subscribers)
subscribers.clear()
for (const subscriber of current) Queue.failCauseUnsafe(subscriber, Cause.fail(error))
})
const publish = Effect.fnUntraced(function* (event: EventV2.Payload) {
if (!isOpenCodeEvent(event)) return
if (subscribers.size === 0) return
const encoded = yield* Effect.try({
try: () => render(event),
catch: (cause) => new EncodingError({ eventID: event.id, eventType: event.type, cause }),
}).pipe(
Effect.catch((error) =>
Effect.logError("Failed to encode public event", {
eventID: error.eventID,
eventType: error.eventType,
cause: error.cause,
}).pipe(Effect.andThen(fail(error)), Effect.as(undefined)),
),
)
if (encoded === undefined) return
for (const subscriber of subscribers) {
if (Queue.offerUnsafe(subscriber, encoded)) continue
subscribers.delete(subscriber)
Queue.failCauseUnsafe(subscriber, Cause.fail(new SubscriberOverflowError({ capacity })))
}
})
const unsubscribe = yield* observe(publish)
yield* Effect.addFinalizer(() => unsubscribe)
return Service.of({
subscribe: Effect.acquireRelease(
Queue.dropping<string, Error>(capacity).pipe(Effect.tap((queue) => Effect.sync(() => subscribers.add(queue)))),
(queue) =>
Effect.sync(() => subscribers.delete(queue)).pipe(Effect.andThen(Queue.shutdown(queue)), Effect.asVoid),
).pipe(Effect.map(Stream.fromQueue)),
})
})
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
return yield* make(events.listen)
}),
)
+2 -1
View File
@@ -26,6 +26,7 @@ import { CredentialHandler } from "./handlers/credential"
import { ProjectHandler } from "./handlers/project"
import { ProjectCopyHandler } from "./handlers/project-copy"
import { VcsHandler } from "./handlers/vcs"
import { EventFeed } from "./event-feed"
export const handlers = Layer.mergeAll(
HealthHandler,
@@ -48,7 +49,7 @@ export const handlers = Layer.mergeAll(
FileSystemHandler,
CommandHandler,
SkillHandler,
EventHandler,
EventHandler.pipe(Layer.provide(EventFeed.layer)),
PtyHandler,
ShellHandler,
QuestionHandler,
+6 -27
View File
@@ -1,44 +1,23 @@
import { EventV2 } from "@opencode-ai/core/event"
import { isOpenCodeEvent, OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import { Effect, Schema, Stream } from "effect"
import { Sse } from "effect/unstable/encoding"
import { Effect, Stream } from "effect"
import { HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
// Session execution emits dense event bursts; allow healthy SSE clients enough
// time to absorb one without weakening the bounded slow-subscriber failure.
const subscriberCapacity = 4_096
function eventData(data: unknown): Sse.Event {
return {
_tag: "Event",
event: "message",
id: undefined,
data: JSON.stringify(Schema.encodeUnknownSync(OpenCodeEvent)(data)),
}
}
import { EventFeed } from "../event-feed"
export const EventHandler = HttpApiBuilder.group(Api, "server.event", (handlers) =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const feed = yield* EventFeed.Service
return handlers.handleRaw("event.subscribe", () =>
Effect.gen(function* () {
const connected = {
id: EventV2.ID.create(),
type: "server.connected",
data: {},
}
} as const
const output = Stream.unwrap(
Effect.gen(function* () {
// Acquiring the bounded stream installs its listener before readiness is observable.
const live = yield* EventV2.liveBounded(events, {
capacity: subscriberCapacity,
accept: isOpenCodeEvent,
})
return Stream.make(connected).pipe(Stream.concat(live))
}),
).pipe(Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()))
feed.subscribe.pipe(Effect.map((live) => Stream.make(EventFeed.frame(connected)).pipe(Stream.concat(live)))),
)
const heartbeat = Stream.tick("15 seconds").pipe(Stream.map(() => ": heartbeat\n\n"))
return HttpServerResponse.stream(
output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }), Stream.encodeText),
@@ -41,6 +41,7 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
service.connection.key({
integrationID: ctx.params.integrationID,
key: ctx.payload.key,
inputs: ctx.payload.inputs,
label: ctx.payload.label,
}),
)
+155
View File
@@ -0,0 +1,155 @@
import { describe, expect, test } from "bun:test"
import { AgentV2 } from "@opencode-ai/core/agent"
import { EventV2 } from "@opencode-ai/core/event"
import { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import { DateTime, Deferred, Effect, Exit, Fiber, Option, Schema, Stream } from "effect"
import { it } from "../../core/test/lib/effect"
import { EventFeed } from "../src/event-feed"
const Internal = EventV2.ephemeral({ type: "test.internal", schema: { value: Schema.String } })
const event = (id: string): EventV2.Payload<typeof AgentV2.Event.Updated> => ({
id: EventV2.ID.make(`evt_${id}`),
created: DateTime.makeUnsafe(Date.now()),
type: AgentV2.Event.Updated.type,
data: {},
})
const internal = (value: string): EventV2.Payload<typeof Internal> => ({
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(Date.now()),
type: Internal.type,
data: { value },
})
function makeSource() {
let subscriber: EventV2.Subscriber | undefined
return {
observe: (next: EventV2.Subscriber) =>
Effect.sync(() => {
subscriber = next
return Effect.sync(() => {
if (subscriber === next) subscriber = undefined
})
}),
publish: (event: EventV2.Payload) => Effect.suspend(() => (subscriber ? subscriber(event) : Effect.void)),
}
}
describe("EventFeed", () => {
test("preserves the public SSE frame encoding", () => {
const payload = event("wire")
expect(EventFeed.frame(payload)).toBe(
`data: ${JSON.stringify(Schema.encodeUnknownSync(OpenCodeEvent)(payload))}\n\n`,
)
})
it.effect("encodes once and delivers the same frame to every subscriber", () =>
Effect.gen(function* () {
let encodes = 0
const source = makeSource()
const feed = yield* EventFeed.make(source.observe, {
encode: (event) => {
encodes += 1
return event.type
},
})
const first = yield* feed.subscribe
const second = yield* feed.subscribe
const left = yield* first.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
const right = yield* second.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* source.publish(event("example"))
expect([Array.from(yield* Fiber.join(left)), Array.from(yield* Fiber.join(right))]).toEqual([
[AgentV2.Event.Updated.type],
[AgentV2.Event.Updated.type],
])
expect(encodes).toBe(1)
}),
)
it.effect("fails only the subscriber that exceeds its lag capacity", () =>
Effect.gen(function* () {
const source = makeSource()
const feed = yield* EventFeed.make(source.observe, {
capacity: 1,
encode: (event) => event.id,
})
const slow = yield* feed.subscribe
const fast = yield* feed.subscribe
const first = yield* Deferred.make<void>()
const second = yield* Deferred.make<void>()
const received = new Array<string>()
const fastFiber = yield* fast.pipe(
Stream.take(3),
Stream.runForEach((frame) =>
Effect.sync(() => received.push(frame)).pipe(
Effect.andThen(
frame === "evt_one"
? Deferred.succeed(first, undefined)
: frame === "evt_two"
? Deferred.succeed(second, undefined)
: Effect.void,
),
),
),
Effect.forkScoped,
)
yield* source.publish(event("one"))
yield* Deferred.await(first)
yield* source.publish(event("two"))
yield* Deferred.await(second)
yield* source.publish(event("three"))
yield* Fiber.join(fastFiber)
const result = yield* slow.pipe(Stream.runCollect, Effect.exit)
expect(received).toEqual(["evt_one", "evt_two", "evt_three"])
expect(Exit.isFailure(result)).toBeTrue()
if (Exit.isSuccess(result)) return
expect(Option.getOrUndefined(Exit.findErrorOption(result))).toBeInstanceOf(EventFeed.SubscriberOverflowError)
}),
)
it.effect("filters internal events before they consume subscriber capacity", () =>
Effect.gen(function* () {
const source = makeSource()
const feed = yield* EventFeed.make(source.observe, { capacity: 1, encode: (event) => event.type })
const stream = yield* feed.subscribe
yield* source.publish(internal("one"))
yield* source.publish(internal("two"))
yield* source.publish(event("public"))
expect(Array.from(yield* stream.pipe(Stream.take(1), Stream.runCollect))).toEqual([AgentV2.Event.Updated.type])
}),
)
it.effect("disconnects current subscribers after an encoding failure and continues for later subscribers", () =>
Effect.gen(function* () {
const source = makeSource()
const feed = yield* EventFeed.make(source.observe, {
encode: (event) => {
if (event.id === EventV2.ID.make("evt_bad")) throw new Error("invalid event")
return event.id
},
})
const current = yield* feed.subscribe
const failed = yield* current.pipe(Stream.runCollect, Effect.exit, Effect.forkScoped)
yield* source.publish(event("bad"))
const exit = yield* Fiber.join(failed)
const next = yield* feed.subscribe
const received = yield* next.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* source.publish(event("good"))
expect(Exit.isFailure(exit)).toBeTrue()
if (Exit.isSuccess(exit)) return
expect(Option.getOrUndefined(Exit.findErrorOption(exit))).toBeInstanceOf(EventFeed.EncodingError)
expect(Array.from(yield* Fiber.join(received))).toEqual(["evt_good"])
}),
)
})
+4 -3
View File
@@ -12,7 +12,10 @@
"exports": {
".": "./src/index.tsx",
"./builtins": "./src/feature-plugins/builtins.ts",
"./config": "./src/config/index.tsx",
"./config/v1": "./src/config/v1/index.tsx",
"./config/v1/keybind": "./src/config/v1/keybind.ts",
"./config/v2": "./src/config/v2/index.ts",
"./config/v2/keybind": "./src/config/v2/keybind.ts",
"./context/args": "./src/context/args.tsx",
"./context/epilogue": "./src/context/epilogue.tsx",
"./context/exit": "./src/context/exit.tsx",
@@ -30,7 +33,6 @@
"./editor-zed": "./src/editor-zed.ts",
"./runtime": "./src/runtime.tsx",
"./terminal-win32": "./src/terminal-win32.ts",
"./config/keybind": "./src/config/keybind.ts",
"./keymap": "./src/keymap.tsx",
"./prompt/content": "./src/prompt/content.ts",
"./prompt/display": "./src/prompt/display.ts",
@@ -55,7 +57,6 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/ui": "workspace:*",
+36 -65
View File
@@ -3,7 +3,7 @@ import { registerOpencodeSpinner } from "./component/register-spinner"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { Deferred, Effect } from "effect"
import { Service } from "@opencode-ai/client/effect"
import { OpenCode } from "@opencode-ai/client/promise"
import { OpenCode } from "@opencode-ai/client"
import { Global } from "@opencode-ai/core/global"
import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
@@ -12,7 +12,14 @@ import { LogProvider, useLog, type LogSink } from "./context/log"
import { ExitProvider, useExit } from "./context/exit"
import { EpilogueProvider } from "./context/epilogue"
import * as Selection from "./util/selection"
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
import {
CliRenderEvents,
createCliRenderer,
MouseButton,
type CliRenderer,
type CliRendererConfig,
type ThemeMode,
} from "@opentui/core"
import { RouteProvider, useRoute } from "./context/route"
import {
Switch,
@@ -53,8 +60,6 @@ import { DialogThemeList } from "./component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "./component/dialog-agent"
import { DialogSessionList } from "./component/dialog-session-list"
import { DialogWorkspaceList } from "./component/dialog-workspace-list"
import { DialogConsoleOrg } from "./component/dialog-console-org"
import { ThemeProvider, useTheme } from "./context/theme"
import { Home } from "./routes/home"
import { Session } from "./routes/session"
@@ -70,7 +75,7 @@ import * as Model from "./util/model"
import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider, useTuiConfig, type TuiConfig } from "./config"
import { TuiConfigProvider, useTuiConfig, type TuiConfig } from "./config/v1"
import { createTuiApiAdapters } from "./plugin/adapters"
import { createTuiApi } from "./plugin/api"
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime, type TuiPluginHost } from "./plugin/runtime"
@@ -121,7 +126,6 @@ const appBindingCommands = [
"variant.cycle",
"variant.list",
"provider.connect",
"console.org.switch",
"opencode.status",
"server.pair",
"opencode.debug",
@@ -131,7 +135,6 @@ const appBindingCommands = [
"help.show",
"docs.open",
"diff.open",
"workspace.list",
"app.debug",
"app.console",
"app.heap_snapshot",
@@ -154,6 +157,14 @@ export type TuiInput = {
config: TuiConfig.Resolved
onSnapshot?: () => Promise<string[]>
pluginHost: TuiPluginHost
terminalHandoff?: () => Promise<
| {
readonly renderer: CliRenderer
readonly mode: ThemeMode | null
readonly complete: () => void
}
| undefined
>
log?: LogSink
}
@@ -198,6 +209,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
Effect.map((response) => response.location.directory),
Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))),
)
const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined
const reconnectEndpoint = input.server.reconnect
const reconnect = reconnectEndpoint
? async (attempt: number) => {
@@ -229,6 +241,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
},
} satisfies CliRendererConfig
if (handoff) {
handoff.renderer.useMouse = options.useMouse
return handoff.renderer
}
if (process.env.OPENCODE_DRIVE) {
const { Drive } = await import("@opencode-ai/simulation/frontend")
return Drive.create(options)
@@ -271,7 +288,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
yield* Effect.tryPromise(async () => {
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
void renderer.getPalette({ size: 16 }).catch(() => undefined)
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
const mode = handoff?.mode ?? (await renderer.waitForThemeMode(1000)) ?? "dark"
if (renderer.isDestroyed) return
await render(() => {
@@ -396,6 +413,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
</LogProvider>
)
}, renderer)
if (handoff) {
renderer.once(CliRenderEvents.FRAME, handoff.complete)
renderer.requestRender()
}
})
yield* Deferred.await(shutdown)
return { epilogue: exit.epilogue, reason: exit.reason }
@@ -422,10 +443,10 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
const keymap = useOpencodeKeymap()
const event = useEvent()
const sdk = useSDK()
const sync = useSync()
const toast = useToast()
const themeState = useTheme()
const { theme, mode, setMode, locked, lock, unlock } = themeState
const sync = useSync()
const data = useData()
const project = useProject()
const exit = useExit()
@@ -439,7 +460,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
// the same problem on every refresh while still re-alerting if the state changes.
const mcpAlerted: Record<string, string> = {}
createEffect(() => {
for (const server of data.location.mcp.list() ?? []) {
for (const server of data.location.mcp.server.list() ?? []) {
const status = server.status
if (status.status !== "failed" && status.status !== "needs_auth") {
delete mcpAlerted[server.name]
@@ -524,7 +545,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
}
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
const [pasteSummaryEnabled, setPasteSummaryEnabled] = createSignal(
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary),
kv.get("paste_summary_enabled", true),
)
// Update terminal window title based on current route and session
@@ -578,7 +599,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
let continued = false
createEffect(() => {
if (continued || sync.status === "loading" || !args.continue) return
if (continued || !args.continue) return
continued = true
const location = data.location.default()
void sdk.api.session
@@ -604,12 +625,10 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
.catch(toast.error)
})
// Handle --session with --fork: wait for sync to be fully complete before forking
// (session list loads in non-blocking phase for --session, so we must wait for "complete"
// to avoid a race where reconcile overwrites the newly forked session)
// Handle --session with --fork once.
let forked = false
createEffect(() => {
if (forked || sync.status !== "complete" || !args.sessionID || !args.fork) return
if (forked || !args.sessionID || !args.fork) return
forked = true
void sdk.api.session
.fork({ sessionID: args.sessionID })
@@ -618,13 +637,6 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
})
const connected = useConnected()
const currentWorktreeWorkspace = createMemo(() => {
const workspaceID = project.workspace.current()
if (!workspaceID) return
const workspace = project.workspace.get(workspaceID)
if (workspace?.type !== "worktree" || !workspace.directory) return
return workspace
})
const appCommands = createMemo(() =>
[
{
@@ -661,31 +673,6 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
dialog.clear()
},
},
{
name: "workspace.copy_path",
title: "Copy worktree path",
category: "Workspace",
enabled: () => currentWorktreeWorkspace() !== undefined,
run: async () => {
const workspace = currentWorktreeWorkspace()
if (!workspace?.directory) return
await clipboard
.write?.(workspace.directory)
.then(() => toast.show({ message: "Copied worktree path", variant: "info" }))
.catch(toast.error)
dialog.clear()
},
},
{
name: "workspace.list",
title: "Manage workspaces",
category: "Workspace",
hidden: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
slashName: "workspaces",
run: () => {
dialog.replace(() => <DialogWorkspaceList />)
},
},
...Array.from({ length: 9 }, (_, i) => ({
name: `session.quick_switch.${i + 1}`,
title: `Switch to session in quick slot ${i + 1}`,
@@ -754,7 +741,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
},
{
name: "mcp.list",
title: "Toggle MCPs",
title: "MCP Servers",
category: "Agent",
slashName: "mcps",
run: () => {
@@ -818,21 +805,6 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
},
category: "Integration",
},
...(sync.data.console_state.switchableOrgCount > 1
? [
{
name: "console.org.switch",
title: "Switch org",
suggested: Boolean(sync.data.console_state.activeOrgName),
slashName: "org",
slashAliases: ["orgs", "switch-org"],
run: () => {
dialog.replace(() => <DialogConsoleOrg />)
},
category: "Provider",
},
]
: []),
{
name: "opencode.status",
title: "View status",
@@ -1040,7 +1012,6 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
category: "System",
run: async () => {
kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true))
await sync.session.refresh()
dialog.clear()
},
},
+1 -1
View File
@@ -10,7 +10,7 @@ import type {
TuiAttentionSoundPack,
TuiAttentionSoundPackInfo,
} from "@opencode-ai/plugin/tui"
import { AttentionSoundName, type TuiConfig } from "./config"
import { AttentionSoundName, type TuiConfig } from "./config/v1"
import { Schema } from "effect"
import stripAnsi from "strip-ansi"
import * as TuiAudio from "./audio"
@@ -8,7 +8,7 @@ import {
useKeymapSelector,
useOpencodeKeymap,
} from "../keymap"
import { useTuiConfig } from "../config"
import { useTuiConfig } from "../config/v1"
type PaletteCommandEntry = ReturnType<OpenTuiKeymap["getCommandEntries"]>[number]
@@ -1,135 +0,0 @@
import { createResource, createMemo, createSignal } from "solid-js"
import { TextAttributes } from "@opentui/core"
import { DialogSelect } from "../ui/dialog-select"
import { useSDK } from "../context/sdk"
import { useDialog } from "../ui/dialog"
import { useToast } from "../ui/toast"
import { useTheme } from "../context/theme"
import { errorMessage } from "../util/error"
import type { ExperimentalConsoleListOrgsResponse } from "@opencode-ai/sdk/v2"
type OrgOption = ExperimentalConsoleListOrgsResponse["orgs"][number]
const accountHost = (url: string) => {
try {
return new URL(url).host
} catch {
return url
}
}
const accountLabel = (item: Pick<OrgOption, "accountEmail" | "accountUrl">) =>
`${item.accountEmail} ${accountHost(item.accountUrl)}`
export function DialogConsoleOrg() {
const sdk = useSDK()
const dialog = useDialog()
const toast = useToast()
const { theme } = useTheme()
const [loadError, setLoadError] = createSignal<unknown>()
const [orgs] = createResource(() =>
sdk.client.experimental.console
.listOrgs({}, { throwOnError: true })
.then((result) => result.data?.orgs ?? [])
// Catch so the rejected resource never reaches the memos below: reading
// orgs() in an errored state re-throws and tears down the dialog.
.catch((error) => {
setLoadError(error)
return undefined
}),
)
const showError = createMemo(() => Boolean(loadError()))
const current = createMemo(() => orgs()?.find((item) => item.active))
const options = createMemo(() => {
if (showError()) return []
const listed = orgs()
if (listed === undefined) {
return [
{
title: "Loading orgs...",
value: "loading",
onSelect: () => {},
},
]
}
if (listed.length === 0) {
return [
{
title: "No orgs found",
value: "empty",
onSelect: () => {},
},
]
}
return listed
.toSorted((a, b) => {
const activeAccountA = a.active ? 0 : 1
const activeAccountB = b.active ? 0 : 1
if (activeAccountA !== activeAccountB) return activeAccountA - activeAccountB
const accountCompare = accountLabel(a).localeCompare(accountLabel(b))
if (accountCompare !== 0) return accountCompare
return a.orgName.localeCompare(b.orgName)
})
.map((item) => ({
title: item.orgName,
value: item,
category: accountLabel(item),
categoryView: (
<box flexDirection="row" gap={2}>
<text fg={theme.accent}>{item.accountEmail}</text>
<text fg={theme.textMuted}>{accountHost(item.accountUrl)}</text>
</box>
),
onSelect: async () => {
if (item.active) {
dialog.clear()
return
}
await sdk.client.experimental.console.switchOrg(
{
accountID: item.accountID,
orgID: item.orgID,
},
{ throwOnError: true },
)
await sdk.client.instance.dispose()
toast.show({
message: `Switched to ${item.orgName}`,
variant: "info",
})
dialog.clear()
},
}))
})
return (
<DialogSelect<string | OrgOption>
title="Switch org"
options={options()}
current={current()}
renderFilter={!showError()}
locked={showError()}
emptyView={
showError() ? (
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.error} attributes={TextAttributes.BOLD}>
Could not load orgs
</text>
<text fg={theme.textMuted}>{errorMessage(loadError())}</text>
</box>
) : undefined
}
/>
)
}
@@ -1,6 +1,10 @@
import { TextAttributes } from "@opentui/core"
import type { IntegrationConnectOauthOutput } from "@opencode-ai/client/promise"
import type { ConnectionInfo, IntegrationInfo, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2"
import type {
ConnectionInfo,
IntegrationConnectOauthOutput,
IntegrationInfo,
IntegrationOAuthMethod,
} from "@opencode-ai/client"
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import { useClipboard } from "../context/clipboard"
import { useData } from "../context/data"
@@ -155,15 +159,29 @@ function openMethod(
onConnected?: OnIntegrationConnected,
) {
if (method.type === "key") {
dialog.replace(() => <KeyMethod integration={integration} method={method} onConnected={onConnected} />)
void beginKey(integration, method, dialog, onConnected)
return
}
void beginOAuth(integration, method, dialog, onConnected)
}
async function beginKey(
integration: IntegrationInfo,
method: Extract<ConnectMethod, { type: "key" }>,
dialog: ReturnType<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) {
const inputs = method.prompts?.length ? await promptInputs(dialog, method.prompts) : {}
if (inputs === null) return
dialog.replace(() => (
<KeyMethod integration={integration} method={method} inputs={inputs} onConnected={onConnected} />
))
}
function KeyMethod(props: {
integration: IntegrationInfo
method: Extract<ConnectMethod, { type: "key" }>
inputs: Record<string, string>
onConnected?: OnIntegrationConnected
}) {
const data = useData()
@@ -179,11 +197,12 @@ function KeyMethod(props: {
placeholder="API key"
onConfirm={(key) => {
if (!key) return
void sdk.api.integration
.connect.key({
void sdk.api.integration.connect
.key({
integrationID: props.integration.id,
location: location(data),
key,
inputs: props.inputs,
})
.then(() => connected(props.integration, data, dialog, toast, props.onConnected))
.catch((cause) => setError(message(cause)))
@@ -218,8 +237,8 @@ function OAuthStarting(props: {
const toast = useToast()
onMount(() => {
void sdk.api.integration
.connect.oauth({
void sdk.api.integration.connect
.oauth({
integrationID: props.integration.id,
location: location(data),
methodID: props.method.id,
@@ -287,8 +306,8 @@ function OAuthAuto(props: {
}))
const poll = () => {
void sdk.api.integration
.attempt.status({ attemptID: props.attempt.attemptID, location: location(data) })
void sdk.api.integration.attempt
.status({ attemptID: props.attempt.attemptID, location: location(data) })
.then((result) => {
const status = result.data
if (status.status === "pending") {
@@ -353,8 +372,8 @@ function OAuthCode(props: {
placeholder="Authorization code"
onConfirm={(code) => {
if (!code) return
void sdk.api.integration
.attempt.complete({ attemptID: props.attempt.attemptID, location: location(data), code })
void sdk.api.integration.attempt
.complete({ attemptID: props.attempt.attemptID, location: location(data), code })
.then(() => {
settled = true
return connected(props.integration, data, dialog, toast, props.onConnected)
+3 -3
View File
@@ -5,11 +5,11 @@ import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useTheme, type Theme } from "../context/theme"
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import type { McpServer } from "@opencode-ai/sdk/v2"
import type { McpServer } from "@opencode-ai/client"
import { useClipboard } from "../context/clipboard"
import { useToast } from "../ui/toast"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { useTuiConfig } from "../config"
import { useTuiConfig } from "../config/v1"
import { getScrollAcceleration } from "../util/scroll"
import { useBindings } from "../keymap"
@@ -45,7 +45,7 @@ export function DialogMcp() {
const servers = createMemo(() =>
pipe(
data.location.mcp.list() ?? [],
data.location.mcp.server.list() ?? [],
sortBy(
(server) => statusMeta(server.status, theme).rank,
(server) => server.name,
@@ -6,7 +6,7 @@ import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useSDK } from "../context/sdk"
import { useTheme } from "../context/theme"
import { useSync } from "../context/sync"
import { useData } from "../context/data"
import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "../context/runtime"
import { Locale } from "../util/locale"
@@ -17,7 +17,7 @@ import { useCommandShortcut } from "../keymap"
import { useProject } from "../context/project"
import { Spinner } from "./spinner"
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
import type { ProjectDirectoriesOutput } from "@opencode-ai/client/promise"
import type { ProjectDirectoriesOutput } from "@opencode-ai/client"
import { useRoute } from "../context/route"
import { DialogProjectCopyName } from "./dialog-project-copy-name"
@@ -38,7 +38,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const sdk = useSDK()
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
const sync = useSync()
const sessionData = useData()
const projectContext = useProject()
const route = useRoute()
const toast = useToast()
@@ -132,9 +132,13 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
})
if (roots.length === 0) return [{ title: "No project directories found", value: undefined }]
const subdirectories = sync.data.session
.filter((session) => session.projectID === props.projectID && session.path && ![".", "/"].includes(session.path))
.map((session) => session.directory)
const subdirectories = sessionData.session
.list()
.filter(
(session) =>
session.projectID === props.projectID && session.subpath && ![".", "/"].includes(session.subpath),
)
.map((session) => session.location.directory)
.filter((directory) => !roots.some((root) => root.directory === directory))
.filter((directory, index, directories) => directories.indexOf(directory) === index)
.map((location) => ({
@@ -1,7 +1,7 @@
import { InputRenderable, TextAttributes } from "@opentui/core"
import { Slug } from "@opencode-ai/core/util/slug"
import { createSignal, onMount } from "solid-js"
import { useTuiConfig } from "../config"
import { useTuiConfig } from "../config/v1"
import { useTheme } from "../context/theme"
import { useBindings, useCommandShortcut } from "../keymap"
import { useDialog, type DialogContext } from "../ui/dialog"
@@ -1,469 +0,0 @@
import { createMemo, createSignal, onMount, Show } from "solid-js"
import { useSync } from "../context/sync"
import { map, pipe, sortBy } from "remeda"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useSDK } from "../context/sdk"
import { DialogPrompt } from "../ui/dialog-prompt"
import { Link } from "../ui/link"
import { useTheme } from "../context/theme"
import { TextAttributes } from "@opentui/core"
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2"
import { DialogModel } from "./dialog-model"
import { useToast } from "../ui/toast"
import { isConsoleManagedProvider } from "../util/provider-origin"
import { useConnected } from "./use-connected"
import { useBindings } from "../keymap"
import { useClipboard } from "../context/clipboard"
const PROVIDER_PRIORITY: Record<string, number> = {
opencode: 0,
"opencode-go": 1,
openai: 2,
"github-copilot": 3,
anthropic: 4,
google: 5,
}
const CUSTOM_PROVIDER_OPTION_VALUE = "__opencode_custom_provider__"
const CUSTOM_PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
type ProviderOptionBase = {
title: string
value: string
description?: string
category: string
}
type ProviderOption =
| (ProviderOptionBase & {
type: "provider"
providerID: string
})
| (ProviderOptionBase & {
type: "custom"
})
export function providerOptions(list: { id: string; name: string }[]): ProviderOption[] {
return [
...pipe(
list,
sortBy(
(x) => PROVIDER_PRIORITY[x.id] ?? 99,
(x) => x.name.toLowerCase(),
(x) => x.id,
),
map((provider) => ({
type: "provider" as const,
title: provider.name,
value: provider.id,
providerID: provider.id,
description: {
opencode: "(Recommended)",
anthropic: "(API key)",
openai: "(ChatGPT Plus/Pro or API key)",
"opencode-go": "Low cost subscription for everyone",
}[provider.id],
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Providers",
})),
),
{
type: "custom",
title: "Other",
value: CUSTOM_PROVIDER_OPTION_VALUE,
description: "Custom provider",
category: "Providers",
},
]
}
export function normalizeCustomProviderID(value: string) {
const providerID = value.trim().replace(/^@ai-sdk\//, "")
if (!CUSTOM_PROVIDER_ID.test(providerID)) return
return providerID
}
export function createDialogProviderOptions() {
const sync = useSync()
const dialog = useDialog()
const sdk = useSDK()
const toast = useToast()
const { theme } = useTheme()
const onboarded = useConnected()
async function promptCustomProviderID(): Promise<string | undefined> {
const value = await DialogPrompt.show(dialog, "Other", {
placeholder: "Provider id",
description: () => (
<text fg={theme.textMuted}>
This only stores a credential. Configure the provider in opencode.json to use it.
</text>
),
})
if (value === null) return
const providerID = normalizeCustomProviderID(value)
if (providerID) return providerID
toast.show({
variant: "error",
message:
"Provider ids must start with a lowercase letter or number and only use lowercase letters, numbers, hyphens, and underscores",
})
return promptCustomProviderID()
}
const options = createMemo(() => {
return pipe(
providerOptions(sync.data.provider_next.all),
map((provider) => {
if (provider.type === "custom") {
return {
title: provider.title,
value: provider.value,
description: provider.description,
category: provider.category,
async onSelect() {
const providerID = await promptCustomProviderID()
if (!providerID) return
return dialog.replace(() => <ApiMethod providerID={providerID} title="API key" custom />)
},
}
}
const providerID = provider.providerID
const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, providerID)
const connected = sync.data.provider_next.connected.includes(providerID)
return {
title: provider.title,
value: provider.value,
description: provider.description,
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
category: provider.category,
gutter: connected && onboarded() ? () => <text fg={theme.success}></text> : undefined,
async onSelect() {
if (consoleManaged) return
const methods = sync.data.provider_auth[providerID] ?? [
{
type: "api",
label: "API key",
},
]
let index: number | null = 0
if (methods.length > 1) {
index = await new Promise<number | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title="Select auth method"
options={methods.map((x, index) => ({
title: x.label,
value: index,
}))}
onSelect={(option) => resolve(option.value)}
/>
),
() => resolve(null),
)
})
}
if (index == null) return
const method = methods[index]
if (method.type === "oauth") {
let inputs: Record<string, string> | undefined
if (method.prompts?.length) {
const value = await PromptsMethod({
dialog,
prompts: method.prompts,
})
if (!value) return
inputs = value
}
const result = await sdk.client.provider.oauth.authorize({
providerID,
method: index,
inputs,
})
if (result.error) {
toast.show({
variant: "error",
message: JSON.stringify(result.error),
})
dialog.clear()
return
}
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod providerID={providerID} title={method.label} index={index} authorization={result.data!} />
))
}
if (result.data?.method === "auto") {
dialog.replace(() => (
<AutoMethod providerID={providerID} title={method.label} index={index} authorization={result.data!} />
))
}
}
if (method.type === "api") {
let metadata: Record<string, string> | undefined
if (method.prompts?.length) {
const value = await PromptsMethod({ dialog, prompts: method.prompts })
if (!value) return
metadata = value
}
return dialog.replace(() => (
<ApiMethod providerID={providerID} title={method.label} metadata={metadata} />
))
}
},
}
}),
)
})
return options
}
export function DialogProvider() {
const options = createDialogProviderOptions()
return <DialogSelect title="Connect a provider" options={options()} />
}
interface AutoMethodProps {
index: number
providerID: string
title: string
authorization: ProviderAuthAuthorization
}
function AutoMethod(props: AutoMethodProps) {
const { theme } = useTheme()
const sdk = useSDK()
const dialog = useDialog()
const sync = useSync()
const toast = useToast()
const clipboard = useClipboard()
useBindings(() => ({
bindings: [
{
key: "c",
desc: "Copy provider code",
group: "Dialog",
cmd: () => {
const code =
props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url
clipboard
.write?.(code)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
},
},
],
}))
onMount(async () => {
const result = await sdk.client.provider.oauth.callback({
providerID: props.providerID,
method: props.index,
})
if (result.error) {
toast.show({
variant: "error",
message:
"name" in result.error && result.error.name === "ProviderAuthOauthCallbackFailed"
? "OAuth authorization failed. Try /connect again."
: JSON.stringify(result.error),
})
dialog.clear()
return
}
await sdk.client.instance.dispose()
await sync.bootstrap()
dialog.replace(() => <DialogModel providerID={props.providerID} />)
})
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
{props.title}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box gap={1}>
<Link href={props.authorization.url} fg={theme.primary} />
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
</box>
<text fg={theme.textMuted}>Waiting for authorization...</text>
<text fg={theme.text}>
c <span style={{ fg: theme.textMuted }}>copy</span>
</text>
</box>
)
}
interface CodeMethodProps {
index: number
title: string
providerID: string
authorization: ProviderAuthAuthorization
}
function CodeMethod(props: CodeMethodProps) {
const { theme } = useTheme()
const sdk = useSDK()
const sync = useSync()
const dialog = useDialog()
const [error, setError] = createSignal(false)
return (
<DialogPrompt
title={props.title}
placeholder="Authorization code"
onConfirm={async (value) => {
const { error } = await sdk.client.provider.oauth.callback({
providerID: props.providerID,
method: props.index,
code: value,
})
if (!error) {
await sdk.client.instance.dispose()
await sync.bootstrap()
dialog.replace(() => <DialogModel providerID={props.providerID} />)
return
}
setError(true)
}}
description={() => (
<box gap={1}>
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
<Link href={props.authorization.url} fg={theme.primary} />
<Show when={error()}>
<text fg={theme.error}>Invalid code</text>
</Show>
</box>
)}
/>
)
}
interface ApiMethodProps {
providerID: string
title: string
metadata?: Record<string, string>
custom?: boolean
}
function ApiMethod(props: ApiMethodProps) {
const dialog = useDialog()
const sdk = useSDK()
const sync = useSync()
const toast = useToast()
const { theme } = useTheme()
return (
<DialogPrompt
title={props.title}
placeholder="API key"
description={() =>
({
opencode: (
<box gap={1}>
<text fg={theme.textMuted}>
OpenCode Zen gives you access to all the best coding models at the cheapest prices with a single API
key.
</text>
<text fg={theme.text}>
Go to <span style={{ fg: theme.primary }}>https://opencode.ai/zen</span> to get a key
</text>
</box>
),
"opencode-go": (
<box gap={1}>
<text fg={theme.textMuted}>
OpenCode Go is a $10 per month subscription that provides reliable access to popular open coding models
with generous usage limits.
</text>
<text fg={theme.text}>
Go to <span style={{ fg: theme.primary }}>https://opencode.ai/go</span> and enable OpenCode Go
</text>
</box>
),
})[props.providerID] ?? undefined
}
onConfirm={async (value) => {
if (!value) return
await sdk.client.auth.set({
providerID: props.providerID,
auth: {
type: "api",
key: value,
...(props.metadata ? { metadata: props.metadata } : {}),
},
})
await sdk.client.instance.dispose()
await sync.bootstrap()
if (props.custom && !sync.data.provider_next.all.some((provider) => provider.id === props.providerID)) {
toast.show({
variant: "info",
message: `Saved credential for ${props.providerID}. Configure it in opencode.json to use it.`,
})
dialog.clear()
return
}
dialog.replace(() => <DialogModel providerID={props.providerID} />)
}}
/>
)
}
interface PromptsMethodProps {
dialog: ReturnType<typeof useDialog>
prompts: NonNullable<ProviderAuthMethod["prompts"]>[number][]
}
async function PromptsMethod(props: PromptsMethodProps) {
const inputs: Record<string, string> = {}
for (const prompt of props.prompts) {
if (prompt.when) {
const value = inputs[prompt.when.key]
if (value === undefined) continue
const matches = prompt.when.op === "eq" ? value === prompt.when.value : value !== prompt.when.value
if (!matches) continue
}
if (prompt.type === "select") {
const value = await new Promise<string | null>((resolve) => {
props.dialog.replace(
() => (
<DialogSelect
title={prompt.message}
options={prompt.options.map((x) => ({
title: x.label,
value: x.value,
description: x.hint,
}))}
onSelect={(option) => resolve(option.value)}
/>
),
() => resolve(null),
)
})
if (value === null) return null
inputs[prompt.key] = value
continue
}
const value = await new Promise<string | null>((resolve) => {
props.dialog.replace(
() => (
<DialogPrompt title={prompt.message} placeholder={prompt.placeholder} onConfirm={(value) => resolve(value)} />
),
() => resolve(null),
)
})
if (value === null) return null
inputs[prompt.key] = value
}
return inputs
}
@@ -1,6 +1,6 @@
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import path from "path"
import type { SessionInfo } from "@opencode-ai/sdk/v2"
import type { SessionInfo } from "@opencode-ai/client"
import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select"
import { useRoute } from "../context/route"
+1 -1
View File
@@ -5,7 +5,7 @@ import { useDialog } from "../ui/dialog"
import { useTheme } from "../context/theme"
import { errorMessage } from "../util/error"
import { useData } from "../context/data"
import type { LocationRef } from "@opencode-ai/sdk/v2"
import type { LocationRef } from "@opencode-ai/client"
export type DialogSkillProps = {
location?: LocationRef
+1 -102
View File
@@ -1,48 +1,17 @@
import { TextAttributes } from "@opentui/core"
import { fileURLToPath } from "bun"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { useSync } from "../context/sync"
import { useData } from "../context/data"
import { For, Match, Switch, Show, createMemo } from "solid-js"
export type DialogStatusProps = {}
export function DialogStatus() {
const sync = useSync()
const data = useData()
const { theme } = useTheme()
const dialog = useDialog()
const mcp = createMemo(() => data.location.mcp.list() ?? [])
const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled))
const plugins = createMemo(() => {
const list = sync.data.config.plugin ?? []
const result = list.map((item) => {
const value = typeof item === "string" ? item : item[0]
if (value.startsWith("file://")) {
const path = fileURLToPath(value)
const parts = path.split("/")
const filename = parts.pop() || path
if (!filename.includes(".")) return { name: filename }
const basename = filename.split(".")[0]
if (basename === "index") {
const dirname = parts.pop()
const name = dirname || basename
return { name }
}
return { name: basename }
}
const index = value.lastIndexOf("@")
if (index <= 0) return { name: value, version: "latest" }
const name = value.substring(0, index)
const version = value.substring(index + 1)
return { name, version }
})
return result.toSorted((a, b) => a.name.localeCompare(b.name))
})
const mcp = createMemo(() => data.location.mcp.server.list() ?? [])
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
@@ -94,76 +63,6 @@ export function DialogStatus() {
</For>
</box>
</Show>
{sync.data.lsp.length > 0 && (
<box>
<text fg={theme.text}>{sync.data.lsp.length} LSP Servers</text>
<For each={sync.data.lsp}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: {
connected: theme.success,
error: theme.error,
}[item.status],
}}
>
</text>
<text fg={theme.text} wrapMode="word">
<b>{item.id}</b> <span style={{ fg: theme.textMuted }}>{item.root}</span>
</text>
</box>
)}
</For>
</box>
)}
<Show when={enabledFormatters().length > 0} fallback={<text fg={theme.text}>No Formatters</text>}>
<box>
<text fg={theme.text}>{enabledFormatters().length} Formatters</text>
<For each={enabledFormatters()}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: theme.success,
}}
>
</text>
<text wrapMode="word" fg={theme.text}>
<b>{item.name}</b>
</text>
</box>
)}
</For>
</box>
</Show>
<Show when={plugins().length > 0} fallback={<text fg={theme.text}>No Plugins</text>}>
<box>
<text fg={theme.text}>{plugins().length} Plugins</text>
<For each={plugins()}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: theme.success,
}}
>
</text>
<text wrapMode="word" fg={theme.text}>
<b>{item.name}</b>
{item.version && <span style={{ fg: theme.textMuted }}> @{item.version}</span>}
</text>
</box>
)}
</For>
</box>
</Show>
</box>
)
}
@@ -1,308 +0,0 @@
import type { ExperimentalWorkspaceAdapterListResponse, Workspace } from "@opencode-ai/sdk/v2"
import { useDialog } from "../ui/dialog"
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { useSync } from "../context/sync"
import { useProject } from "../context/project"
import { useRoute } from "../context/route"
import { createMemo, createSignal, onMount } from "solid-js"
import { errorMessage } from "../util/error"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
import { DialogAlert } from "../ui/dialog-alert"
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
type Adapter = ExperimentalWorkspaceAdapterListResponse[number]
export type WorkspaceSelection =
| {
type: "none"
}
| {
type: "new"
workspaceType: string
workspaceName: string
}
| {
type: "existing"
workspaceID: string
workspaceType: string
workspaceName: string
}
type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" }
type ExistingWorkspaceSelectValue = { workspace: Workspace }
export function recentConnectedWorkspaces<WorkspaceInfo extends { id: string; timeUsed: number | string }>(input: {
workspaces: readonly WorkspaceInfo[]
status: (workspaceID: string) => string | undefined
limit?: number
omitWorkspaceID?: string
}) {
const allWorkspaces = input.workspaces.filter((workspace) => input.status(workspace.id) === "connected")
const workspaces = allWorkspaces.toSorted((a, b) => Number(b.timeUsed) - Number(a.timeUsed))
const recent = workspaces.slice(0, input.limit ?? 3)
return { recent, hasMore: recent.length < workspaces.length }
}
export function warpReminderText(dir: string) {
return `<system-reminder>The user has changed the current working directory to "${dir}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.</system-reminder>`
}
async function loadWorkspaceAdapters(input: {
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
}) {
const dir = input.sync.path.directory || process.cwd()
try {
const response = await input.sdk.client.experimental.workspace.adapter.list({ directory: dir })
if (response.error) throw response.error
return response.data
} catch (err) {
input.toast.show({
title: "Failed to load workspace adapters",
message: errorMessage(err),
variant: "error",
})
return undefined
}
}
export async function openWorkspaceSelect(input: {
dialog: ReturnType<typeof useDialog>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
project: ReturnType<typeof useProject>
toast: ReturnType<typeof useToast>
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
}) {
input.dialog.clear()
await input.sdk.client.experimental.workspace.syncList().catch(() => undefined)
await input.project.workspace.sync().catch(() => undefined)
const adapters = await loadWorkspaceAdapters(input)
if (!adapters) return
input.dialog.replace(() => <DialogWorkspaceSelect adapters={adapters} onSelect={input.onSelect} />)
}
export async function warpWorkspaceSession(input: {
dialog: ReturnType<typeof useDialog>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
project: ReturnType<typeof useProject>
toast: ReturnType<typeof useToast>
sourceWorkspaceID?: string
workspaceID: string | null
sessionID: string
copyChanges: boolean
done?: () => void
}): Promise<boolean> {
let result
try {
result = await input.sdk.client.experimental.workspace.warp({
id: input.workspaceID,
sessionID: input.sessionID,
copyChanges: input.copyChanges,
})
} catch (err) {
input.toast.show({
title: "Failed to warp session",
message: errorMessage(err),
variant: "error",
})
return false
}
if (!result?.data) {
if (result?.error && "name" in result.error && result.error.name === "VcsApplyError") {
await DialogAlert.show(
input.dialog,
"Unable to Warp Session",
"Unable to apply file changes to this workspace. It has existing changes that conflict or is based off a different branch. Session has not been warped.",
)
return false
}
input.toast.show({
title: "Failed to warp session",
message: errorMessage(result?.error ?? "no response"),
variant: "error",
})
return false
}
input.project.workspace.set(input.workspaceID)
await input.sync.bootstrap({ fatal: false }).catch(() => undefined)
const dir = input.project.instance.directory() || input.sync.path.directory
if (dir) {
await input.sdk.client.session
.promptAsync({
sessionID: input.sessionID,
workspace: input.workspaceID ?? undefined,
noReply: true,
parts: [
{
type: "text",
text: warpReminderText(dir),
synthetic: true,
},
],
})
.catch(() => undefined)
}
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()])
if (input.done) {
input.done()
return true
}
input.dialog.clear()
return true
}
export async function confirmWorkspaceFileChanges(input: {
dialog: ReturnType<typeof useDialog>
sdk: ReturnType<typeof useSDK>
sourceWorkspaceID?: string
}) {
const status = await input.sdk.client.vcs.status({ workspace: input.sourceWorkspaceID }).catch(() => undefined)
const fileChangeChoice = status?.data?.length
? await DialogWorkspaceFileChanges.show(input.dialog, status.data)
: "no"
if (!fileChangeChoice) return
return fileChangeChoice === "yes"
}
export function DialogWorkspaceSelect(props: {
adapters?: Adapter[]
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
}) {
const dialog = useDialog()
const project = useProject()
const route = useRoute()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const [adapters, setAdapters] = createSignal<Adapter[] | undefined>(props.adapters)
const omittedWorkspaceID = createMemo(() => (route.data.type === "session" ? project.workspace.current() : undefined))
onMount(() => {
dialog.setSize("medium")
void (async () => {
if (adapters()) return
const res = await loadWorkspaceAdapters({ sdk, sync, toast })
if (!res) return
setAdapters(res)
})()
})
const options = createMemo<DialogSelectOption<WorkspaceSelectValue>[]>(() => {
const list = adapters()
if (!list) return []
const { recent, hasMore } = recentConnectedWorkspaces({
workspaces: project.workspace.list(),
status: project.workspace.status,
omitWorkspaceID: omittedWorkspaceID(),
})
return [
...list.map((adapter) => ({
title: adapter.name,
value: { type: "new" as const, workspaceType: adapter.type, workspaceName: adapter.name },
description: adapter.description,
category: "New workspace",
})),
{
title: "None",
value: { type: "none" as const },
description: "Use the local project",
category: "Choose workspace",
},
...recent.map((workspace: Workspace) => ({
title: workspace.name,
description: `(${workspace.type})`,
value: {
type: "existing" as const,
workspaceID: workspace.id,
workspaceType: workspace.type,
workspaceName: workspace.name,
},
category: "Choose workspace",
})),
...(hasMore
? [
{
title: "View all workspaces",
value: { type: "existing-list" as const },
description: "Choose from all workspaces",
category: "Choose workspace",
},
]
: []),
]
})
if (!adapters()) return null
return (
<DialogSelect<WorkspaceSelectValue>
title="Warp"
skipFilter={true}
renderFilter={false}
options={options()}
onSelect={(option) => {
if (!option.value) return
if (option.value.type === "none") {
void props.onSelect(option.value)
return
}
if (option.value.type === "new") {
void props.onSelect(option.value)
return
}
if (option.value.type === "existing") {
void props.onSelect(option.value)
return
}
dialog.replace(() => (
<DialogExistingWorkspaceSelect omitWorkspaceID={omittedWorkspaceID()} onSelect={props.onSelect} />
))
}}
/>
)
}
function DialogExistingWorkspaceSelect(props: {
omitWorkspaceID?: string
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
}) {
const project = useProject()
const options = createMemo<DialogSelectOption<ExistingWorkspaceSelectValue>[]>(() =>
project.workspace
.list()
.filter((workspace) => project.workspace.status(workspace.id) === "connected")
.filter((workspace) => workspace.id !== props.omitWorkspaceID)
.map((workspace: Workspace) => ({
title: workspace.name,
description: `(${workspace.type})`,
value: { workspace },
})),
)
return (
<DialogSelect<ExistingWorkspaceSelectValue>
title="Existing Workspace"
options={options()}
onSelect={(option) => {
void props.onSelect({
type: "existing",
workspaceID: option.value.workspace.id,
workspaceType: option.value.workspace.type,
workspaceName: option.value.workspace.name,
})
}}
/>
)
}
@@ -1,11 +1,11 @@
import { TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import type { VcsFileStatus } from "@opencode-ai/sdk/v2"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import type { VcsFileStatus } from "@opencode-ai/client"
import { createMemo, For } from "solid-js"
import { createStore } from "solid-js/store"
import { Locale } from "../util/locale"
import { FilePath } from "../ui/file-path"
import { useTheme } from "../context/theme"
import { useTuiConfig } from "../config"
import { useTuiConfig } from "../config/v1"
import { useDialog, type DialogContext } from "../ui/dialog"
import { getScrollAcceleration } from "../util/scroll"
@@ -33,10 +33,13 @@ export function DialogWorkspaceFileChanges(props: {
const dialog = useDialog()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const dimensions = useTerminalDimensions()
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const [store, setStore] = createStore({ active: "yes" as WorkspaceFileChangesChoice })
const height = createMemo(() => Math.min(props.files.length, 8))
const fileNameWidth = createMemo(() => 48 - Math.max(Math.max(7, ...props.files.map(changeCountWidth)) - 7, 0))
const fileNameWidth = createMemo(
() => Math.max(2, Math.min(60, dimensions().width - 2) - 6 - Math.max(7, ...props.files.map(changeCountWidth))),
)
function confirm() {
props.onSelect(store.active)
@@ -93,9 +96,7 @@ export function DialogWorkspaceFileChanges(props: {
<box width={2} flexShrink={0}>
<text fg={theme.textMuted}>{statusLabel(item.status)}</text>
</box>
<text fg={theme.textMuted} wrapMode="none">
{Locale.truncateLeft(item.file, fileNameWidth())}
</text>
<FilePath value={item.file} maxWidth={fileNameWidth()} fg={theme.textMuted} />
</box>
<box flexDirection="row" gap={1} minWidth={7} flexShrink={0} justifyContent="flex-end">
<text>
@@ -1,112 +0,0 @@
import type { Workspace } from "@opencode-ai/sdk/v2"
import { useDialog } from "../ui/dialog"
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { useProject } from "../context/project"
import { useRoute } from "../context/route"
import { useSync } from "../context/sync"
import { useTheme } from "../context/theme"
import { createMemo, createSignal, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { errorMessage } from "../util/error"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
type WorkspaceOption = { workspace: Workspace }
export function DialogWorkspaceList() {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const project = useProject()
const { theme } = useTheme()
const [deleting, setDeleting] = createSignal<string>()
const [removing, setRemoving] = createSignal<string>()
const [expanded, setExpanded] = createStore<Record<string, boolean>>({})
const current = createMemo(() => {
if (route.data.type === "session") return sync.session.get(route.data.sessionID)?.workspaceID
return project.workspace.current()
})
const options = createMemo<DialogSelectOption<WorkspaceOption>[]>(() =>
project.workspace
.list()
.toSorted((a, b) => a.name.localeCompare(b.name))
.map((workspace) => {
const status = project.workspace.status(workspace.id)
return {
title:
removing() === workspace.id
? "Deleting..."
: deleting() === workspace.id
? `Delete ${workspace.name}? Press delete again`
: workspace.name,
value: { workspace },
footer: workspace.type,
details: expanded[workspace.id] && workspace.directory ? [workspace.directory] : undefined,
gutter: () => <text fg={status === "connected" ? theme.success : theme.error}></text>,
}
}),
)
function showDetails(workspace: Workspace) {
setExpanded(workspace.id, (open) => !open)
}
async function remove(workspace: Workspace) {
if (removing()) return
if (deleting() !== workspace.id) {
setDeleting(workspace.id)
return
}
setDeleting(undefined)
setRemoving(workspace.id)
const result = await sdk.client.experimental.workspace.remove({ id: workspace.id }).catch((err) => ({
error: err,
}))
if (result?.error) {
setRemoving(undefined)
toast.show({
variant: "error",
title: "Failed to delete workspace",
message: errorMessage(result.error),
})
return
}
if (current() === workspace.id) {
project.workspace.set(undefined)
route.navigate({ type: "home" })
}
await project.workspace.sync()
await sync.bootstrap({ fatal: false }).catch(() => undefined)
setRemoving(undefined)
}
onMount(() => {
dialog.setSize("large")
void sdk.client.experimental.workspace.syncList().catch(() => undefined)
void project.workspace.sync()
})
return (
<DialogSelect
title="Workspaces"
options={options()}
onMove={(option) => {
setDeleting(undefined)
}}
onSelect={(option) => showDetails(option.value.workspace)}
actions={[
{
command: "session.delete",
title: "delete",
onTrigger: (option) => void remove(option.value.workspace),
},
]}
/>
)
}
@@ -1,69 +0,0 @@
import { TextAttributes } from "@opentui/core"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { useBindings } from "../keymap"
export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise<boolean | void> }) {
const dialog = useDialog()
const { theme } = useTheme()
const [store, setStore] = createStore({
active: "restore" as "cancel" | "restore",
})
const options = ["cancel", "restore"] as const
async function confirm() {
if (store.active === "cancel") {
dialog.clear()
return
}
const result = await props.onRestore?.()
if (result === false) return
}
useBindings(() => ({
bindings: [
{ key: "return", desc: "Confirm workspace option", group: "Dialog", cmd: () => void confirm() },
{ key: "left", desc: "Cancel workspace restore", group: "Dialog", cmd: () => setStore("active", "cancel") },
{ key: "right", desc: "Restore workspace", group: "Dialog", cmd: () => setStore("active", "restore") },
],
}))
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Workspace Unavailable
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={theme.textMuted} wrapMode="word">
This session is attached to a workspace that is no longer available.
</text>
<text fg={theme.textMuted} wrapMode="word">
Would you like to restore this session into a new workspace?
</text>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1} gap={1}>
<For each={options}>
{(item) => (
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={item === store.active ? theme.primary : undefined}
onMouseUp={() => {
setStore("active", item)
void confirm()
}}
>
<text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
</box>
)}
</For>
</box>
</box>
)
}

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