Compare commits

..
77 changed files with 685 additions and 2541 deletions
-1
View File
@@ -168,7 +168,6 @@
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
"@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2",
"@yuuang/ffi-rs-win32-x64-msvc": "1.3.2",
"solid-refresh": "0.6.3",
"vite": "catalog:",
"vite-plugin-solid": "catalog:",
},
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-/5VErB3NjnKi0/LHqqJgcDadD9woNLMZZYxUjriRvJI=",
"aarch64-linux": "sha256-CTXqFEvQIiKDe0OmtdkdY8KLQtQOxdsJNCom/Clzc1c=",
"aarch64-darwin": "sha256-vF2+/jgWhF1Smef9U3nSpTS3RI5ZcriV0mjg1q9s8YM=",
"x86_64-darwin": "sha256-suCQ+yDT048D3EbjFAzplyZedYZYAihseLkqg6c+wHc="
"x86_64-linux": "sha256-EKhY3iZDrbNrBhntWpSdtLcmNLte6yVBxpIrCxr1uNM=",
"aarch64-linux": "sha256-0OjDGZHgcnnk6IxkfK6ogeeqsGTqY/dcaZ/XzT23sgA=",
"aarch64-darwin": "sha256-Zk51gnOicaLtPuqCYfgARhm2TjL222w1Y0Em288o0YY=",
"x86_64-darwin": "sha256-hvDZ9zCV6zOSx6i7JZ1kVUMht+JI/jc8/y+aYrNHQ1E="
}
}
-2
View File
@@ -9,8 +9,6 @@
"scripts": {
"dev": "bun run --cwd packages/cli src/index.ts",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:vite": "bun run --cwd packages/cli --conditions=browser dev/vite.ts",
"dev:vite:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev:vite \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
@@ -1,5 +1,5 @@
import { base64Encode } from "@opencode/util/encode"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
import type { OpenCodeEvent, SessionMessageAssistantTool, SessionMessageInfo } from "@opencode/client/promise"
import { expect, test, type Page } from "@playwright/test"
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
@@ -35,6 +35,91 @@ test("returns to the parent session with Escape", async ({ page }) => {
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)])
})
for (const input of ["click", "keyboard", "narrow", "modified", "middle"] as const) {
test(`opens a background subagent from Session details with ${input}`, async ({ page, context }) => {
if (input === "narrow") await page.setViewportSize({ width: 390, height: 844 })
await setup(page, { background: true })
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
if (input === "narrow") {
await page
.locator('[data-slot="session-mobile-view-navigation"]')
.getByRole("button", { name: "More options", exact: true })
.click()
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
}
if (input !== "narrow") await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("button", { name: "2 background tasks running", exact: true })
await summary.click()
const list = page.locator('[data-component="session-background-list"]')
const link = list.getByRole("link", { name: `Explore ${taskDescription}`, exact: true })
await expect(link).toHaveAttribute("href", sessionHref(childID))
await expect(list.getByRole("link")).toHaveCount(1)
await expect(list.getByText("sleep 120", { exact: true })).toBeVisible()
if (input === "modified" || input === "middle") {
const opened = context.waitForEvent("page")
await link.click(input === "middle" ? { button: "middle" } : { modifiers: ["ControlOrMeta"] })
const child = await opened
await expect(child).toHaveURL(sessionHref(childID))
await expect(page).toHaveURL(sessionHref(parentID))
await child.close()
return
}
if (input === "keyboard") {
await expect(link).toBeFocused()
await page.keyboard.press("Escape")
await expect(list).toBeHidden()
await expect(summary).toBeFocused()
await page.keyboard.press("Enter")
await expect(link).toBeFocused()
await page.keyboard.press("Enter")
}
if (input !== "keyboard") await link.click()
await expect(page).toHaveURL(sessionHref(childID))
await expectSessionTitle(page, input === "narrow" ? childTitle : taskDescription)
await expect(list).toBeHidden()
await expect(page.locator("[data-titlebar-tab-link]")).toHaveCount(1)
await page.keyboard.press("Escape")
await expect(page).toHaveURL(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
})
}
for (const input of ["click", "keyboard", "modified", "middle"] as const) {
test(`opens a finished background subagent from the timeline with ${input}`, async ({ page, context }) => {
await setup(page, { completion: true })
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
const link = page.getByRole("link", { name: `explore finished · ${taskDescription}`, exact: true })
await expect(link).toHaveAttribute("href", sessionHref(childID))
if (input === "modified" || input === "middle") {
const opened = context.waitForEvent("page")
await link.click(input === "middle" ? { button: "middle" } : { modifiers: ["ControlOrMeta"] })
const child = await opened
await expect(child).toHaveURL(sessionHref(childID))
await expect(page).toHaveURL(sessionHref(parentID))
await child.close()
return
}
if (input === "keyboard") await link.press("Enter")
if (input === "click") await link.click()
await expect(page).toHaveURL(sessionHref(childID))
await expectSessionTitle(page, taskDescription)
await expect(page.locator("[data-titlebar-tab-link]")).toHaveCount(1)
await page.keyboard.press("Escape")
await expect(page).toHaveURL(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
})
}
test("shows parent lineage while the child timeline loads", async ({ page }) => {
await setup(page)
const requested = Promise.withResolvers<void>()
@@ -126,7 +211,7 @@ test("keeps the parent tab selected while a loaded child session resolves", asyn
test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
const events: OpenCodeEvent[] = []
await setup(page, () => events.splice(0, 1))
await setup(page, { events: () => events.splice(0, 1) })
await openChildFromParent(page)
await expectSessionTitle(page, taskDescription)
@@ -144,7 +229,10 @@ test("shows the not found fallback when the viewed session is deleted", async ({
await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0)
})
async function setup(page: Page, events?: () => OpenCodeEvent[]) {
async function setup(
page: Page,
input: { events?: () => OpenCodeEvent[]; background?: boolean; completion?: boolean } = {},
) {
await mockOpenCodeServer(page, {
directory,
project: {
@@ -169,9 +257,11 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) {
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
sessions: [session(parentID, parentTitle, 1700000000000), childSession()],
pageMessages: (sessionID) => ({ items: sessionID === parentID ? parentMessages() : [] }),
events,
eventRetry: events ? 16 : undefined,
pageMessages: (sessionID) => ({
items: sessionID === parentID ? parentMessages(input.background, input.completion) : [],
}),
events: input.events,
eventRetry: input.events ? 16 : undefined,
})
// The child session resolves by ID but is absent from the session list,
// matching a subagent session that has not been loaded into the list cache yet.
@@ -220,7 +310,7 @@ function childSession() {
return session(childID, childTitle, 1700000001000, { parentID })
}
function parentMessages(): SessionMessageInfo[] {
function parentMessages(background = false, completion = false): SessionMessageInfo[] {
const userID = "msg_user_0001"
const assistantID = "msg_assistant_0001"
return [
@@ -249,11 +339,39 @@ function parentMessages(): SessionMessageInfo[] {
status: "completed",
input: { description: taskDescription, agent: "explore", prompt: "Inspect the delegated work." },
content: [{ type: "text", text: "Subagent finished" }],
metadata: { sessionID: childID },
metadata: { sessionID: childID, ...(background ? { status: "running" } : {}) },
},
},
...(background
? [
{
type: "tool",
id: "call_shell_background",
name: "shell",
time: { created: 1700000001000, completed: 1700000002000 },
state: {
status: "completed",
input: { command: "sleep 120" },
content: [{ type: "text", text: "Running in background" }],
metadata: { shellID: "shell_background", status: "running" },
},
} satisfies SessionMessageAssistantTool,
]
: []),
],
},
...(completion
? [
{
id: "msg_subagent_completion",
type: "synthetic",
text: "Subagent completed",
description: taskDescription,
metadata: { source: "subagent", childID, agent: "explore", state: "completed" },
time: { created: 1700000003000 },
} satisfies SessionMessageInfo,
]
: []),
]
}
@@ -1,3 +1,4 @@
import { DataProvider } from "@opencode/session-ui/context"
import { BackgroundMoveHint, BackgroundWorkSummary } from "./message-timeline"
const tasks = [
@@ -30,7 +31,13 @@ export const InlineMoveHint = {
export const SummaryPanelEntry = {
render: () => (
<div class="w-[280px] rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
<BackgroundWorkSummary tasks={tasks} />
<DataProvider
data={{ session: [], session_status: {}, session_diff: {} }}
directory="/project"
onSessionHref={(id) => `#${id}`}
>
<BackgroundWorkSummary tasks={tasks} />
</DataProvider>
</div>
),
}
@@ -1,7 +1,9 @@
import { createEffect, createMemo, createSignal, For, on, onCleanup, Show, type Accessor, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import type { SessionUserActions } from "@opencode/session-ui/actions"
import { useData } from "@opencode/session-ui/context"
import { Button } from "@opencode/ui/button"
import { DiffChanges } from "@opencode/ui/diff-changes"
import { Icon } from "@opencode/ui/icon"
@@ -16,7 +18,7 @@ import { getFilename } from "@opencode/util/path"
import { Popover } from "@kobalte/core/popover"
import { SessionContextUsage } from "@/session/timeline/session-context-usage"
import { useLanguage } from "@/runtime/i18n/language"
import { useData, useServer } from "@/runtime/server/current"
import { useServer } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { Timeline, TimelineRow } from "@opencode/session-ui/timeline/projection"
import { createSessionTimelineRowRenderer } from "@opencode/session-ui/timeline/row"
@@ -70,6 +72,7 @@ export function BackgroundMoveHint(props: { keybind?: string[]; onMove?: () => v
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?: boolean }) {
const language = useLanguage()
const data = useData()
const [open, setOpen] = createSignal(false)
const [triggerRef, setTriggerRef] = createSignal<HTMLButtonElement>()
const tasks = createMemo<BackgroundTask[]>((previous = []) => (props.tasks.length > 0 ? props.tasks : previous))
@@ -106,10 +109,7 @@ export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?:
}}
aria-label={language.plural("session.background.tasksRunning", tasks().length)}
>
<Icon
name="outline-arrow-to-corner-top-right"
class="shrink-0 text-v2-icon-icon-muted"
/>
<Icon name="outline-arrow-to-corner-top-right" class="shrink-0 text-v2-icon-icon-muted" />
<TextShimmer
as="span"
text={language.plural("session.background.tasksRunning", tasks().length)}
@@ -125,13 +125,26 @@ export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?:
>
<For each={tasks().slice(0, 10)}>
{(task) => (
<div
<Dynamic
component={task.type === "subagent" ? "a" : "div"}
data-component="session-background-list-item"
class="flex h-7 min-w-0 items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-none tracking-[-0.04px]"
class="flex h-7 min-w-0 items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-[var(--line-height-compact)] tracking-[-0.04px]"
classList={{
"hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none":
task.type === "subagent",
}}
href={task.type === "subagent" ? data.sessionHref?.(task.id) : undefined}
onClick={(event: MouseEvent) => {
if (task.type !== "subagent" || !data.navigateToSession) return
if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
event.preventDefault()
setOpen(false)
data.navigateToSession(task.id)
}}
>
<span class="shrink-0 text-v2-text-text-base">{taskType(task)}</span>
<span class="min-w-0 flex-1 truncate text-v2-text-text-faint">{task.label}</span>
</div>
</Dynamic>
)}
</For>
</Popover.Content>
@@ -389,8 +402,8 @@ function MessageTimelineView(
},
) {
const language = useLanguage()
const data = useData()
const server = useServer()
const data = server.ctx.data
const settings = useSettings()
const sdk = useWorkspaceLocation()
const sessionID = props.data.sessionID
-25
View File
@@ -1,25 +0,0 @@
# Vite TUI entrypoint
From the repository root:
```sh
bun run dev:vite:live /path/to/project
```
This uses the normal CLI and its real TUI through Vite + `solid-refresh`. For an explicit server or private backend, use `dev:vite` with `--server URL` or `--standalone` respectively. Plain `dev:vite` uses normal CLI service discovery; `dev:vite:live` explicitly connects to the installed server without replacing it.
- Component edits hot-update through the existing Solid refresh runtime.
- Full reloads await TUI cleanup and restore the current route: the selected session, Home workspace, or plugin page. They do not preserve composer drafts or other component-local state, and do not replay launch prompts, route prompts, `--continue`, or `--fork`.
- Correcting syntax errors retries a failed reload. The backend stays alive.
- Refreshable components get local error boundaries. A render failure during a hot update triggers one full UI reload. If the fresh render also fails, the error appears in the shared themed Dialog rather than causing a reload loop. Only the latest error is shown. Escape dismisses it; saving retries failed components. State within remounted components can still reset, especially when several components share an edited file.
- Launcher/config/dependency changes require restarting the development client.
`vite.ts` registers a Bun runtime module that supplies the Vite runner for the CLI's existing static `@opencode/tui` import. This registration runs only in the dev launcher; production handlers and their import graph are unchanged. `tui.ts` owns Vite and the TUI lifecycle. `entry.ts` loads the real application source through Vite. `host.js` keeps lifecycle ownership outside Vite's reloadable module cache. No production CLI handler, TUI component, or route changes are needed.
`refresh.ts` delegates component replacement to stock `solid-refresh`, wrapping each returned component proxy in Solid's standard ErrorBoundary. It preserves registered context identities during module evaluation: Vite's native runner can re-evaluate cyclic dependencies without invoking their HMR accept callbacks, which is too late for stock context patching. `refresh-runtime.d.ts` supplies types for the package's existing deep runtime export.
Vite redirects imports of the TUI route context through `route.tsx`, a dev-only wrapper around the real provider. It saves plain route snapshots in the external `host.js`, including nested Home location and plugin page data. Production route code is unchanged. Recovery is armed only for a hot update, consumed before requesting a full reload, and disarmed when the update settles or the full reload starts.
The entry initializes the error overlay after loading the app graph because the shared dialog and theme modules themselves use the refresh runtime.
Tested on Linux/Bun with full-app rendering, message/palette HMR, draft preservation, and native-terminal full reload/error recovery. External native-loaded plugins remain experimental across full reloads because their process-lifetime runtime mappings can retain an older Solid generation.
-17
View File
@@ -1,17 +0,0 @@
/// <reference types="vite/client" />
import { host } from "@opencode/cli/vite-host"
import { configureErrorOverlay } from "./refresh"
if (import.meta.hot) {
import.meta.hot.on("vite:afterUpdate", () => queueMicrotask(() => host.settle?.()))
import.meta.hot.on("vite:beforeFullReload", async () => {
await host.stop?.()
host.reset?.()
})
}
const { run } = await import("../../tui/src/index")
// Theme/dialog modules use refresh themselves; initialize their overlay after the app graph loads.
const { ErrorOverlay } = await import("./error-overlay")
configureErrorOverlay(ErrorOverlay)
await host.mount?.(run)
-56
View File
@@ -1,56 +0,0 @@
/* @refresh skip */
import { BoxRenderable, TextAttributes } from "@opentui/core"
import { Portal, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { onCleanup, onMount } from "solid-js"
import { useTheme } from "../../tui/src/context/theme"
import { Dialog } from "../../tui/src/ui/dialog"
import { Keymap } from "../../tui/src/context/keymap"
export function ErrorOverlay(props: { component: string; error: unknown; onClose: () => void }) {
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const focus = renderer.currentFocusedRenderable
onCleanup(Keymap.use().mode.push("modal"))
Keymap.createLayer(() => ({
mode: "modal",
priority: 1000,
commands: [{ bind: "escape", title: "Close hot reload error", group: "Development", run: props.onClose }],
}))
onMount(() => focus?.blur())
onCleanup(() => {
if (focus && !focus.isDestroyed) focus.focus()
})
return (
<Portal
ref={(container) => {
if (!(container instanceof BoxRenderable)) return
// Anchor Portal's wrapper above the app rather than after it in root layout.
container.position = "absolute"
container.left = 0
container.top = 0
container.zIndex = 5000
}}
>
<Dialog centered onClose={props.onClose}>
<box maxHeight={Math.max(1, dimensions().height - 3)} paddingX={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Error while hot reloading
</text>
<text fg={theme.text.subdued} onMouseUp={props.onClose}>
esc
</text>
</box>
<text maxHeight={Math.max(1, dimensions().height - 9)} fg={theme.text.default}>
{props.error instanceof Error ? props.error.message : String(props.error)}
</text>
<text flexShrink={0} fg={theme.text.subdued}>
{props.component} · Fix the component and save to retry.
</text>
</box>
</Dialog>
</Portal>
)
}
-16
View File
@@ -1,16 +0,0 @@
import type { Effect, Fiber, FileSystem } from "effect"
import type { TuiInput } from "@opencode/tui"
import type { Global } from "@opencode/util/global"
import type { Route } from "../../tui/src/context/route"
export type Run = (input: TuiInput) => Effect.Effect<void, unknown, Global.Service | FileSystem.FileSystem>
export declare const host: {
active?: Fiber.Fiber<void, unknown>
mount?: (app: Run) => Promise<void>
stop?: () => Promise<void>
reset?: () => void
recover?: () => boolean
settle?: () => void
route?: Route
}
-2
View File
@@ -1,2 +0,0 @@
// External to Vite's module cache: keep lifecycle and route state across reloads.
export const host = {}
-3
View File
@@ -1,3 +0,0 @@
declare module "solid-refresh/dist/solid-refresh.mjs" {
export * from "solid-refresh"
}
-66
View File
@@ -1,66 +0,0 @@
/// <reference types="vite/client" />
import { createComponent, createSignal, ErrorBoundary, onCleanup, Show, type JSX } from "solid-js"
import { $$component, $$refresh, type Registry } from "solid-refresh/dist/solid-refresh.mjs"
import type { ErrorOverlay } from "./error-overlay"
import { host } from "@opencode/cli/vite-host"
let overlay: typeof ErrorOverlay
const [activeError, setActiveError] = createSignal<symbol>()
export function configureErrorOverlay(component: typeof ErrorOverlay) {
overlay = component
}
export { $$context, $$decline, $$registry } from "solid-refresh/dist/solid-refresh.mjs"
export { refresh as $$refresh }
export { component as $$component }
function refresh(...args: Parameters<typeof $$refresh>) {
// The native runner can re-evaluate a dependency in a cycle without accepting
// an update for that module. Preserve context identity now, before an updated
// consumer renders, rather than waiting for solid-refresh's accept callback.
const previous = args[1].data?.["solid-refresh"]
args[2].contexts.forEach((entry, id) => {
const old = previous?.contexts.get(id)
if (!old) return
old.context.defaultValue = entry.context.defaultValue
entry.context.id = old.context.id
entry.context.Provider = old.context.Provider
})
$$refresh(...args)
}
function component<P extends Record<string, unknown>>(
registry: Registry,
id: string,
render: (props: P) => JSX.Element,
options?: Parameters<typeof $$component>[3],
) {
const proxy = $$component(registry, id, render, options)
return (props: P) =>
createComponent(ErrorBoundary, {
fallback(error: unknown, reset: () => void) {
if (host.recover?.()) return null
const token = Symbol(id)
// Several instances can fail in one update. Stack neither dialogs nor translucent backdrops.
setActiveError(token)
onCleanup(() => {
if (activeError() === token) setActiveError(undefined)
})
// Retry only this failed subtree. Resetting the app's boundary destroys its providers and route.
import.meta.hot?.on("vite:afterUpdate", reset)
onCleanup(() => import.meta.hot?.off("vite:afterUpdate", reset))
return createComponent(Show, {
keyed: true,
get when() {
return activeError() === token
},
get children() {
return createComponent(overlay, { component: id, error, onClose: () => setActiveError(undefined) })
},
})
},
get children() {
return createComponent(proxy, props)
},
})
}
-43
View File
@@ -1,43 +0,0 @@
import { createEffect, on, type ComponentProps } from "solid-js"
import { unwrap } from "solid-js/store"
import { host } from "@opencode/cli/vite-host"
import { RouteProvider, useRoute } from "../../tui/src/context/route"
export {
useRoute,
useRouteData,
type Route,
type HomeRoute,
type SessionRoute,
type PluginRoute,
} from "../../tui/src/context/route"
export { ReloadableRouteProvider as RouteProvider }
function ReloadableRouteProvider(props: ComponentProps<typeof RouteProvider>) {
return (
<RouteProvider {...props} initialRoute={host.route ?? props.initialRoute}>
<RememberRoute />
{props.children}
</RouteProvider>
)
}
function RememberRoute() {
const route = useRoute()
createEffect(
on(
() => JSON.stringify(route.data),
() => {
// A route's prompt is a one-shot handoff, not a composer draft.
const value = structuredClone(unwrap({ ...route.data }))
host.route =
value.type === "home"
? { type: "home", location: value.location }
: value.type === "session"
? { type: "session", sessionID: value.sessionID }
: value
},
),
)
return null
}
-188
View File
@@ -1,188 +0,0 @@
import { createRequire } from "node:module"
import path from "node:path"
import { Effect, Exit, Fiber } from "effect"
import { createRunnableDevEnvironment, createServer, isRunnableDevEnvironment } from "vite"
import solid from "vite-plugin-solid"
import refresh from "solid-refresh/babel"
import { host, type Run } from "./host.js"
const require = createRequire(import.meta.url)
export const run: Run = Effect.fn("Tui.vite")(function* (input: Parameters<Run>[0]) {
const fork = Effect.runForkWith(yield* Effect.context<Effect.Services<ReturnType<Run>>>())
const finished = Promise.withResolvers<Exit.Exit<void, unknown>>()
let initial = true
let recoverable = false
host.route = undefined
host.settle = () => {
recoverable = false
}
host.stop = async () => {
const fiber = host.active
host.active = undefined
if (fiber) await Effect.runPromise(Fiber.interrupt(fiber))
}
host.mount = async (app) => {
await host.stop?.()
const fiber = fork(
app({
...input,
args: initial
? input.args
: { ...input.args, prompt: undefined, sessionID: undefined, continue: false, fork: false },
terminalHandoff: initial ? input.terminalHandoff : undefined,
}),
)
initial = false
host.active = fiber
fiber.addObserver((exit) => {
if (host.active !== fiber) return
host.active = undefined
finished.resolve(exit)
})
}
const server = yield* Effect.acquireRelease(
Effect.tryPromise(() =>
createServer({
root: path.resolve(import.meta.dirname, "../../tui"),
configFile: false,
appType: "custom",
clearScreen: false,
logLevel: "error",
server: { middlewareMode: true, ws: false },
resolve: {
alias: [
{ find: /^solid-js(?:\/dist\/solid.js)?$/, replacement: require.resolve("solid-js/dist/dev.js") },
{
find: /^solid-js\/store(?:\/dist\/store.js)?$/,
replacement: require.resolve("solid-js/store/dist/dev.js"),
},
],
},
plugins: [
{
name: "tui-refresh-boundaries",
enforce: "pre",
async resolveId(source, importer) {
if (importer === path.join(import.meta.dirname, "route.tsx")) return
if (!source.endsWith("/route") && !source.endsWith("/route.tsx")) return
const resolved = await this.resolve(source, importer, { skipSelf: true })
if (resolved?.id === path.resolve(import.meta.dirname, "../../tui/src/context/route.tsx"))
return path.join(import.meta.dirname, "route.tsx")
},
load(id) {
if (id === "/@solid-refresh")
return `export * from ${JSON.stringify(path.join(import.meta.dirname, "refresh.ts"))}`
},
},
solid({
hot: false,
dev: true,
solid: { generate: "universal", moduleName: "@opentui/solid" },
// Enable the existing refresh plugin in Vite's non-browser environment.
babel: { plugins: [[refresh, { bundler: "vite" }]] },
}),
{
name: "tui-recovery",
hotUpdate() {
if (this.environment.name !== "native") return
recoverable = Boolean(host.active)
if (host.active) return
this.environment.moduleGraph.invalidateAll()
this.environment.hot.send({ type: "full-reload" })
return []
},
},
],
environments: {
native: {
consumer: "server",
resolve: {
conditions: ["bun", "development", "module"],
externalConditions: ["bun", "node"],
noExternal: [
"solid-js",
"solid-refresh",
"@opentui/solid",
"@opentui/keymap",
"opentui-spinner",
/^@solid-primitives\//,
"@opencode/plugin",
"@opencode/client",
"@opencode/latex",
"@opencode/merman",
],
// Exact subpaths are needed for workspace TypeScript exports.
external: [
"@opentui/core",
"@opentui/core/testing",
"effect",
"@opencode/cli/vite-host",
"@opencode/client",
"@opencode/client/effect/service",
"@opencode/client/promise",
"@opencode/core/util/slug",
"@opencode/schema",
"@opencode/schema/event",
"@opencode/schema/project",
"@opencode/schema/session-id",
"@opencode/schema/session-inbox",
"@opencode/schema/session-message",
"@opencode/schema/skill",
"@opencode/schema/token-usage",
"@opencode/schema/vcs",
"@opencode/schema/worktree",
"@opencode/simulation/frontend",
"@opencode/simulation/protocol",
"@opencode/theme/tui",
"@opencode/theme/tui/v1",
"@opencode/util/activity-calendar",
"@opencode/util/flock",
"@opencode/util/global",
"@opencode/util/hash",
"@opencode/util/session-title-fallback",
],
},
optimizeDeps: { noDiscovery: true, include: [] },
dev: {
createEnvironment: (name, config) =>
createRunnableDevEnvironment(name, config, {
runnerOptions: {
sourcemapInterceptor: false,
hmr: { logger: { debug() {}, error: (error) => console.error(error) } },
},
}),
},
},
},
}),
),
(server) =>
Effect.promise(async () => {
await host.stop?.()
await server.close()
}),
)
const environment = server.environments.native
if (!isRunnableDevEnvironment(environment)) return yield* Effect.die(new Error("Expected a runnable environment"))
host.reset = () => {
recoverable = false
environment.runner.clearCache()
}
host.recover = () => {
if (!recoverable) return false
recoverable = false
queueMicrotask(() => {
input.log?.("warn", "TUI hot update failed; reloading", {})
environment.moduleGraph.invalidateAll()
environment.hot.send({ type: "full-reload" })
})
return true
}
yield* Effect.promise(() =>
environment.runner.import(path.join(import.meta.dirname, "entry.ts")).catch(console.error),
)
const exit = yield* Effect.promise(() => finished.promise)
if (Exit.isFailure(exit)) return yield* Effect.failCause(exit.cause)
}, Effect.scoped)
-15
View File
@@ -1,15 +0,0 @@
import { plugin } from "bun"
import { ensureSolidTransformPlugin } from "@opentui/solid/bun-plugin"
ensureSolidTransformPlugin()
if (process.argv[2] !== "serve") {
// Vite must initialize before the CLI installs its process/error handling on Bun.
const { run } = await import("./tui")
plugin({
name: "vite-tui-entry",
setup(build) {
build.module("@opencode/tui", () => ({ loader: "object", exports: { run } }))
},
})
}
await import("../src/index")
-2
View File
@@ -11,7 +11,6 @@
"bin"
],
"exports": {
"./vite-host": "./dev/host.js",
"./run": "./src/run/index.ts",
"./server-process": "./src/server-process.ts"
},
@@ -75,7 +74,6 @@
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1",
"solid-refresh": "0.6.3",
"vite": "catalog:",
"vite-plugin-solid": "catalog:"
}
-45
View File
@@ -1,45 +0,0 @@
import { expect, test } from "bun:test"
import { createComponent, createContext, createRoot, useContext } from "solid-js"
import { $$context, $$registry } from "solid-refresh/dist/solid-refresh.mjs"
import { $$refresh } from "../dev/refresh"
test("re-evaluated dependencies retain their mounted context before any HMR accept callback", () => {
const previous = $$registry()
const mounted = $$context(previous, "Context", createContext("old default"))
const next = $$registry()
const updated = $$context(next, "Context", createContext("new default"))
const unrelated = $$context($$registry(), "Context", createContext("unrelated"))
let accepted = false
$$refresh(
"vite",
{
data: { "solid-refresh": previous, "solid-refresh-prev": previous },
accept() {
accepted = true
},
invalidate() {
throw new Error("Unexpected invalidation")
},
decline() {
throw new Error("Unexpected decline")
},
},
next,
)
// Only register acceptance: Vite re-evaluates cyclic dependencies without
// necessarily sending those modules their own accepted update.
expect(accepted).toBe(true)
createRoot((dispose) => {
createComponent(mounted.Provider, {
value: "mounted provider",
get children() {
expect(useContext(updated)).toBe("mounted provider")
expect(useContext(unrelated)).toBe("unrelated")
return undefined
},
})
expect(useContext(mounted)).toBe("new default")
dispose()
})
})
-65
View File
@@ -1,65 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { RouteProvider, useRoute, type Route } from "../dev/route"
import { host } from "../dev/host.js"
import { TuiStartupProvider } from "../../tui/src/context/runtime"
test("the dev route wrapper restores the current route without replaying its prompt", async () => {
const saved = () => host.route
let route!: ReturnType<typeof useRoute>
function Probe() {
route = useRoute()
return null
}
async function render() {
return testRender(
() => (
<TuiStartupProvider value={{ skipInitialLoading: true }}>
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_launch" }}>
<Probe />
</RouteProvider>
</TuiStartupProvider>
),
{ width: 80, height: 24 },
)
}
const routes: Route[] = [
{ type: "home", location: { directory: "/selected/worktree", workspaceID: "wrk_test" } },
{ type: "home", location: { directory: "/another/worktree", workspaceID: "wrk_other" } },
{ type: "session", sessionID: "ses_selected" },
{ type: "plugin", id: "test", name: "page", data: { nested: { selected: 1 } } },
{ type: "plugin", id: "test", name: "page", data: { nested: { selected: 2 } } },
]
host.route = undefined
const app = await render()
try {
await app.waitFor(() => host.route !== undefined)
for (const value of routes) {
route.navigate(
value.type === "plugin"
? value
: {
...value,
prompt: { text: "one-shot handoff", files: [], agents: [], pasted: [] },
},
)
await app.waitFor(() => JSON.stringify(host.route) === JSON.stringify(value))
expect(saved()).toEqual(value)
// Saved routes contain plain data, not a proxy tied to the old Solid tree.
expect(structuredClone(saved())).toEqual(value)
}
} finally {
app.renderer.destroy()
}
for (const value of routes) {
host.route = value
const restored = await render()
try {
expect(route.data).toEqual(value)
} finally {
restored.renderer.destroy()
}
}
host.route = undefined
})
+9 -10
View File
@@ -518,7 +518,6 @@ export type SessionLogOutput =
readonly sessionID: Session.ID
readonly parentID: Session.ID
readonly boundary: Session.ForkBoundary
readonly messages?: ReadonlyArray<SessionMessage.InfoEncoded> | undefined
readonly instructions?:
| { readonly [x: string & Brand.Brand<"Instruction.Key">]: string & Brand.Brand<"Instruction.Hash"> }
| undefined
@@ -715,7 +714,7 @@ export type SessionLogOutput =
readonly assistantMessageID: SessionMessage.ID
readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string | undefined
readonly providerState?: { readonly [x: string]: unknown } | undefined
readonly providerState?: SessionMessage.ProviderState | undefined
readonly cost: number & Brand.Brand<"Money.USD">
readonly tokens: {
readonly input: number
@@ -740,7 +739,7 @@ export type SessionLogOutput =
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly finish?: "content-filter" | undefined
readonly rawFinish?: string | undefined
readonly providerState?: { readonly [x: string]: unknown } | undefined
readonly providerState?: SessionMessage.ProviderState | undefined
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
readonly tokens?:
| {
@@ -779,7 +778,7 @@ export type SessionLogOutput =
readonly assistantMessageID: SessionMessage.ID
readonly ordinal: number
readonly text: string
readonly state?: { readonly [x: string]: unknown } | undefined
readonly state?: SessionMessage.ProviderState | undefined
}
}
| {
@@ -793,7 +792,7 @@ export type SessionLogOutput =
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly ordinal: number
readonly state?: { readonly [x: string]: unknown } | undefined
readonly state?: SessionMessage.ProviderState | undefined
}
}
| {
@@ -808,7 +807,7 @@ export type SessionLogOutput =
readonly assistantMessageID: SessionMessage.ID
readonly ordinal: number
readonly text: string
readonly state?: { readonly [x: string]: unknown } | undefined
readonly state?: SessionMessage.ProviderState | undefined
}
}
| {
@@ -852,7 +851,7 @@ export type SessionLogOutput =
readonly id: string
readonly input: { readonly [x: string]: unknown }
readonly executed: boolean
readonly state?: { readonly [x: string]: unknown } | undefined
readonly state?: SessionMessage.ProviderState | undefined
}
}
| {
@@ -888,7 +887,7 @@ export type SessionLogOutput =
]
readonly metadata?: { readonly [x: string]: Schema.Json } | undefined
readonly executed: boolean
readonly resultState?: { readonly [x: string]: unknown } | undefined
readonly resultState?: SessionMessage.ProviderState | undefined
}
}
| {
@@ -927,7 +926,7 @@ export type SessionLogOutput =
| undefined
readonly metadata?: { readonly [x: string]: Schema.Json } | undefined
readonly executed: boolean
readonly resultState?: { readonly [x: string]: unknown } | undefined
readonly resultState?: SessionMessage.ProviderState | undefined
}
}
| {
@@ -970,7 +969,7 @@ export type SessionLogOutput =
readonly sessionID: Session.ID
readonly reason: "auto" | "manual"
readonly model?: Model.Ref | undefined
readonly providerState?: { readonly [x: string]: unknown } | undefined
readonly providerState?: SessionMessage.ProviderState | undefined
readonly providerContext?:
| {
readonly version: 1
+86 -237
View File
@@ -159,76 +159,6 @@ export type InstructionEntryKey = string
export type SessionGenerateResponse = { data: { text: string } }
export type SessionMessageAgentSelected1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
type: "agent-switched"
agent: string
previous?: string
}
export type SessionMessageSynthetic1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
text: string
description?: string
type: "synthetic"
}
export type SessionMessageSystem1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
type: "system"
text: string
description?: string
}
export type SessionMessageSkill1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
type: "skill"
skill: string
name: string
text: string
}
export type SessionMessageShell1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number; completed?: number }
type: "shell"
shellID: string
command: string
status: "running" | "exited" | "timeout" | "killed"
exit?: number
output?: { output: string; cursor: number; size: number; truncated: boolean }
}
export type SessionMessageProviderState1 = { [x: string]: any }
export type SessionMessageToolStateRunning1 = {
status: "running"
input: { [x: string]: any }
metadata: { [x: string]: JsonValue }
}
export type ToolFileContent1 = { type: "file"; uri: string; mime: string; name?: string | undefined }
export type SessionMessageCompactionRunning1 = {
type: "compaction"
id: string
metadata?: { [x: string]: any }
time: { created: number }
status: "running"
reason: "auto" | "manual"
summary: string
recent: string
}
export type SessionInboxSyntheticPayload1 = { text: string; description?: string; metadata?: { [x: string]: any } }
export type ShellInfo = {
@@ -244,6 +174,16 @@ export type ShellInfo = {
time: { started: number; completed?: number }
}
export type SessionMessageProviderState1 = { [x: string]: any }
export type ToolFileContent1 = { type: "file"; uri: string; mime: string; name?: string | undefined }
export type SessionMessageToolStateRunning1 = {
status: "running"
input: { [x: string]: any }
metadata: { [x: string]: JsonValue }
}
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
export type SessionInterruptResponse = { interrupted: boolean }
@@ -514,17 +454,6 @@ export type SessionMessageLocationSwitched = {
export type SessionInboxMovePayload = { location: LocationRef; projectID: string; subpath?: string }
export type SessionMessageLocationSwitched1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
type: "location-switched"
location: LocationRef
projectID?: string
subpath?: string
previous?: { location: LocationRef; projectID?: string; subpath?: string }
}
export type V2EventRpc = {
id: string
created: number
@@ -560,15 +489,6 @@ export type SessionMessageModelSelected = {
previous?: ModelRef
}
export type SessionMessageModelSelected1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
type: "model-switched"
model: ModelRef
previous?: ModelRef
}
export type PromptFileAttachment = {
data: PromptBase64
mime: string
@@ -605,16 +525,6 @@ export type SessionMessageCompactionFailed = {
error: SessionStructuredError
}
export type SessionMessageCompactionFailed1 = {
type: "compaction"
id: string
metadata?: { [x: string]: any }
time: { created: number }
status: "failed"
reason: "auto" | "manual"
error: SessionStructuredError
}
export type SessionProviderContext = { version: 1; provenance: SessionProviderContextProvenance; messages: JsonValue }
export type SessionInboxSynthetic = {
@@ -1290,13 +1200,37 @@ export type McpResourcesChanged = {
data: { server: string }
}
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
export type SessionShellStarted = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.shell.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; shell: ShellInfo }
}
export type SessionMessageAssistantReasoning1 = {
type: "reasoning"
text: string
state?: SessionMessageProviderState1
time?: { created: number; completed?: number }
export type SessionShellEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.shell.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
shell: ShellInfo
output: { output: string; cursor: number; size: number; truncated: boolean }
}
}
export type ShellCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "shell.created"
location?: LocationRef
data: { info: ShellInfo }
}
export type SessionStepEnded = {
@@ -1399,41 +1333,17 @@ export type SessionToolCalled = {
}
}
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
export type SessionMessageAssistantReasoning1 = {
type: "reasoning"
text: string
state?: SessionMessageProviderState1
time?: { created: number; completed?: number }
}
export type ToolContent1 = ToolTextContent | ToolFileContent1
export type SessionShellStarted = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.shell.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; shell: ShellInfo }
}
export type SessionShellEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.shell.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
shell: ShellInfo
output: { output: string; cursor: number; size: number; truncated: boolean }
}
}
export type ShellCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "shell.created"
location?: LocationRef
data: { info: ShellInfo }
}
export type ModelCompatibility = {
reasoningField?: ModelReasoningField
requireReasoning?: boolean
@@ -1793,17 +1703,6 @@ export type SessionInboxUserPayload = {
metadata?: { [x: string]: JsonValue }
}
export type SessionMessageUser1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number }
text: string
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
skills?: Array<PromptSkillAttachment>
type: "user"
}
export type SessionInboxUserPayload1 = {
text: string
files?: Array<PromptFileAttachment>
@@ -1841,20 +1740,6 @@ export type SessionMessageCompactionCompleted = {
providerContext?: SessionProviderContext
}
export type SessionMessageCompactionCompleted1 = {
type: "compaction"
id: string
metadata?: { [x: string]: any }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState1
summary: string
recent: string
providerContext?: SessionProviderContext
}
export type SessionCompactionEnded = {
id: string
created: number
@@ -1873,19 +1758,20 @@ export type SessionCompactionEnded = {
}
}
export type SessionMessageToolStateCompleted1 = {
status: "completed"
input: { [x: string]: any }
content: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
}
export type SessionMessageToolStateError1 = {
status: "error"
input: { [x: string]: any }
error: SessionStructuredError
content?: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
export type SessionForked = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.forked"
durable: { aggregateID: string; seq: number; version: 2 }
location?: LocationRef
data: {
sessionID: string
parentID: string
boundary: SessionForkBoundary
instructions?: { [x: string]: string }
instructionEntries?: InstructionEntrySnapshot
}
}
export type SessionToolSuccess = {
@@ -1925,6 +1811,21 @@ export type SessionToolFailed = {
}
}
export type SessionMessageToolStateCompleted1 = {
status: "completed"
input: { [x: string]: any }
content: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
}
export type SessionMessageToolStateError1 = {
status: "error"
input: { [x: string]: any }
error: SessionStructuredError
content?: [ToolContent1, ...Array<ToolContent1>]
metadata?: { [x: string]: JsonValue }
}
export type ModelInfo = {
id: string
modelID: string
@@ -2203,11 +2104,6 @@ export type SessionMessageCompaction =
| SessionMessageCompactionCompleted
| SessionMessageCompactionFailed
export type SessionMessageCompaction1 =
| SessionMessageCompactionRunning1
| SessionMessageCompactionCompleted1
| SessionMessageCompactionFailed1
export type SessionMessageAssistantTool1 = {
type: "tool"
id: string
@@ -2257,24 +2153,6 @@ export type SessionMessageAssistant = {
retry?: SessionMessageAssistantRetry
}
export type SessionMessageAssistant1 = {
id: string
metadata?: { [x: string]: any }
time: { created: number; streamed?: number; completed?: number }
type: "assistant"
agent: string
model: ModelRef
content: Array<SessionMessageAssistantText1 | SessionMessageAssistantReasoning1 | SessionMessageAssistantTool1>
snapshot?: { start?: string; end?: string; files?: Array<string> }
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
rawFinish?: string
providerState?: SessionMessageProviderState1
cost?: MoneyUSD
tokens?: TokenUsageInfo
error?: SessionStructuredError
retry?: SessionMessageAssistantRetry
}
export type SessionMessageAssistantContentEncoded =
| SessionMessageAssistantText1
| SessionMessageAssistantReasoning1
@@ -2300,18 +2178,6 @@ export type SessionMessageInfo =
| SessionMessageAssistant
| SessionMessageCompaction
export type SessionMessageInfoEncoded =
| SessionMessageAgentSelected1
| SessionMessageModelSelected1
| SessionMessageLocationSwitched1
| SessionMessageUser1
| SessionMessageSynthetic1
| SessionMessageSystem1
| SessionMessageSkill1
| SessionMessageShell1
| SessionMessageAssistant1
| SessionMessageCompaction1
export type SessionMessageContentUpdated = {
id: string
created: number
@@ -2344,31 +2210,6 @@ export type SessionMessagesResponse = {
cursor: { previous?: string | null; next?: string | null }
}
export type SessionForked = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.forked"
durable: { aggregateID: string; seq: number; version: 2 }
location?: LocationRef
data: {
sessionID: string
parentID: string
boundary: SessionForkBoundary
messages?: Array<SessionMessageInfoEncoded>
instructions?: { [x: string]: string }
instructionEntries?: InstructionEntrySnapshot
}
}
export type IntegrationInfo = {
id: string
name: string
metadata?: { [x: string]: any }
methods: Array<IntegrationMethod>
connections: Array<ConnectionInfo>
}
export type SessionEventDurable =
| SessionCreated
| SessionAgentSelected
@@ -2414,6 +2255,14 @@ export type SessionEventDurable =
| SessionMessageContentUpdated
| SessionUsageRecorded
export type IntegrationInfo = {
id: string
name: string
metadata?: { [x: string]: any }
methods: Array<IntegrationMethod>
connections: Array<ConnectionInfo>
}
export type V2Event =
| ModelsDevRefreshed
| CredentialUpdated
-6
View File
@@ -91,12 +91,6 @@ runtime.execute(source) // Effect<CodeMode.Result, never, ToolServices>
The Effect environment is inferred from the supplied tools. `onToolCallStart` observes admitted calls with decoded
input; `onToolCallEnd` observes settled outcomes and duration. Both hooks return Effects and must not fail.
### `Values`
`Values` exports the runtime's non-JSON value classes: `Values.URL`, `Values.URLSearchParams`, `Values.Date`,
`Values.RegExp`, `Values.Map`, `Values.Set`, and `Values.Promise`. The interpreter recognizes these by class; a
program's `new URL(...)` is a `Values.URL` wrapping the host `URL`. `Values.isValue` narrows to the data-like kinds.
### OpenAPI tools
`OpenAPI.fromSpec` converts an OpenAPI 3.x document into one tool per supported operation. Dotted `operationId` values
-1
View File
@@ -2,6 +2,5 @@ export * as CodeMode from "./codemode.js"
export * as Namespace from "./namespace.js"
export * as Tool from "./tool.js"
export * as OpenAPI from "./openapi/index.js"
export { Values } from "./values.js"
export { searchSignature, toolExpression } from "./codemode.js"
export { ToolError, toolError } from "./tool-error.js"
+37 -28
View File
@@ -16,7 +16,16 @@ import {
} from "./model.js"
import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, typeofValue } from "./references.js"
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
import { Values } from "../values.js"
import {
CodeModeDate,
CodeModeMap,
CodeModePromise,
CodeModeRegExp,
CodeModeSet,
CodeModeURL,
CodeModeURLSearchParams,
isCodeModeValue,
} from "../values.js"
import { dateSetterArgumentCount, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
import { invokeMathMethod } from "../stdlib/math.js"
import { invokeNumberMethod, invokeNumberStatic } from "../stdlib/number.js"
@@ -34,7 +43,7 @@ export type CallbackRunner<R> = {
args: Array<unknown>,
node: AstNode,
) => Effect.Effect<unknown, unknown, R>
readonly settlePromise: (promise: Values.Promise) => Effect.Effect<unknown, unknown, never>
readonly settlePromise: (promise: CodeModePromise) => Effect.Effect<unknown, unknown, never>
}
// The single acceptance list for callbacks: collections, sort, string replacers,
@@ -91,7 +100,7 @@ export const invokeIntrinsic = <R>(
if (Array.isArray(ref.receiver)) {
return invokeArrayMethod(runner, ref.receiver, ref.name, args, node)
}
if (ref.receiver instanceof Values.Date) {
if (ref.receiver instanceof CodeModeDate) {
const target = ref.receiver
const argumentCount = dateSetterArgumentCount(ref.name)
if (argumentCount === undefined) return Effect.succeed(invokeDateMethod(target, ref.name, [], node))
@@ -104,19 +113,19 @@ export const invokeIntrinsic = <R>(
(values) => invokeDateMethod(target, ref.name, values, node, initialTime),
)
}
if (ref.receiver instanceof Values.RegExp) {
if (ref.receiver instanceof CodeModeRegExp) {
return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node))
}
if (ref.receiver instanceof Values.Map) {
if (ref.receiver instanceof CodeModeMap) {
return invokeMapMethod(runner, ref.receiver, ref.name, args, node)
}
if (ref.receiver instanceof Values.Set) {
if (ref.receiver instanceof CodeModeSet) {
return invokeSetMethod(runner, ref.receiver, ref.name, args, node)
}
if (ref.receiver instanceof Values.URL) {
if (ref.receiver instanceof CodeModeURL) {
return Effect.succeed(invokeURLMethod(ref.receiver, ref.name, node))
}
if (ref.receiver instanceof Values.URLSearchParams) {
if (ref.receiver instanceof CodeModeURLSearchParams) {
return invokeURLSearchParamsMethod(runner, ref.receiver, ref.name, args, node)
}
throw new InterpreterRuntimeError(`Method '${ref.name}' is not available.`, node)
@@ -127,7 +136,7 @@ const coerceNumericArgument = <R>(
value: unknown,
node: AstNode,
): Effect.Effect<number, unknown, R> => {
if (value === null || typeof value !== "object" || Array.isArray(value) || Values.isValue(value)) {
if (value === null || typeof value !== "object" || Array.isArray(value) || isCodeModeValue(value)) {
return Effect.succeed(coerceToNumber(value))
}
const object = value as Record<string, unknown>
@@ -183,7 +192,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index))
const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index))
const rejectRegex = (): void => {
if (args[0] instanceof Values.RegExp) {
if (args[0] instanceof CodeModeRegExp) {
throw new InterpreterRuntimeError(
`String.${name} cannot take a regular expression; use regex.test(string) or String.search instead.`,
node,
@@ -232,7 +241,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
result = requestedLimit !== undefined && requestedLimit >>> 0 === 0 ? [] : [value]
break
}
if (args[0] instanceof Values.RegExp) {
if (args[0] instanceof CodeModeRegExp) {
result = value.split(args[0].regex, optNum(1))
break
}
@@ -263,7 +272,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
break
case "replace":
case "replaceAll": {
if (args[0] instanceof Values.RegExp) {
if (args[0] instanceof CodeModeRegExp) {
const pattern = args[0].regex
const replacement = str(1)
if (name === "replaceAll" && !pattern.global) {
@@ -359,7 +368,7 @@ const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): u
}
const arrayLikeSource = (source: unknown, node: AstNode): { readonly length: number; readonly source: object } => {
if (source instanceof Values.Promise) {
if (source instanceof CodeModePromise) {
throw new InterpreterRuntimeError(
"Array.from received an un-awaited Promise; await it before creating the array.",
node,
@@ -436,7 +445,7 @@ export const invokeGroupBy = <R>(
throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError")
}
if (namespace === "Map") {
const result = new Values.Map()
const result = new CodeModeMap()
let index = 0
while (true) {
const step = yield* cursor.next
@@ -479,10 +488,10 @@ const coerceGroupByPropertyKey = <R>(
value: unknown,
node: AstNode,
): Effect.Effect<string, unknown, R> => {
if (value === null || typeof value !== "object" || Array.isArray(value) || Values.isValue(value)) {
if (value === null || typeof value !== "object" || Array.isArray(value) || isCodeModeValue(value)) {
return Effect.succeed(coerceToString(value))
}
if (value instanceof Values.Promise) return Effect.succeed("[object Promise]")
if (value instanceof CodeModePromise) return Effect.succeed("[object Promise]")
if (isRuntimeReference(value)) {
throw new InterpreterRuntimeError("Object.groupBy callback must return a data value.", node, "InvalidDataValue")
}
@@ -534,7 +543,7 @@ const invokeStringReplacer = <R>(
}
const pattern = args[0]
if (pattern instanceof Values.RegExp) {
if (pattern instanceof CodeModeRegExp) {
if (name === "replaceAll" && !pattern.regex.global) {
throw new InterpreterRuntimeError(
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.regex.source}/${pattern.regex.flags}g, or use String.replace to replace only the first match.`,
@@ -557,7 +566,7 @@ const invokeStringReplacer = <R>(
// Error values are branded plain objects; boundedData would strip the brand before coercion.
output.push(
value.slice(end, match.offset),
replacement instanceof Values.Promise
replacement instanceof CodeModePromise
? "[object Promise]"
: errorBrandName(replacement)
? coerceToString(replacement)
@@ -590,7 +599,7 @@ export const applyCollectionCallback = <R>(
const invokeMapMethod = <R>(
runner: CallbackRunner<R>,
target: Values.Map,
target: CodeModeMap,
name: string,
args: Array<unknown>,
node: AstNode,
@@ -632,7 +641,7 @@ const invokeMapMethod = <R>(
const invokeSetMethod = <R>(
runner: CallbackRunner<R>,
target: Values.Set,
target: CodeModeSet,
name: string,
args: Array<unknown>,
node: AstNode,
@@ -679,7 +688,7 @@ const invokeSetMethod = <R>(
const invokeSetOperation = <R>(
runner: CallbackRunner<R>,
target: Values.Set,
target: CodeModeSet,
name: string,
source: unknown,
node: AstNode,
@@ -692,7 +701,7 @@ const invokeSetOperation = <R>(
return result
}
if (name === "intersection") {
const result = new Values.Set()
const result = new CodeModeSet()
if (target.set.size <= other.size) {
for (const item of target.set.values()) {
if (yield* other.has(item)) result.set.add(item)
@@ -749,28 +758,28 @@ const invokeSetOperation = <R>(
return true
})
const copySet = (source: Values.Set): Values.Set => {
const result = new Values.Set()
const copySet = (source: CodeModeSet): CodeModeSet => {
const result = new CodeModeSet()
for (const item of source.set.values()) result.set.add(item)
return result
}
const loadSetRecord = <R>(runner: CallbackRunner<R>, source: unknown, name: string, node: AstNode) => {
if (source instanceof Values.Set) {
if (source instanceof CodeModeSet) {
return Effect.succeed({
size: source.set.size,
has: (item: unknown) => Effect.succeed(source.set.has(item)),
keys: () => Effect.succeed(source.set.values()),
})
}
if (source instanceof Values.Map) {
if (source instanceof CodeModeMap) {
return Effect.succeed({
size: source.map.size,
has: (item: unknown) => Effect.succeed(source.map.has(item)),
keys: () => Effect.succeed(source.map.keys()),
})
}
if (source === null || typeof source !== "object" || Values.isValue(source)) {
if (source === null || typeof source !== "object" || isCodeModeValue(source)) {
throw new InterpreterRuntimeError(`Set.${name} expects a Set-like object.`, node).as("TypeError")
}
const object = source as Record<string, unknown>
@@ -800,7 +809,7 @@ const loadSetRecord = <R>(runner: CallbackRunner<R>, source: unknown, name: stri
const invokeURLSearchParamsMethod = <R>(
runner: CallbackRunner<R>,
target: Values.URLSearchParams,
target: CodeModeURLSearchParams,
name: string,
args: Array<unknown>,
node: AstNode,
+3 -3
View File
@@ -1,6 +1,6 @@
import type { Effect } from "effect"
import type { SafeObject } from "../tool-runtime.js"
import type { Values } from "../values.js"
import type { CodeModePromise, CodeModeRegExp, CodeModeURL } from "../values.js"
export type SourcePosition = {
line: number
@@ -36,7 +36,7 @@ export type StatementResult =
| { kind: "continue"; label?: string }
export type MemberReference = {
target: SafeObject | Array<unknown> | Values.RegExp | Values.URL
target: SafeObject | Array<unknown> | CodeModeRegExp | CodeModeURL
key: PropertyKey
}
@@ -99,7 +99,7 @@ export type PromiseInstanceMethodName = "then" | "catch" | "finally"
export class PromiseInstanceMethodReference {
constructor(
readonly promise: Values.Promise,
readonly promise: CodeModePromise,
readonly name: PromiseInstanceMethodName,
) {}
}
+23 -23
View File
@@ -14,25 +14,25 @@ import { caughtErrorValue, normalizeError } from "./errors.js"
import { applyCollectionCallback, isSupportedCallback, type CallbackRunner, type SupportedCallback } from "./methods.js"
import { typeofValue } from "./references.js"
import { createAggregateErrorValue } from "../stdlib/value.js"
import { Values } from "../values.js"
import { CodeModePromise } from "../values.js"
import type { SyncIteratorRunner } from "./iterator.js"
// Observation only controls rejection reporting; program completion interrupts all promise work.
export class PromiseRuntime<R> {
private readonly active = new Set<Values.Promise>()
private readonly ids = new WeakMap<Values.Promise, number>()
private readonly observed = new WeakSet<Values.Promise>()
private readonly active = new Set<CodeModePromise>()
private readonly ids = new WeakMap<CodeModePromise, number>()
private readonly observed = new WeakSet<CodeModePromise>()
private readonly failures = new Map<number, Diagnostic>()
private nextID = 0
constructor(private readonly scope: Scope.Scope) {}
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<Values.Promise, never, R> {
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<CodeModePromise, never, R> {
return Effect.suspend(() => {
// Allocate before forking so reruns get distinct IDs and diagnostics retain creation order.
const id = this.nextID++
return Effect.map(Effect.forkIn(effect, this.scope, { startImmediately: true }), (fiber) => {
const promise = new Values.Promise(fiber)
const promise = new CodeModePromise(fiber)
this.active.add(promise)
this.ids.set(promise, id)
fiber.addObserver((exit) => {
@@ -53,14 +53,14 @@ export class PromiseRuntime<R> {
}
// Observation must be recorded when responsibility transfers, before the consumer fiber runs.
markObserved(promise: Values.Promise): void {
markObserved(promise: CodeModePromise): void {
this.observed.add(promise)
const id = this.ids.get(promise)
this.ids.delete(promise)
if (id !== undefined) this.failures.delete(id)
}
await(promise: Values.Promise): Effect.Effect<Exit.Exit<unknown, unknown>> {
await(promise: CodeModePromise): Effect.Effect<Exit.Exit<unknown, unknown>> {
return Fiber.await(promise.fiber)
}
@@ -91,10 +91,10 @@ export const resolvePromiseValue = <R>(
runner: CallbackRunner<R>,
value: unknown,
node: AstNode,
own?: { promise?: Values.Promise },
own?: { promise?: CodeModePromise },
): Effect.Effect<unknown, unknown, R> => {
if (own?.promise !== undefined && value === own.promise) return Effect.fail(selfResolutionError(node))
if (value instanceof Values.Promise) return runner.settlePromise(value)
if (value instanceof CodeModePromise) return runner.settlePromise(value)
if (value === null || typeof value !== "object" || !Object.hasOwn(value, "then")) return Effect.succeed(value)
const then = (value as SafeObject).then
if (typeofValue(then) !== "function") return Effect.succeed(value)
@@ -123,9 +123,9 @@ export const resolvePromise = <R>(
promises: PromiseRuntime<R>,
value: unknown,
node: AstNode,
): Effect.Effect<Values.Promise, never, R> => {
if (value instanceof Values.Promise) return Effect.succeed(value)
const box: { promise?: Values.Promise } = {}
): Effect.Effect<CodeModePromise, never, R> => {
if (value instanceof CodeModePromise) return Effect.succeed(value)
const box: { promise?: CodeModePromise } = {}
return Effect.map(promises.create(resolvePromiseValue(runner, value, node, box)), (promise) => {
box.promise = promise
return promise
@@ -155,7 +155,7 @@ export const invokePromiseMethod = <R>(
node,
).as("TypeError")
}
const items: Array<Values.Promise> = []
const items: Array<CodeModePromise> = []
while (true) {
const step = yield* cursor.next
if (step.done) break
@@ -227,7 +227,7 @@ export const invokePromiseInstanceMethod = <R>(
ref: PromiseInstanceMethodReference,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<Values.Promise, never, R> => {
): Effect.Effect<CodeModePromise, never, R> => {
const method = `Promise.prototype.${ref.name}`
promises.markObserved(ref.promise)
if (ref.name === "finally") {
@@ -243,7 +243,7 @@ export const constructPromise = <R>(
promises: PromiseRuntime<R>,
executor: unknown,
node: AstNode,
): Effect.Effect<Values.Promise, unknown, R> => {
): Effect.Effect<CodeModePromise, unknown, R> => {
if (!(executor instanceof CodeModeFunction)) {
throw new InterpreterRuntimeError(
"new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).",
@@ -252,7 +252,7 @@ export const constructPromise = <R>(
}
return Effect.gen(function* () {
const deferred = Deferred.makeUnsafe<unknown, unknown>()
const box: { promise?: Values.Promise } = {}
const box: { promise?: CodeModePromise } = {}
const promise = yield* promises.create(
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, node, box)),
)
@@ -294,7 +294,7 @@ const reactionHandler = (value: unknown, method: string, node: AstNode): Support
// Teardown bypasses handlers; settled reactions yield once so handlers never run inline.
const reactionExit = <R>(
promises: PromiseRuntime<R>,
source: Values.Promise,
source: CodeModePromise,
): Effect.Effect<Exit.Exit<unknown, unknown>, unknown, R> =>
Effect.gen(function* () {
const exit = yield* promises.await(source)
@@ -306,13 +306,13 @@ const reactionExit = <R>(
const chainReaction = <R>(
runner: CallbackRunner<R>,
promises: PromiseRuntime<R>,
source: Values.Promise,
source: CodeModePromise,
onFulfilled: SupportedCallback | undefined,
onRejected: SupportedCallback | undefined,
method: string,
node: AstNode,
): Effect.Effect<Values.Promise, never, R> => {
const box: { promise?: Values.Promise } = {}
): Effect.Effect<CodeModePromise, never, R> => {
const box: { promise?: CodeModePromise } = {}
const body = Effect.gen(function* () {
const exit = yield* reactionExit(promises, source)
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
@@ -330,11 +330,11 @@ const chainReaction = <R>(
const chainFinally = <R>(
runner: CallbackRunner<R>,
promises: PromiseRuntime<R>,
source: Values.Promise,
source: CodeModePromise,
cleanup: SupportedCallback | undefined,
method: string,
node: AstNode,
): Effect.Effect<Values.Promise, never, R> =>
): Effect.Effect<CodeModePromise, never, R> =>
promises.create(
Effect.gen(function* () {
const exit = yield* reactionExit(promises, source)
@@ -21,7 +21,7 @@ import {
UriFunction,
} from "./model.js"
import { ToolReference } from "../tool-runtime.js"
import { Values } from "../values.js"
import { isCodeModeValue, CodeModePromise } from "../values.js"
export const isRuntimeReference = (value: unknown): boolean =>
value instanceof CodeModeFunction ||
@@ -35,14 +35,14 @@ export const isRuntimeReference = (value: unknown): boolean =>
value instanceof PromiseNamespace ||
value instanceof PromiseMethodReference ||
value instanceof PromiseInstanceMethodReference ||
value instanceof Values.Promise ||
value instanceof CodeModePromise ||
value instanceof CoercionFunction ||
value instanceof UriFunction ||
value instanceof SearchFunction ||
value instanceof PromiseCapabilityFunction ||
value instanceof ErrorConstructorReference ||
value instanceof SymbolNamespace ||
Values.isValue(value)
isCodeModeValue(value)
function* childValues(value: object): Generator {
for (const key of Reflect.ownKeys(value)) {
@@ -81,7 +81,7 @@ export const containsOpaqueReference = (value: unknown): boolean => {
continue
}
const current = next.value
if (Values.isValue(current)) continue
if (isCodeModeValue(current)) continue
if (isRuntimeReference(current)) return true
if (current === null || typeof current !== "object" || seen.has(current)) continue
seen.add(current)
+70 -61
View File
@@ -98,7 +98,16 @@ import {
invokeCoercion,
valueConstructors,
} from "../stdlib/value.js"
import { Values } from "../values.js"
import {
isCodeModeValue,
CodeModeDate,
CodeModeMap,
CodeModePromise,
CodeModeRegExp,
CodeModeSet,
CodeModeURL,
CodeModeURLSearchParams,
} from "../values.js"
const globalStaticMembers: Partial<Record<GlobalNamespaceName, Set<string>>> = {
Object: objectStatics,
@@ -144,24 +153,24 @@ const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean =>
if (rhs instanceof GlobalNamespace) {
switch (rhs.name) {
case "Date":
return lhs instanceof Values.Date
return lhs instanceof CodeModeDate
case "RegExp":
return lhs instanceof Values.RegExp
return lhs instanceof CodeModeRegExp
case "Map":
return lhs instanceof Values.Map
return lhs instanceof CodeModeMap
case "Set":
return lhs instanceof Values.Set
return lhs instanceof CodeModeSet
case "URL":
return lhs instanceof Values.URL
return lhs instanceof CodeModeURL
case "URLSearchParams":
return lhs instanceof Values.URLSearchParams
return lhs instanceof CodeModeURLSearchParams
case "Array":
return Array.isArray(lhs)
case "Object":
return lhs !== null && (typeof lhs === "object" || typeofValue(lhs) === "function")
}
}
if (rhs instanceof PromiseNamespace) return lhs instanceof Values.Promise
if (rhs instanceof PromiseNamespace) return lhs instanceof CodeModePromise
if (rhs instanceof CoercionFunction && (rhs.name === "Number" || rhs.name === "String" || rhs.name === "Boolean")) {
return false
}
@@ -362,16 +371,16 @@ export class Interpreter<R> {
private createToolCallPromise(
path: ReadonlyArray<string>,
args: Array<unknown>,
): Effect.Effect<Values.Promise, never, R> {
): Effect.Effect<CodeModePromise, never, R> {
return this.createPromise(Effect.suspend(() => this.executeTool(path, args)))
}
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<Values.Promise, never, R> {
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<CodeModePromise, never, R> {
return this.promises.create(effect)
}
// Fiber exits make settlement idempotent; yielding prevents inline continuation.
private settlePromise(promise: Values.Promise): Effect.Effect<unknown, unknown, never> {
private settlePromise(promise: CodeModePromise): Effect.Effect<unknown, unknown, never> {
const promises = this.promises
return Effect.suspend(() => {
promises.markObserved(promise)
@@ -803,11 +812,11 @@ export class Interpreter<R> {
? value[Symbol.iterator]()
: typeof value === "string"
? value[Symbol.iterator]()
: value instanceof Values.Map
: value instanceof CodeModeMap
? value.map.entries()
: value instanceof Values.Set
: value instanceof CodeModeSet
? value.set.values()
: value instanceof Values.URLSearchParams
: value instanceof CodeModeURLSearchParams
? value.params.entries()
: undefined
if (iterator !== undefined) {
@@ -1469,19 +1478,19 @@ export class Interpreter<R> {
)
}
private constructDate(args: Array<unknown>, node: AstNode): Effect.Effect<Values.Date, unknown, R> {
if (args.length === 0) return Effect.succeed(new Values.Date(Date.now()))
private constructDate(args: Array<unknown>, node: AstNode): Effect.Effect<CodeModeDate, unknown, R> {
if (args.length === 0) return Effect.succeed(new CodeModeDate(Date.now()))
if (args.length === 1) {
const arg = args[0]
if (arg instanceof Values.Date) return Effect.succeed(new Values.Date(arg.time))
if (arg instanceof CodeModeDate) return Effect.succeed(new CodeModeDate(arg.time))
return Effect.map(this.toDatePrimitive(arg, node), (value) =>
typeof value === "string"
? new Values.Date(Date.parse(value))
: new Values.Date(new Date(coerceToNumber(value)).getTime()),
? new CodeModeDate(Date.parse(value))
: new CodeModeDate(new Date(coerceToNumber(value)).getTime()),
)
}
const parts = args.map((arg) => coerceToNumber(arg))
return Effect.succeed(new Values.Date(new Date(...(parts as [number, number])).getTime()))
return Effect.succeed(new CodeModeDate(new Date(...(parts as [number, number])).getTime()))
}
private toDatePrimitive(value: unknown, node: AstNode): Effect.Effect<unknown, unknown, R> {
@@ -1502,10 +1511,10 @@ export class Interpreter<R> {
})
}
private constructRegExp(args: Array<unknown>, node: AstNode): Values.RegExp {
private constructRegExp(args: Array<unknown>, node: AstNode): CodeModeRegExp {
const first = args[0]
const pattern =
first instanceof Values.RegExp ? first.regex.source : first === undefined ? "" : coerceToString(first)
first instanceof CodeModeRegExp ? first.regex.source : first === undefined ? "" : coerceToString(first)
const flagsArg = args[1]
if (flagsArg !== undefined && typeof flagsArg !== "string") {
throw new InterpreterRuntimeError(
@@ -1513,9 +1522,9 @@ export class Interpreter<R> {
node,
).as("SyntaxError")
}
const flags = flagsArg ?? (first instanceof Values.RegExp ? first.regex.flags : "")
const flags = flagsArg ?? (first instanceof CodeModeRegExp ? first.regex.flags : "")
try {
return new Values.RegExp(pattern, flags)
return new CodeModeRegExp(pattern, flags)
} catch (error) {
const reason = regexFailureReason(error)
throw new InterpreterRuntimeError(
@@ -1527,8 +1536,8 @@ export class Interpreter<R> {
}
}
private constructMap(init: unknown, node: AstNode): Effect.Effect<Values.Map, unknown, R> {
const target = new Values.Map()
private constructMap(init: unknown, node: AstNode): Effect.Effect<CodeModeMap, unknown, R> {
const target = new CodeModeMap()
if (init === undefined || init === null) return Effect.succeed(target)
const self = this
return Effect.gen(function* () {
@@ -1557,8 +1566,8 @@ export class Interpreter<R> {
})
}
private constructSet(init: unknown, node: AstNode): Effect.Effect<Values.Set, unknown, R> {
const target = new Values.Set()
private constructSet(init: unknown, node: AstNode): Effect.Effect<CodeModeSet, unknown, R> {
const target = new CodeModeSet()
if (init === undefined || init === null) return Effect.succeed(target)
const self = this
return Effect.gen(function* () {
@@ -1576,7 +1585,7 @@ export class Interpreter<R> {
})
}
private constructURL(args: Array<unknown>, node: AstNode): Values.URL {
private constructURL(args: Array<unknown>, node: AstNode): CodeModeURL {
if (args.length === 0) {
throw new InterpreterRuntimeError("new URL(...) requires a URL string and an optional base URL.", node).as(
"TypeError",
@@ -1585,7 +1594,7 @@ export class Interpreter<R> {
const input = urlArgument(args[0], "new URL input")
const base = args[1] === undefined ? undefined : urlArgument(args[1], "new URL base")
try {
return new Values.URL(new URL(input, base))
return new CodeModeURL(new URL(input, base))
} catch {
throw new InterpreterRuntimeError(
`new URL(...) received an invalid URL${base === undefined ? "" : " or base URL"}.`,
@@ -1594,14 +1603,14 @@ export class Interpreter<R> {
}
}
private constructURLSearchParams(init: unknown, node: AstNode): Effect.Effect<Values.URLSearchParams, unknown, R> {
if (init === undefined) return Effect.succeed(new Values.URLSearchParams(new URLSearchParams()))
if (init instanceof Values.URLSearchParams) {
return Effect.succeed(new Values.URLSearchParams(new URLSearchParams(init.params)))
private constructURLSearchParams(init: unknown, node: AstNode): Effect.Effect<CodeModeURLSearchParams, unknown, R> {
if (init === undefined) return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams()))
if (init instanceof CodeModeURLSearchParams) {
return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams(init.params)))
}
if (typeof init === "string") return Effect.succeed(new Values.URLSearchParams(new URLSearchParams(init)))
if (typeof init === "string") return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams(init)))
if (init === null || typeof init === "number" || typeof init === "boolean") {
return Effect.succeed(new Values.URLSearchParams(new URLSearchParams(coerceToString(init))))
return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams(coerceToString(init))))
}
const self = this
return Effect.gen(function* () {
@@ -1617,7 +1626,7 @@ export class Interpreter<R> {
node,
).as("TypeError")
}
return new Values.URLSearchParams(
return new CodeModeURLSearchParams(
new URLSearchParams(entries.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""])),
)
}
@@ -1630,7 +1639,7 @@ export class Interpreter<R> {
node,
).as("TypeError")
}
if (Values.isValue(init)) return new Values.URLSearchParams(new URLSearchParams())
if (isCodeModeValue(init)) return new CodeModeURLSearchParams(new URLSearchParams())
const data = boundedData(init, "new URLSearchParams input")
if (data === null || typeof data !== "object") {
throw new InterpreterRuntimeError(
@@ -1638,7 +1647,7 @@ export class Interpreter<R> {
node,
).as("TypeError")
}
return new Values.URLSearchParams(
return new CodeModeURLSearchParams(
new URLSearchParams(
Object.fromEntries(Object.entries(data).map(([key, value]) => [key, coerceToString(value)])),
),
@@ -1689,7 +1698,7 @@ export class Interpreter<R> {
// Null-prototype data needs explicit primitive coercion; identity and `in` retain raw objects.
// Dates use their default string hint for addition and loose equality, and epoch time elsewhere.
const coerceOperand = (operand: unknown): unknown => {
if (operand instanceof Values.Date) {
if (operand instanceof CodeModeDate) {
return operator === "+" || operator === "==" || operator === "!=" ? coerceToString(operand) : operand.time
}
return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand
@@ -1774,7 +1783,7 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError("Unary operators require data values.", node, "InvalidDataValue")
}
const operand =
value instanceof Values.Date
value instanceof CodeModeDate
? value.time
: value !== null && typeof value === "object"
? coerceToString(value)
@@ -2101,7 +2110,7 @@ export class Interpreter<R> {
if (fn.generator) return Effect.succeed(this.createGenerator(invocation, run, fn.async))
if (!fn.async) return run
// The initial yield assigns the promise before the body can self-resolve.
const box: { promise?: Values.Promise } = {}
const box: { promise?: CodeModePromise } = {}
return Effect.map(
this.createPromise(Effect.flatMap(run, (value) => resolvePromiseValue(invocation.runner, value, fn.body, box))),
(promise) => {
@@ -2272,9 +2281,9 @@ export class Interpreter<R> {
if (
Array.isArray(value) ||
typeof value === "string" ||
value instanceof Values.Map ||
value instanceof Values.Set ||
value instanceof Values.URLSearchParams
value instanceof CodeModeMap ||
value instanceof CodeModeSet ||
value instanceof CodeModeURLSearchParams
) {
const cursor = yield* self.syncIterator(value, node)
if (!cursor) throw new InterpreterRuntimeError("Built-in iterator is unavailable.", node)
@@ -2365,7 +2374,7 @@ export class Interpreter<R> {
if (property.type === "SpreadElement") {
const spread = yield* self.evaluateExpression(getNode(property, "argument"))
if (spread === null || spread === undefined || Values.isValue(spread)) continue
if (spread === null || spread === undefined || isCodeModeValue(spread)) continue
if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) {
throw new InterpreterRuntimeError("Object spread requires a data object.", property, "InvalidDataValue")
}
@@ -2589,11 +2598,11 @@ export class Interpreter<R> {
return new ComputedValue(undefined)
}
if (objectValue instanceof Values.Date) {
if (objectValue instanceof CodeModeDate) {
if (typeof key === "string" && dateMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined)
}
if (objectValue instanceof Values.RegExp) {
if (objectValue instanceof CodeModeRegExp) {
if (key === "lastIndex") return { target: objectValue, key }
if (typeof key === "string" && regexpProperties.has(key)) {
return new ComputedValue((objectValue.regex as unknown as Record<string, unknown>)[key])
@@ -2601,17 +2610,17 @@ export class Interpreter<R> {
if (typeof key === "string" && regexpMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined)
}
if (objectValue instanceof Values.Map) {
if (objectValue instanceof CodeModeMap) {
if (key === "size") return new ComputedValue(objectValue.map.size)
if (typeof key === "string" && mapMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined)
}
if (objectValue instanceof Values.Set) {
if (objectValue instanceof CodeModeSet) {
if (key === "size") return new ComputedValue(objectValue.set.size)
if (typeof key === "string" && setMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined)
}
if (objectValue instanceof Values.URL) {
if (objectValue instanceof CodeModeURL) {
if (key === "searchParams") {
return new ComputedValue(objectValue.searchParams)
}
@@ -2619,7 +2628,7 @@ export class Interpreter<R> {
if (typeof key === "string" && urlProperties.has(key)) return { target: objectValue, key }
return new ComputedValue(undefined)
}
if (objectValue instanceof Values.URLSearchParams) {
if (objectValue instanceof CodeModeURLSearchParams) {
if (key === "size") return new ComputedValue(objectValue.params.size)
if (typeof key === "string" && urlSearchParamsMethods.has(key)) {
return new IntrinsicReference(objectValue, key)
@@ -2628,7 +2637,7 @@ export class Interpreter<R> {
}
// Reject unknown promise properties so a missing await cannot hide.
if (objectValue instanceof Values.Promise) {
if (objectValue instanceof CodeModePromise) {
if (key === "then" || key === "catch" || key === "finally") {
return new PromiseInstanceMethodReference(objectValue, key)
}
@@ -2694,8 +2703,8 @@ export class Interpreter<R> {
if (typeof reference.key === "string") return new IntrinsicReference(reference.target, reference.key)
return Reflect.get(reference.target, reference.key)
}
if (reference.target instanceof Values.RegExp) return reference.target.lastIndex
if (reference.target instanceof Values.URL) {
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
if (reference.target instanceof CodeModeURL) {
return Reflect.get(reference.target.url, reference.key)
}
return Reflect.get(reference.target, reference.key)
@@ -2717,11 +2726,11 @@ export class Interpreter<R> {
reference instanceof ComputedValue ||
reference === undefined ||
isOpaqueMemberReference(reference) ||
reference.target instanceof Values.URL
reference.target instanceof CodeModeURL
) {
throw new InterpreterRuntimeError("Only data fields may be deleted.", target, "InvalidDataValue")
}
if (reference.target instanceof Values.RegExp) {
if (reference.target instanceof CodeModeRegExp) {
return Reflect.deleteProperty(reference.target.regex, reference.key)
}
return Reflect.deleteProperty(reference.target, reference.key)
@@ -2758,10 +2767,10 @@ export class Interpreter<R> {
}
private readReferenceValue(reference: MemberReference, key: PropertyKey): unknown {
if (reference.target instanceof Values.URL) {
if (reference.target instanceof CodeModeURL) {
return Reflect.get(reference.target.url, key)
}
if (reference.target instanceof Values.RegExp) return reference.target.lastIndex
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
return Reflect.get(reference.target, key)
}
@@ -2779,7 +2788,7 @@ export class Interpreter<R> {
target[key] = next
return
}
if (reference.target instanceof Values.URL) {
if (reference.target instanceof CodeModeURL) {
const property = key as string
if (!urlWritableProperties.has(property)) {
throw new InterpreterRuntimeError(`URL.${property} is read-only.`, node).as("TypeError")
@@ -2793,7 +2802,7 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError(`URL.${property} received an invalid value.`, node).as("TypeError")
}
}
if (reference.target instanceof Values.RegExp) {
if (reference.target instanceof CodeModeRegExp) {
reference.target.lastIndex = next
return
}
+19 -10
View File
@@ -1,6 +1,15 @@
import { containsOpaqueReference, containsRuntimeReference, isRuntimeReference } from "../interpreter/references.js"
import { copyIn, copyOut } from "../tool-runtime.js"
import { Values } from "../values.js"
import {
isCodeModeValue,
CodeModeDate,
CodeModeMap,
CodeModePromise,
CodeModeRegExp,
CodeModeSet,
CodeModeURL,
CodeModeURLSearchParams,
} from "../values.js"
import { boundedData, coerceToString } from "./value.js"
export const consoleMethods = new Set(["log", "info", "debug", "warn", "error", "dir", "table"])
@@ -25,14 +34,14 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
if (typeof value === "string") return JSON.stringify(value)
if (typeof value === "number" || typeof value === "boolean") return String(value)
if (typeof value !== "object") return String(value)
if (value instanceof Values.Promise) return "[Promise (await it to get its value)]"
if (value instanceof Values.Date) return coerceToString(value)
if (value instanceof Values.RegExp) return coerceToString(value)
if (value instanceof Values.URL) return coerceToString(value)
if (value instanceof Values.URLSearchParams) return coerceToString(value)
if (value instanceof CodeModePromise) return "[Promise (await it to get its value)]"
if (value instanceof CodeModeDate) return coerceToString(value)
if (value instanceof CodeModeRegExp) return coerceToString(value)
if (value instanceof CodeModeURL) return coerceToString(value)
if (value instanceof CodeModeURLSearchParams) return coerceToString(value)
if (depth > MAX_CONSOLE_DEPTH) return "..."
if (seen.has(value)) return "[Circular]"
if (value instanceof Values.Map) {
if (value instanceof CodeModeMap) {
seen.add(value)
try {
const entries = Array.from(value.map.entries(), ([key, item]): Array<unknown> => [key, item])
@@ -41,7 +50,7 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
seen.delete(value)
}
}
if (value instanceof Values.Set) {
if (value instanceof CodeModeSet) {
seen.add(value)
try {
return `Set(${value.set.size}) ${formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}`
@@ -91,14 +100,14 @@ const consoleTableRows = (
if (Array.isArray(data)) {
return data.map((item, index) => ({ index: String(index), values: consoleTableValues(item, columns) }))
}
if (data !== null && typeof data === "object" && !Values.isValue(data)) {
if (data !== null && typeof data === "object" && !isCodeModeValue(data)) {
return Object.entries(data).map(([index, item]) => ({ index, values: consoleTableValues(item, columns) }))
}
return [{ index: "0", values: { Value: data } }]
}
const consoleTableValues = (value: unknown, columns: ReadonlyArray<string> | undefined): Record<string, unknown> => {
if (value !== null && typeof value === "object" && !Array.isArray(value) && !Values.isValue(value)) {
if (value !== null && typeof value === "object" && !Array.isArray(value) && !isCodeModeValue(value)) {
const source = value as Record<string, unknown>
if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]]))
return Object.fromEntries(Object.entries(source))
+3 -3
View File
@@ -1,5 +1,5 @@
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { Values } from "../values.js"
import { CodeModeDate } from "../values.js"
import { coerceToNumber, coerceToString } from "./value.js"
const dateSetterArguments = new Map<string, number>([
@@ -66,7 +66,7 @@ export const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNo
export const dateSetterArgumentCount = (name: string): number | undefined => dateSetterArguments.get(name)
export const invokeDateMethod = (
value: Values.Date,
value: CodeModeDate,
name: string,
args: Array<number>,
node: AstNode,
@@ -174,7 +174,7 @@ export const invokeDateMethod = (
}
}
const updateDate = (value: Values.Date, time: number): number => {
const updateDate = (value: CodeModeDate, time: number): number => {
value.time = time
return time
}
+16 -9
View File
@@ -4,7 +4,14 @@ import { applyCollectionCallback } from "../interpreter/methods.js"
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { typeofValue } from "../interpreter/references.js"
import { copyIn, copyOut, type SafeObject } from "../tool-runtime.js"
import { Values } from "../values.js"
import {
CodeModeDate,
CodeModeMap,
CodeModeRegExp,
CodeModeSet,
CodeModeURL,
CodeModeURLSearchParams,
} from "../values.js"
export const jsonStatics = new Set(["parse", "stringify"])
export type JsonMethodName = "parse" | "stringify"
@@ -124,19 +131,19 @@ const stringify = <R>(
}
const toJSONValue = (value: unknown): unknown => {
if (value instanceof Values.Date) {
if (value instanceof CodeModeDate) {
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
}
if (value instanceof Values.URL) return value.url.href
if (value instanceof CodeModeURL) return value.url.href
return value
}
const isPlainObject = (value: unknown): value is SafeObject =>
value !== null &&
typeof value === "object" &&
!(value instanceof Values.Date) &&
!(value instanceof Values.RegExp) &&
!(value instanceof Values.Map) &&
!(value instanceof Values.Set) &&
!(value instanceof Values.URL) &&
!(value instanceof Values.URLSearchParams)
!(value instanceof CodeModeDate) &&
!(value instanceof CodeModeRegExp) &&
!(value instanceof CodeModeMap) &&
!(value instanceof CodeModeSet) &&
!(value instanceof CodeModeURL) &&
!(value instanceof CodeModeURLSearchParams)
+6 -6
View File
@@ -2,7 +2,7 @@ import { Effect } from "effect"
import { type AstNode, AsyncIteratorSymbol, InterpreterRuntimeError, IteratorSymbol } from "../interpreter/model.js"
import { containsOpaqueReference, rejectCircularInsertion } from "../interpreter/references.js"
import { isBlockedMember } from "../tool-runtime.js"
import { Values } from "../values.js"
import { isCodeModeValue, CodeModePromise } from "../values.js"
import { boundedData, coerceToString } from "./value.js"
import { preserveConsumerError, type SyncIteratorRunner } from "../interpreter/iterator.js"
@@ -14,8 +14,8 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
const requireObject = (): Record<string, unknown> => {
const input = args[0]
if (Array.isArray(input)) return input as unknown as Record<string, unknown>
if (Values.isValue(input)) return {}
if (input instanceof Values.Promise) {
if (isCodeModeValue(input)) return {}
if (input instanceof CodeModePromise) {
throw new InterpreterRuntimeError(
`Object.${name} received an un-awaited Promise; await it before inspecting the result.`,
node,
@@ -50,7 +50,7 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
return Object.is(args[0], args[1])
case "assign": {
const target = args[0]
if (target === null || typeof target !== "object" || Array.isArray(target) || Values.isValue(target)) {
if (target === null || typeof target !== "object" || Array.isArray(target) || isCodeModeValue(target)) {
throw new InterpreterRuntimeError("Object.assign expects a data object target.", node)
}
const out = target as Record<string, unknown>
@@ -65,7 +65,7 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
)
}
for (const source of args.slice(1)) {
if (source === null || source === undefined || Values.isValue(source)) continue
if (source === null || source === undefined || isCodeModeValue(source)) continue
if (typeof source !== "object" || Array.isArray(source)) {
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
}
@@ -107,7 +107,7 @@ export const invokeObjectFromEntries = <R>(
if (
step.value === null ||
typeof step.value !== "object" ||
Values.isValue(step.value) ||
isCodeModeValue(step.value) ||
containsOpaqueReference(step.value)
) {
throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node).as(
+3 -3
View File
@@ -1,6 +1,6 @@
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
import { Values } from "../values.js"
import { CodeModeRegExp } from "../values.js"
import { coerceToNumber, coerceToString } from "./value.js"
type MatchValue = Array<unknown> & {
@@ -40,7 +40,7 @@ export const escapeRegexHint =
export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => {
// Native parity: an undefined pattern behaves as an empty pattern.
if (arg === undefined) return new RegExp("", extraFlags)
if (arg instanceof Values.RegExp) return arg.regex
if (arg instanceof CodeModeRegExp) return arg.regex
if (typeof arg === "string") {
try {
return new RegExp(arg, extraFlags)
@@ -80,7 +80,7 @@ export const invokeRegExpStatic = (name: string, args: Array<unknown>, node: Ast
}
export const invokeRegExpMethod = (
value: Values.RegExp,
value: CodeModeRegExp,
name: string,
args: Array<unknown>,
node: AstNode,
+4 -4
View File
@@ -66,7 +66,7 @@ export const invokeUriFunction = (ref: UriFunction, args: Array<unknown>, node:
}
export const urlArgument = (value: unknown, label: string): string =>
value instanceof Values.URL ? value.url.href : uriArgument(value, label)
value instanceof CodeModeURL ? value.url.href : uriArgument(value, label)
export const invokeURLStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
if (!urlStatics.has(name)) throw new InterpreterRuntimeError(`URL.${name} is not available.`, node)
@@ -75,16 +75,16 @@ export const invokeURLStatic = (name: string, args: Array<unknown>, node: AstNod
const base = args[1] === undefined ? undefined : urlArgument(args[1], `URL.${name} base`)
try {
const url = new URL(input, base)
return name === "canParse" ? true : new Values.URL(url)
return name === "canParse" ? true : new CodeModeURL(url)
} catch {
return name === "canParse" ? false : null
}
}
export const invokeURLMethod = (value: Values.URL, name: string, node: AstNode): string => {
export const invokeURLMethod = (value: CodeModeURL, name: string, node: AstNode): string => {
if (name === "toString" || name === "toJSON") return value.url.href
throw new InterpreterRuntimeError(`URL method '${name}' is not available.`, node)
}
import { type AstNode, InterpreterRuntimeError, UriFunction } from "../interpreter/model.js"
import { Values } from "../values.js"
import { CodeModeURL } from "../values.js"
import { boundedData, coerceToString } from "./value.js"
+18 -10
View File
@@ -34,13 +34,13 @@ export const boundedData = (value: unknown, label: string): unknown => copyIn(va
export const coerceToString = (value: unknown): string => {
if (value === null) return "null"
if (value === undefined) return "undefined"
if (value instanceof Values.Date)
if (value instanceof CodeModeDate)
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : "Invalid Date"
if (value instanceof Values.RegExp) return `/${value.regex.source}/${value.regex.flags}`
if (value instanceof Values.Map) return "[object Map]"
if (value instanceof Values.Set) return "[object Set]"
if (value instanceof Values.URL) return value.url.href
if (value instanceof Values.URLSearchParams) return value.params.toString()
if (value instanceof CodeModeRegExp) return `/${value.regex.source}/${value.regex.flags}`
if (value instanceof CodeModeMap) return "[object Map]"
if (value instanceof CodeModeSet) return "[object Set]"
if (value instanceof CodeModeURL) return value.url.href
if (value instanceof CodeModeURLSearchParams) return value.params.toString()
if (errorBrandName(value) !== undefined) {
// Match Error.prototype.toString: "name: message", or just one when the other is empty.
const error = value as { name?: unknown; message?: unknown }
@@ -59,8 +59,8 @@ export const coerceToString = (value: unknown): string => {
}
export const coerceToNumber = (value: unknown): number => {
if (value instanceof Values.Date) return value.time
if (Values.isValue(value)) return Number.NaN
if (value instanceof CodeModeDate) return value.time
if (isCodeModeValue(value)) return Number.NaN
// Arrays coerce through our own string coercion: host Number(array) joins with host
// ToPrimitive, which throws on the null-prototype objects the interpreter produces.
if (Array.isArray(value)) return Number(coerceToString(value))
@@ -77,7 +77,7 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node
const raw = args[0]
// Error values are plain SafeObjects; the boundedData path below would strip their brand.
if (ref.name === "String" && errorBrandName(raw) !== undefined) return coerceToString(raw)
if (Values.isValue(raw)) {
if (isCodeModeValue(raw)) {
if (ref.name === "Boolean") return true
if (ref.name === "Number") return coerceToNumber(raw)
if (ref.name === "String") return coerceToString(raw)
@@ -103,4 +103,12 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node
}
import { type AstNode, CoercionFunction, InterpreterRuntimeError } from "../interpreter/model.js"
import { copyIn, type SafeObject } from "../tool-runtime.js"
import { Values } from "../values.js"
import {
isCodeModeValue,
CodeModeDate,
CodeModeMap,
CodeModeRegExp,
CodeModeSet,
CodeModeURL,
CodeModeURLSearchParams,
} from "../values.js"
+28 -20
View File
@@ -12,7 +12,15 @@ import {
import { isNamespace, type Namespace } from "./namespace.js"
import { isTool, type Tool } from "./tool.js"
import type { Tools } from "./tools.js"
import { Values } from "./values.js"
import {
CodeModeDate,
CodeModeMap,
CodeModePromise,
CodeModeRegExp,
CodeModeSet,
CodeModeURL,
CodeModeURLSearchParams,
} from "./values.js"
const compareText = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0)
@@ -143,7 +151,7 @@ const copyBounded = (
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`)
}
if (value instanceof Values.Promise) {
if (value instanceof CodeModePromise) {
throw new ToolRuntimeError(
"InvalidDataValue",
`${label} contains an un-awaited Promise; await tool calls (e.g. \`const result = await tools.ns.tool(...)\`) before using their results.`,
@@ -152,46 +160,46 @@ const copyBounded = (
if (preserveCodeModeValues) {
if (
value instanceof Values.Date ||
value instanceof Values.RegExp ||
value instanceof Values.Map ||
value instanceof Values.Set ||
value instanceof Values.URL ||
value instanceof Values.URLSearchParams
value instanceof CodeModeDate ||
value instanceof CodeModeRegExp ||
value instanceof CodeModeMap ||
value instanceof CodeModeSet ||
value instanceof CodeModeURL ||
value instanceof CodeModeURLSearchParams
) {
return value
}
if (value instanceof Date) return new Values.Date(value.getTime())
if (value instanceof RegExp) return new Values.RegExp(value.source, value.flags)
if (value instanceof Date) return new CodeModeDate(value.getTime())
if (value instanceof RegExp) return new CodeModeRegExp(value.source, value.flags)
if (value instanceof Map) {
const wrapped = new Values.Map()
const wrapped = new CodeModeMap()
for (const [key, item] of value.entries()) {
wrapped.map.set(copyBounded(key, label, depth + 1, seen, true), copyBounded(item, label, depth + 1, seen, true))
}
return wrapped
}
if (value instanceof Set) {
const wrapped = new Values.Set()
const wrapped = new CodeModeSet()
for (const item of value.values()) wrapped.set.add(copyBounded(item, label, depth + 1, seen, true))
return wrapped
}
if (value instanceof URL) return new Values.URL(new URL(value.href))
if (value instanceof URLSearchParams) return new Values.URLSearchParams(new URLSearchParams(value))
if (value instanceof URL) return new CodeModeURL(new URL(value.href))
if (value instanceof URLSearchParams) return new CodeModeURLSearchParams(new URLSearchParams(value))
}
if (value instanceof Values.Date) {
if (value instanceof CodeModeDate) {
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
}
if (value instanceof Date) {
return Number.isFinite(value.getTime()) ? value.toISOString() : null
}
if (value instanceof Values.URL) return value.url.href
if (value instanceof CodeModeURL) return value.url.href
if (value instanceof URL) return value.href
if (
value instanceof Values.RegExp ||
value instanceof Values.Map ||
value instanceof Values.Set ||
value instanceof Values.URLSearchParams ||
value instanceof CodeModeRegExp ||
value instanceof CodeModeMap ||
value instanceof CodeModeSet ||
value instanceof CodeModeURLSearchParams ||
value instanceof RegExp ||
value instanceof Map ||
value instanceof Set ||
+24 -30
View File
@@ -1,24 +1,17 @@
export * as Values from "./values.js"
import type { Fiber } from "effect"
/**
* Runtime values the interpreter recognizes by class. Each wraps the host value it stands for,
* so hosts construct these to hand a value to a program and receive them back unchanged.
*/
export class Promise {
export class CodeModePromise {
constructor(readonly fiber: Fiber.Fiber<unknown, unknown>) {}
}
export class Date {
export class CodeModeDate {
constructor(public time: number) {}
}
export class RegExp {
readonly regex: globalThis.RegExp
export class CodeModeRegExp {
readonly regex: RegExp
constructor(pattern: string, flags: string) {
this.regex = new globalThis.RegExp(pattern, flags)
this.regex = new RegExp(pattern, flags)
}
get lastIndex(): unknown {
@@ -30,30 +23,31 @@ export class RegExp {
}
}
export class Map {
readonly map = new globalThis.Map<unknown, unknown>()
export class CodeModeMap {
readonly map = new Map<unknown, unknown>()
}
export class Set {
readonly set = new globalThis.Set<unknown>()
export class CodeModeSet {
readonly set = new Set<unknown>()
}
export class URLSearchParams {
constructor(readonly params: globalThis.URLSearchParams) {}
export class CodeModeURLSearchParams {
constructor(readonly params: URLSearchParams) {}
}
export class URL {
readonly searchParams: URLSearchParams
constructor(readonly url: globalThis.URL) {
this.searchParams = new URLSearchParams(url.searchParams)
export class CodeModeURL {
readonly searchParams: CodeModeURLSearchParams
constructor(readonly url: URL) {
this.searchParams = new CodeModeURLSearchParams(url.searchParams)
}
}
/** Data-like runtime values; excludes Promise, which never crosses a boundary. */
export const isValue = (value: unknown): value is Date | RegExp | Map | Set | URL | URLSearchParams =>
value instanceof Date ||
value instanceof RegExp ||
value instanceof Map ||
value instanceof Set ||
value instanceof URL ||
value instanceof URLSearchParams
export const isCodeModeValue = (
value: unknown,
): value is CodeModeDate | CodeModeRegExp | CodeModeMap | CodeModeSet | CodeModeURL | CodeModeURLSearchParams =>
value instanceof CodeModeDate ||
value instanceof CodeModeRegExp ||
value instanceof CodeModeMap ||
value instanceof CodeModeSet ||
value instanceof CodeModeURL ||
value instanceof CodeModeURLSearchParams
+1 -1
View File
@@ -26,7 +26,7 @@ export const Plugin = define({
directory: AbsolutePath.make(
directory.startsWith("~/")
? path.join(global.home, directory.slice(2))
: path.resolve(location.project.canonical, directory),
: path.resolve(entry.path ? path.dirname(entry.path) : location.directory, directory),
),
})
}
-1
View File
@@ -513,7 +513,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
}),
get: (input) => sessions.get(input.sessionID),
fork: sessions.fork,
switchAgent: sessions.switchAgent,
switchModel: sessions.switchModel,
prompt: sessions.prompt,
-2
View File
@@ -25,7 +25,6 @@ import { OpenAICompatiblePlugin } from "./provider/openai-compatible.js"
import { OpencodePlugin } from "./provider/opencode.js"
import { OpenRouterPlugin } from "./provider/openrouter.js"
import { PerplexityPlugin } from "./provider/perplexity.js"
import { PoePlugin } from "./provider/poe.js"
import { SapAICorePlugin } from "./provider/sap-ai-core.js"
import { VercelPlugin } from "./provider/vercel.js"
import { VenicePlugin } from "./provider/venice.js"
@@ -61,7 +60,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
OpenAIPlugin,
OpenRouterPlugin,
PerplexityPlugin,
PoePlugin,
SapAICorePlugin,
VercelPlugin,
VenicePlugin,
+18 -101
View File
@@ -1,25 +1,19 @@
import { Duration, Effect, Equal, Schema, Semaphore, Stream } from "effect"
import { Duration, Effect, Schema, Semaphore, Stream } from "effect"
import type { Scope } from "effect"
import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
import { define } from "@opencode/plugin/effect/plugin"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { Provider } from "../../provider.js"
import { WebSearch } from "../../websearch.js"
import { ConfigProvider } from "@opencode/schema/config/provider"
import { Money } from "@opencode/schema/money"
const defaultServer = "https://opencode.ai/console"
const clientID = "opencode-cli"
const methodID = Integration.MethodID.make("device")
const RemoteResponse = Schema.Struct({
providers: Schema.Record(Schema.String, ConfigProvider.Info),
websearch: Schema.Struct({
providerID: WebSearch.ID,
}).pipe(Schema.optional),
})
const RemoteResponse = Schema.Struct({ providers: Schema.Record(Schema.String, ConfigProvider.Info) })
const Device = Schema.Struct({
device_code: Schema.String,
user_code: Schema.String,
@@ -67,9 +61,10 @@ function oauth(http: HttpClient.HttpClient) {
}),
refresh: (credential) =>
Effect.gen(function* () {
const server = typeof credential.metadata?.server === "string" ? credential.metadata.server : defaultServer
const token = yield* post(
http,
`${serverUrl(credential)}/auth/device/token`,
`${server}/auth/device/token`,
{ grant_type: "refresh_token", refresh_token: credential.refresh, client_id: clientID },
Token,
)
@@ -90,25 +85,22 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
const bus = yield* Bus.Service
const http = yield* HttpClient.HttpClient
const loading = Semaphore.makeUnsafe(1)
type ActiveConnection = Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
let snapshot: {
config: typeof RemoteResponse.Type | undefined
connection: ActiveConnection
} = { config: undefined, connection: undefined }
let connected = false
let providers: typeof RemoteResponse.Type.providers | undefined
const load = Effect.fn("OpencodePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("opencode")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
const config = credential
? yield* fetchConfig(http, credential).pipe(
connected = connection !== undefined
providers = credential
? yield* fetchProviders(http, credential).pipe(
Effect.catch((cause) =>
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
),
)
: undefined
return { config, connection }
})
yield* ctx.integration.transform((editor) => {
@@ -119,9 +111,9 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
editor.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } })
})
snapshot = yield* load()
yield* load()
yield* ctx.catalog.transform((catalog) => {
for (const [providerID, item] of Object.entries(snapshot.config?.providers ?? {})) {
for (const [providerID, item] of Object.entries(providers ?? {})) {
const source = catalog.provider.get(item.canonical ?? providerID)
catalog.provider.update(providerID, (provider) => {
if (source && source.provider !== provider)
@@ -191,7 +183,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
const item = catalog.provider.get(Provider.ID.opencode)
if (!item) return
const hasKey = Boolean(process.env.OPENCODE_API_KEY || snapshot.connection || item.provider.settings?.apiKey)
const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.settings?.apiKey)
catalog.provider.update(item.provider.id, (provider) => {
if (!hasKey) {
provider.activation = "enabled"
@@ -207,95 +199,23 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
}
})
yield* ctx.websearch.transform((editor) => {
const descriptor = snapshot.config?.websearch
const connection = snapshot.connection
if (!descriptor || !connection) return
editor.add({
id: descriptor.providerID,
name: "OpenCode Web Search",
execute: (input) =>
Effect.gen(function* () {
const active = yield* ctx.integration.connection.active("opencode")
if (
!active ||
(connection.type === "credential"
? active.type !== "credential" || active.id !== connection.id
: active.type !== "env" || active.name !== connection.name)
) {
return yield* Effect.fail(new Error("OpenCode Console connection changed"))
}
const credential = yield* ctx.integration.connection.resolve(active)
if (!credential) return yield* Effect.fail(new Error("OpenCode Console is not connected"))
const metadata = credential.metadata
const orgID = typeof metadata?.orgID === "string" ? metadata.orgID : undefined
const token = credential.type === "oauth" ? credential.access : credential.key
const server = yield* normalizeServer(serverUrl(credential))
const request = yield* HttpClientRequest.post(`${server}/api/websearch`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(token),
HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
HttpClientRequest.schemaBodyJson(WebSearch.Input)({
query: input.query,
providerID: descriptor.providerID,
}),
)
const response = yield* HttpClient.withScope(HttpClient.filterStatusOk(http))
.execute(request)
.pipe(
Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }),
Effect.flatMap(HttpClientResponse.schemaBodyJson(WebSearch.Response)),
Effect.scoped,
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error("OpenCode web search request timed out")),
}),
)
if (response.providerID !== descriptor.providerID) {
return yield* Effect.fail(
new Error(
`OpenCode web search returned provider ${response.providerID} instead of ${descriptor.providerID}`,
),
)
}
return response.results
}),
})
editor.default.set(descriptor.providerID)
})
const apply = Effect.fn("OpencodePlugin.apply")(function* (next: typeof snapshot) {
snapshot = next
yield* Effect.all([ctx.catalog.reload(), ctx.websearch.reload()], { concurrency: 2, discard: true })
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(apply)))
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
)
// Console config can change independently of local credential activity, so re-fetch
// periodically and only rebuild the catalog and search providers when the snapshot differs.
yield* Effect.sleep(Duration.minutes(10)).pipe(
Effect.andThen(
loading.withPermit(
load().pipe(Effect.flatMap((next) => (Equal.equals(snapshot, next) ? Effect.void : apply(next)))),
),
),
Effect.forever,
Effect.forkScoped,
)
}),
})
function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
const metadata = value.metadata
const server = typeof metadata?.server === "string" ? metadata.server : defaultServer
const orgID = typeof metadata?.orgID === "string" ? metadata.orgID : undefined
const token = value.type === "oauth" ? value.access : value.key
return http
.execute(
HttpClientRequest.get(`${serverUrl(value)}/api/v2/config`).pipe(
HttpClientRequest.get(`${server}/api/v2/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(token),
HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
@@ -306,15 +226,12 @@ function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
if (response.status === 404) return Effect.undefined
return HttpClientResponse.filterStatusOk(response).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
Effect.map((remote) => remote.providers),
)
}),
)
}
function serverUrl(value: Credential.Value) {
return typeof value.metadata?.server === "string" ? value.metadata.server : defaultServer
}
function withoutCredentials<Value>(body: Readonly<Record<string, Value>> | undefined) {
return (
body &&
-163
View File
@@ -1,163 +0,0 @@
import { define } from "@opencode/plugin/effect/plugin"
import { Clock, Deferred, Effect, Option, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import type { ServerResponse } from "node:http"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { OauthCallbackPage } from "../../oauth/page.js"
const integrationID = Integration.ID.make("poe")
const methodID = Integration.MethodID.make("browser")
const clientID = "client_728290227fc048cc9262091a1ea197ea"
const issuer = "https://poe.com"
const maxExpiry = 8_640_000_000_000_000
const Token = Schema.Struct({
api_key: Schema.Trim.check(Schema.isNonEmpty(), Schema.isPattern(/^\S+$/)),
api_key_expires_in: Schema.optional(Schema.NullOr(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)))),
})
const decodeError = Schema.decodeUnknownOption(
Schema.fromJsonString(
Schema.Struct({ error: Schema.optional(Schema.String), error_description: Schema.optional(Schema.String) }),
),
)
export const PoePlugin = define({
id: "opencode.provider.poe",
effect: Effect.fn(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((editor) => {
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Login with Poe (browser)" },
// Poe-issued API keys remain usable until expiry, then require another login.
refresh: (value) =>
Clock.currentTimeMillis.pipe(
Effect.flatMap((now) =>
value.expires > now
? Effect.succeed(value)
: Effect.fail(new Error("Poe API key expired. Log in with Poe again.")),
),
),
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 state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
const callback = yield* Deferred.make<{ code: string; response: ServerResponse }, Error>()
const { createServer } = yield* Effect.promise(() => import("node:http"))
const { EventEmitter } = yield* Effect.promise(() => import("node:events"))
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1")
if (request.method !== "GET" || url.pathname !== "/callback") {
response.writeHead(404).end()
return
}
const error = callbackError(url.searchParams, state)
if (error) {
response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(error, { provider: "Poe" }))
Effect.runSync(Deferred.fail(callback, new Error(error)))
return
}
if (!Effect.runSync(Deferred.succeed(callback, { code: url.searchParams.get("code") ?? "", response })))
response.writeHead(409).end("OAuth callback already received")
})
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
server.close()
server.closeAllConnections()
}),
)
yield* Effect.tryPromise(() => EventEmitter.once(server.listen(0, "127.0.0.1"), "listening"))
const address = server.address()
if (!address || typeof address === "string")
return yield* Effect.fail(new Error("Missing OAuth callback port"))
const redirect = `http://127.0.0.1:${address.port}/callback`
return {
mode: "auto" as const,
url: `${issuer}/oauth/authorize?${new URLSearchParams({
response_type: "code",
client_id: clientID,
redirect_uri: redirect,
scope: "apikey:create",
code_challenge: challenge,
code_challenge_method: "S256",
state,
}).toString()}`,
instructions: "Complete authorization in your browser. This window will close automatically.",
callback: Effect.gen(function* () {
const request = yield* Deferred.await(callback)
const respond = (error?: string) =>
Effect.sync(() =>
request.response
.writeHead(error ? 400 : 200, { "Content-Type": "text/html" })
.end(
error
? OauthCallbackPage.error(error, { provider: "Poe" })
: OauthCallbackPage.success({ provider: "Poe" }),
),
)
return yield* exchangeCode(http, { code: request.code, redirect, verifier }).pipe(
Effect.tap(() => respond()),
Effect.tapError((error) => respond(error.message)),
// Bun's server.closeAllConnections() leaves an unanswered callback response pending.
Effect.onInterrupt(() => Effect.sync(() => request.response.destroy())),
)
}),
}
}),
})
})
}),
})
function callbackError(params: URLSearchParams, state: string) {
if (params.get("state") !== state) return "Invalid OAuth state"
// Poe's client pins this issuer but does not require iss; its documented callbacks may omit it.
if (params.has("iss") && params.get("iss") !== issuer) return "Invalid OAuth issuer"
if (params.has("error")) {
const detail = params.get("error_description") || params.get("error") || "Authorization denied"
return detail.includes(state) ? "Poe authorization failed" : detail
}
return params.get("code")?.trim() ? undefined : "Missing authorization code"
}
function exchangeCode(http: HttpClient.HttpClient, input: { code: string; redirect: string; verifier: string }) {
return Effect.gen(function* () {
const response = yield* http
.execute(
HttpClientRequest.post("https://api.poe.com/token").pipe(
HttpClientRequest.bodyUrlParams({
grant_type: "authorization_code",
client_id: clientID,
code: input.code,
redirect_uri: input.redirect,
code_verifier: input.verifier,
}),
),
)
.pipe(Effect.mapError(() => new Error("Poe token exchange request failed")))
if (response.status < 200 || response.status >= 300) {
const error = Option.getOrUndefined(decodeError(yield* response.text.pipe(Effect.orElseSucceed(() => ""))))
const detail = error?.error_description || error?.error
return yield* Effect.fail(
new Error(
detail && ![input.code, input.verifier].some((secret) => detail.includes(secret))
? `Poe token exchange failed: ${detail}`
: `Poe token exchange failed (${response.status})`,
),
)
}
const token = yield* HttpClientResponse.schemaBodyJson(Token)(response).pipe(
Effect.mapError(() => new Error("Invalid Poe token response")),
)
const expires =
token.api_key_expires_in == null ? maxExpiry : (yield* Clock.currentTimeMillis) + token.api_key_expires_in * 1000
if (!Number.isSafeInteger(expires) || expires > maxExpiry)
return yield* Effect.fail(new Error("Invalid Poe API key expiry"))
return Credential.OAuth.make({ type: "oauth", methodID, access: token.api_key, refresh: "", expires })
})
}
+2 -30
View File
@@ -4,7 +4,7 @@ export * from "./session/schema.js"
import { Effect, Layer, Schema, Context, Stream } from "effect"
import { LLMClient } from "@opencode/ai"
import { ListAnchor } from "@opencode/schema/session"
import { and, asc, desc, eq, lt, lte, sql } from "drizzle-orm"
import { and, desc, eq } from "drizzle-orm"
import { Project } from "./project.js"
import { Model } from "@opencode/schema/model"
import { Location } from "./location.js"
@@ -23,7 +23,6 @@ import { Slug } from "./util/slug.js"
import path from "path"
import { SessionRunner } from "./session/runner/index.js"
import { SessionStore } from "./session/store.js"
import { decodeMessageRow } from "./session/history.js"
import { SessionExecution } from "./session/execution.js"
import {
AttachmentError,
@@ -94,7 +93,6 @@ type CompactInput = Parameters<Session.Handle["compact"]>[0] & { sessionID: Sess
type ForkInput = {
sessionID: SessionSchema.ID
boundary: SessionSchema.ForkRequestBoundary
filter?: (messages: readonly SessionMessage.Info[]) => readonly SessionMessage.Info[]
}
export {
@@ -295,7 +293,7 @@ const layer = Layer.effect(
fork: Effect.fn("Session.fork")(function* (input) {
const parent = yield* result.get(input.sessionID)
const boundary = yield* db
.select({ id: SessionMessageTable.id, seq: SessionMessageTable.seq })
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(
and(
@@ -313,31 +311,6 @@ const layer = Layer.effect(
messageID: input.boundary.messageID,
})
if (!boundary) return yield* new ForkEmptyError({ sessionID: input.sessionID })
const messages = input.filter
? input.filter(
yield* db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, parent.id),
input.boundary.type === "before"
? lt(SessionMessageTable.seq, boundary.seq)
: lte(SessionMessageTable.seq, boundary.seq),
sql`${SessionMessageTable.type} != 'assistant' or json_extract(${SessionMessageTable.data}, '$.time.completed') is not null`,
sql`${SessionMessageTable.type} != 'shell' or json_extract(${SessionMessageTable.data}, '$.status') != 'running'`,
sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') != 'running'`,
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(
Effect.orDie,
Effect.flatMap((rows) => Effect.forEach(rows, decodeMessageRow)),
Effect.orDie,
),
)
: undefined
const sessionID = SessionSchema.ID.create()
const inherited = yield* db
.transaction(() =>
@@ -354,7 +327,6 @@ const layer = Layer.effect(
sessionID,
parentID: parent.id,
boundary: { ...input.boundary, messageID: boundary.id },
messages: messages === undefined ? undefined : Schema.encodeSync(Schema.Array(SessionMessage.Info))(messages),
...inherited,
})
return yield* result.get(sessionID).pipe(Effect.orDie)
-26
View File
@@ -179,32 +179,6 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
if (event.data.instructionEntries)
yield* InstructionEntry.initialize(db, event.data.sessionID, event.data.instructionEntries, event.created)
if (event.data.messages !== undefined) {
if (event.data.messages.length > 0) {
yield* db
.insert(SessionMessageTable)
.values(
event.data.messages.map((message, index) => {
const { id: _, type, ...data } = message
return {
id: SessionMessage.ID.make(`${SessionMessage.ID.fromEvent(event.id)}_${index + 1}`),
session_id: event.data.sessionID,
type,
seq: index + 1,
time_created: data.time.created,
data,
}
}),
)
.run()
.pipe(Effect.orDie)
yield* Bus.reserveSequence(db, event.data.sessionID, event.data.messages.length)
}
if (event.data.instructions)
yield* InstructionState.initialize(db, event.data.sessionID, event.durable.seq, event.data.instructions)
return
}
let cursor = -1
while (copiedSeq !== undefined) {
const rows = yield* db
+2
View File
@@ -254,6 +254,8 @@ const layer = Layer.effect(
})
.pipe(Effect.mapError((error) => operationError(selected.id, "create", error)))
const result = { directory: yield* canonical(fs, created.directory) }
if (result.directory !== (yield* canonical(fs, worktreeDirectory)))
return yield* new InvalidDirectoryError({ directory: result.directory })
yield* changed(
yield* ops.create({
directory: result.directory,
-1
View File
@@ -162,7 +162,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
session: {
hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")),
create: overrides.session?.create ?? (() => Effect.die("unused session.create")),
fork: overrides.session?.fork ?? (() => Effect.die("unused session.fork")),
get: overrides.session?.get ?? (() => Effect.die("unused session.get")),
switchAgent: overrides.session?.switchAgent ?? (() => Effect.die("unused session.switchAgent")),
switchModel: overrides.session?.switchModel ?? (() => Effect.die("unused session.switchModel")),
@@ -3,8 +3,7 @@ import { LLM } from "@opencode/ai"
import { LLMClient, RequestExecutor } from "@opencode/ai/route"
import { Money } from "@opencode/schema/money"
import { Effect, Layer, Stream } from "effect"
import { TestClock } from "effect/testing"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { Catalog } from "@opencode/core/catalog"
import { Credential } from "@opencode/core/credential"
import { Integration } from "@opencode/core/integration"
@@ -14,9 +13,7 @@ import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { OpencodePlugin } from "@opencode/core/plugin/provider/opencode"
import { Provider } from "@opencode/core/provider"
import { WebSearch } from "@opencode/core/websearch"
import { withEnv } from "../fixture/env"
import { drain } from "../lib/clock"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -383,409 +380,6 @@ describe("OpencodePlugin", () => {
),
)
it.effect("refreshes hosted search with Console config and skips unchanged snapshots", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const state = { advertised: false, requests: 0 }
const server = Bun.serve({
port: 0,
fetch: () => {
state.requests++
return Response.json({
providers: {},
...(state.advertised ? { websearch: { providerID: "opencode" } } : {}),
})
},
})
return { server, state }
}),
({ server, state }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
const websearch = yield* WebSearch.Service
const rebuilds = { catalog: 0, websearch: 0 }
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({ type: "key", key: "secret", metadata: { server: server.url.origin } }),
})
yield* catalog.transform(() => {
rebuilds.catalog++
})
yield* websearch.transform(() => {
rebuilds.websearch++
})
yield* addPlugin()
yield* drain
const initial = { ...rebuilds }
expect(state.requests).toBe(1)
expect(yield* websearch.default()).toBeUndefined()
state.advertised = true
yield* TestClock.adjust("9 minutes")
yield* drain
expect(state.requests).toBe(1)
expect(rebuilds).toEqual(initial)
expect(yield* websearch.default()).toBeUndefined()
yield* TestClock.adjust("1 minute")
yield* drain
expect(state.requests).toBe(2)
expect(rebuilds).toEqual({ catalog: initial.catalog + 1, websearch: initial.websearch + 1 })
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
yield* TestClock.adjust("10 minutes")
yield* drain
expect(state.requests).toBe(3)
expect(rebuilds).toEqual({ catalog: initial.catalog + 1, websearch: initial.websearch + 1 })
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
state.advertised = false
yield* TestClock.adjust("10 minutes")
yield* drain
expect(state.requests).toBe(4)
expect(rebuilds).toEqual({ catalog: initial.catalog + 2, websearch: initial.websearch + 2 })
expect(yield* websearch.providers()).toEqual([])
expect(yield* websearch.default()).toBeUndefined()
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.live("loads and executes hosted web search from the connected OpenCode server", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const requests: Array<{
method: string
path: string
authorization: string | null
orgID: string | null
body?: unknown
}> = []
const gate = Promise.withResolvers<void>()
const state = { advertised: true, providerID: "opencode", waitForConfig: false }
const server = Bun.serve({
port: 0,
fetch: async (request) => {
const path = new URL(request.url).pathname
const body = request.method === "POST" ? await request.json() : undefined
requests.push({
method: request.method,
path,
authorization: request.headers.get("authorization"),
orgID: request.headers.get("x-org-id"),
...(body === undefined ? {} : { body }),
})
if (path === "/api/v2/config") {
if (state.waitForConfig) await gate.promise
return Response.json({
providers: {},
...(state.advertised
? {
websearch: {
providerID: "opencode",
},
}
: {}),
})
}
if (path === "/api/websearch" || path === "/other/api/websearch") {
return Response.json({
providerID: state.providerID,
results: [
{
url: "https://github.com/anomalyco/opencode",
title: "OpenCode",
content: "Open source AI coding agent.",
time: { published: 1_700_000_000_000 },
},
],
})
}
return new Response("Not found", { status: 404 })
},
})
return { gate, requests, server, state }
}),
({ gate, requests, server, state }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const websearch = yield* WebSearch.Service
const account = (access: string, serverURL = server.url.origin, orgID = "org_test") =>
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access,
refresh: "refresh",
expires: Date.now() + 600_000,
metadata: { server: serverURL, orgID },
})
const initial = yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: account("secret"),
})
yield* addPlugin()
expect(yield* websearch.providers()).toContainEqual({
id: WebSearch.ID.make("opencode"),
name: "OpenCode Web Search",
})
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
expect(yield* websearch.query({ query: "effect web search" })).toEqual(
new WebSearch.Response({
providerID: WebSearch.ID.make("opencode"),
results: [
{
url: "https://github.com/anomalyco/opencode",
title: "OpenCode",
content: "Open source AI coding agent.",
time: { published: 1_700_000_000_000 },
},
],
}),
)
expect(requests).toEqual([
{
method: "GET",
path: "/api/v2/config",
authorization: "Bearer secret",
orgID: "org_test",
},
{
method: "POST",
path: "/api/websearch",
authorization: "Bearer secret",
orgID: "org_test",
body: { query: "effect web search", providerID: "opencode" },
},
])
yield* credentials.update(initial.id, {
value: account("replacement"),
})
yield* websearch.query({ query: "fresh credential" })
expect(requests.at(-1)).toMatchObject({
method: "POST",
authorization: "Bearer replacement",
body: { query: "fresh credential", providerID: "opencode" },
})
yield* credentials.update(initial.id, {
value: account("moved", `${server.url.origin}/other///?ignored=true#ignored`),
})
yield* websearch.query({ query: "updated server" })
expect(requests.at(-1)).toMatchObject({
method: "POST",
path: "/other/api/websearch",
authorization: "Bearer moved",
orgID: "org_test",
body: { query: "updated server", providerID: "opencode" },
})
yield* credentials.update(initial.id, {
value: account("replacement"),
})
state.providerID = "unexpected"
expect((yield* websearch.query({ query: "wrong provider" }).pipe(Effect.flip))._tag).toBe("WebSearch.Request")
state.advertised = false
state.waitForConfig = true
const searchCount = requests.filter((request) => request.path === "/api/websearch").length
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: account("switched", server.url.origin, "org_switched"),
})
yield* eventually(
Effect.sync(() => requests),
(requests) => requests.some((request) => request.authorization === "Bearer switched"),
)
expect((yield* websearch.query({ query: "switch race" }).pipe(Effect.flip))._tag).toBe("WebSearch.Request")
expect(requests.filter((request) => request.path === "/api/websearch")).toHaveLength(searchCount)
gate.resolve()
yield* eventually(websearch.providers(), (providers) =>
providers.every((provider) => provider.id !== WebSearch.ID.make("opencode")),
)
expect(yield* websearch.default()).toBeUndefined()
expect(requests.at(-1)).toMatchObject({
method: "GET",
path: "/api/v2/config",
authorization: "Bearer switched",
orgID: "org_switched",
})
}),
({ gate, server }) =>
Effect.sync(() => gate.resolve()).pipe(Effect.andThen(Effect.promise(() => server.stop(true)))),
),
)
it.live("derives hosted search identity and the default Console endpoint locally", () =>
Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
fetch: (request) => {
if (new URL(request.url).pathname === "/console/api/v2/config") {
return Response.json({
providers: {},
websearch: {
providerID: "managed-search",
name: "Remote name",
url: "https://example.invalid/search",
},
})
}
return Response.json({ providerID: "managed-search", results: [] })
},
}),
),
(server) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const websearch = yield* WebSearch.Service
const http = yield* HttpClient.HttpClient
const requests: string[] = []
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({ type: "key", key: "secret" }),
})
yield* addPlugin().pipe(
Effect.provideService(
HttpClient.HttpClient,
http.pipe(
HttpClient.mapRequest((request) => {
requests.push(request.url)
return HttpClientRequest.setUrl(request, `${server.url.origin}${new URL(request.url).pathname}`)
}),
),
),
)
expect(yield* websearch.default()).toEqual({
id: WebSearch.ID.make("managed-search"),
name: "OpenCode Web Search",
})
expect(yield* websearch.query({ query: "default Console" })).toEqual(
new WebSearch.Response({ providerID: WebSearch.ID.make("managed-search"), results: [] }),
)
expect(requests).toEqual([
"https://opencode.ai/console/api/v2/config",
"https://opencode.ai/console/api/websearch",
])
}),
(server) => Effect.promise(() => server.stop(true)),
),
)
it.live("does not forward hosted search credentials through redirects", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const requests: string[] = []
const state = { crossOrigin: false }
const server = Bun.serve({
port: 0,
fetch: (request) => {
const url = new URL(request.url)
requests.push(url.pathname)
if (url.pathname === "/console/api/v2/config") {
return Response.json({
providers: {},
websearch: {
providerID: "opencode",
},
})
}
if (url.pathname === "/console/api/websearch") {
if (state.crossOrigin) url.hostname = "127.0.0.1"
return Response.redirect(`${url.origin}/outside-console`, 307)
}
return Response.json({ providerID: "opencode", results: [] })
},
})
return { requests, server, state }
}),
({ requests, server, state }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const websearch = yield* WebSearch.Service
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "secret",
metadata: { server: `${server.url.origin}/console`, orgID: "org_test" },
}),
})
yield* addPlugin()
expect((yield* websearch.query({ query: "private search" }).pipe(Effect.flip))._tag).toBe("WebSearch.Request")
expect(requests).toEqual(["/console/api/v2/config", "/console/api/websearch"])
state.crossOrigin = true
expect((yield* websearch.query({ query: "private search" }).pipe(Effect.flip))._tag).toBe("WebSearch.Request")
expect(requests).toEqual(["/console/api/v2/config", "/console/api/websearch", "/console/api/websearch"])
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.live("closes a rejected hosted search response without waiting for its body", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const state = { cancelled: false }
const server = Bun.serve({
port: 0,
fetch: (request) => {
const url = new URL(request.url)
if (url.pathname === "/api/v2/config") {
return Response.json({
providers: {},
websearch: {
providerID: "opencode",
},
})
}
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("temporarily unavailable"))
},
cancel() {
state.cancelled = true
},
}),
{ status: 503 },
)
},
})
return { server, state }
}),
({ server, state }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const websearch = yield* WebSearch.Service
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "secret",
metadata: { server: server.url.origin, orgID: "org_test" },
}),
})
yield* addPlugin()
const error = yield* websearch.query({ query: "rejected search" }).pipe(Effect.flip)
expect(error._tag).toBe("WebSearch.Request")
yield* eventually(
Effect.sync(() => state.cancelled),
(cancelled) => cancelled,
)
// Callers can retain errors, so response cleanup must not depend on garbage collection.
expect(error).toBeInstanceOf(WebSearch.RequestError)
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.live("preserves native Console OpenAI variant bodies in inference requests", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
@@ -1,334 +0,0 @@
import { LLM } from "@opencode/ai"
import { LLMClient, RequestExecutor } from "@opencode/ai/route"
import { Catalog } from "@opencode/core/catalog"
import { Credential } from "@opencode/core/credential"
import { Integration } from "@opencode/core/integration"
import { Model } from "@opencode/core/model"
import { ModelResolver } from "@opencode/core/model-resolver"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { ProviderPlugins } from "@opencode/core/plugin/provider"
import { PoePlugin } from "@opencode/core/plugin/provider/poe"
import { Provider } from "@opencode/core/provider"
import { expect } from "bun:test"
import { Clock, Deferred, Effect, Fiber, Layer, Schedule, Stream } from "effect"
import { TestClock } from "effect/testing"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const integrationID = Integration.ID.make("poe")
const providerID = Provider.ID.make("poe")
const methodID = Integration.MethodID.make("browser")
const modelID = Model.ID.make("test-model")
const fixture = Effect.gen(function* () {
const requests: Request[] = []
const replies: (Response | Effect.Effect<Response>)[] = []
const http = HttpClient.make((request) =>
Effect.gen(function* () {
requests.push(yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie))
const response = replies.shift()
if (!response) throw new Error(`Unexpected request: ${request.url}`)
return HttpClientResponse.fromWeb(request, yield* Effect.isEffect(response) ? response : Effect.succeed(response))
}),
)
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
yield* integrations.transform((editor) => {
editor.method.update({ integrationID, method: { type: "key" } })
editor.method.update({ integrationID, method: { type: "env", names: ["POE_API_KEY"] } })
})
yield* catalog.transform((editor) => {
editor.provider.update(providerID, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { baseURL: "https://api.poe.com/v1" }
})
editor.model.update(providerID, modelID, () => {})
})
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* PoePlugin.effect(host).pipe(Effect.provideService(HttpClient.HttpClient, http))
const status = (attemptID: Integration.AttemptID) =>
integrations.oauth.status({ integrationID, attemptID }).pipe(
Effect.repeat({
until: (value) => value.status !== "pending",
schedule: Schedule.spaced("1 millis"),
times: 100,
}),
)
const connect = Effect.gen(function* () {
const attempt = yield* integrations.oauth.connect({ integrationID, methodID, label: "Poe browser" })
const url = new URL(attempt.url)
const callback = new URL(url.searchParams.get("redirect_uri") ?? "")
callback.searchParams.set("state", url.searchParams.get("state") ?? "")
callback.searchParams.set("code", "auth-code")
return { attempt, url, callback }
})
const send = Effect.gen(function* () {
const resolver = yield* ModelResolver.Service
const resolved = yield* resolver.resolve(Model.Ref.make({ providerID, id: modelID }))
if (!resolved) throw new Error("Expected Poe model")
expect(resolved.model.route.id).toBe("openai-compatible-chat")
return yield* LLMClient.stream(LLM.request({ model: resolved.model, prompt: "Hello" })).pipe(
Stream.runCollect,
Effect.provide(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer), Layer.fresh)),
Effect.provideService(HttpClient.HttpClient, http),
)
}).pipe(Effect.provide(ModelResolver.layer))
return { requests, replies, integrations, credentials, status, connect, send }
})
it.effect("registers Poe browser OAuth alongside generic key and environment methods without fetching", () =>
Effect.gen(function* () {
const test = yield* fixture
expect(ProviderPlugins).toContain(PoePlugin)
expect((yield* test.integrations.get(integrationID))?.methods).toEqual([
{ type: "key" },
{ type: "env", names: ["POE_API_KEY"] },
{ id: methodID, type: "oauth", label: "Login with Poe (browser)" },
])
expect(test.requests).toHaveLength(0)
}),
)
for (const expiry of [3600, null, undefined]) {
it.live(`exchanges a PKCE code for a native Poe credential (expiry: ${expiry})`, () =>
Effect.gen(function* () {
const test = yield* fixture
yield* test.integrations.connection.key({ integrationID, key: "previous-key", label: "Previous account" })
const previous = yield* test.integrations.connection.active(integrationID)
const login = yield* test.connect
expect(yield* test.integrations.connection.active(integrationID)).toEqual(previous)
const url = login.url
expect(url.origin + url.pathname).toBe("https://poe.com/oauth/authorize")
expect(Object.fromEntries(url.searchParams)).toMatchObject({
response_type: "code",
client_id: "client_728290227fc048cc9262091a1ea197ea",
scope: "apikey:create",
code_challenge_method: "S256",
})
const callback = login.callback
expect(callback.hostname).toBe("127.0.0.1")
expect(callback.pathname).toBe("/callback")
expect(url.searchParams.get("state")).toBeTruthy()
// Both issuer-bearing and documented issuer-less callbacks must work.
if (expiry != null) callback.searchParams.set("iss", "https://poe.com")
test.replies.push(Response.json({ api_key: " poe-key ", api_key_expires_in: expiry }))
const now = Date.now()
expect((yield* Effect.promise(() => fetch(callback, { headers: { Connection: "close" } }))).status).toBe(200)
expect((yield* test.status(login.attempt.attemptID)).status).toBe("complete")
const exchange = test.requests[0]
expect(exchange.url).toBe("https://api.poe.com/token")
expect(exchange.headers.get("content-type")).toContain("application/x-www-form-urlencoded")
const form = new URLSearchParams(yield* Effect.promise(() => exchange.text()))
expect(Object.fromEntries(form)).toMatchObject({
grant_type: "authorization_code",
client_id: "client_728290227fc048cc9262091a1ea197ea",
code: "auth-code",
redirect_uri: url.searchParams.get("redirect_uri"),
})
expect(url.searchParams.get("code_challenge")).toBe(
Buffer.from(
yield* Effect.promise(() =>
crypto.subtle.digest("SHA-256", new TextEncoder().encode(form.get("code_verifier") ?? "")),
),
).toString("base64url"),
)
const records = yield* test.credentials.list(integrationID)
const active = records.find((credential) => credential.label === "Poe browser")
expect(records).toHaveLength(2)
expect(yield* test.integrations.connection.active(integrationID)).toMatchObject({
type: "credential",
id: active?.id,
label: "Poe browser",
})
const saved = active?.value
if (saved?.type !== "oauth") throw new Error("Expected OAuth credential")
expect(saved.access).toBe("poe-key")
expect(saved.refresh).toBe("")
if (expiry == null) expect(saved.expires).toBe(8_640_000_000_000_000)
if (expiry != null) {
expect(saved.expires).toBeGreaterThanOrEqual(now + expiry * 1000)
expect(saved.expires).toBeLessThanOrEqual(Date.now() + expiry * 1000)
}
test.replies.push(
new Response(
'data: {"choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n',
{
headers: { "Content-Type": "text/event-stream" },
},
),
)
expect(yield* test.send).toContainEqual(expect.objectContaining({ type: "text-delta", text: "Hello" }))
expect(test.requests[1].url).toBe("https://api.poe.com/v1/chat/completions")
expect(test.requests[1].headers.get("authorization")).toBe("Bearer poe-key")
yield* test.integrations.oauth.complete({ integrationID, attemptID: login.attempt.attemptID })
expect(yield* test.credentials.list(integrationID)).toEqual(records)
expect(test.requests).toHaveLength(2)
}),
)
}
it.live("isolates overlapping login attempts and closes cancelled listeners", () =>
Effect.gen(function* () {
const test = yield* fixture
const first = yield* test.connect
const next = yield* test.connect
expect(first.callback.origin).not.toBe(next.callback.origin)
expect(first.url.searchParams.get("code_challenge")).not.toBe(next.url.searchParams.get("code_challenge"))
expect(first.url.searchParams.get("state")).not.toBe(next.url.searchParams.get("state"))
first.callback.searchParams.set("state", next.url.searchParams.get("state") ?? "")
expect((yield* Effect.promise(() => fetch(first.callback, { headers: { Connection: "close" } }))).status).toBe(400)
expect(yield* test.status(first.attempt.attemptID)).toMatchObject({
status: "failed",
message: "Invalid OAuth state",
})
expect((yield* test.integrations.oauth.status({ integrationID, attemptID: next.attempt.attemptID })).status).toBe(
"pending",
)
yield* test.integrations.oauth.cancel({ integrationID, attemptID: next.attempt.attemptID })
expect((yield* Effect.tryPromise(() => fetch(next.callback)).pipe(Effect.exit))._tag).toBe("Failure")
expect(test.requests).toHaveLength(0)
expect(yield* test.credentials.list(integrationID)).toEqual([])
}),
)
for (const invalid of [
{ params: { state: "" }, message: "Invalid OAuth state" },
{ params: { iss: "https://other.example", error: "access_denied" }, message: "Invalid OAuth issuer" },
{ params: { iss: "https://poe.com/" }, message: "Invalid OAuth issuer" },
{ params: { iss: "" }, message: "Invalid OAuth issuer" },
{ params: { code: "" }, message: "Missing authorization code" },
{ params: { error: "access_denied", error_description: "User declined access" }, message: "User declined access" },
]) {
it.live(`rejects invalid or denied callbacks (${JSON.stringify(invalid.params)})`, () =>
Effect.gen(function* () {
const test = yield* fixture
const login = yield* test.connect
Object.entries(invalid.params).forEach(([key, value]) => login.callback.searchParams.set(key, value))
const response = yield* Effect.promise(() => fetch(login.callback, { headers: { Connection: "close" } }))
expect(response.status).toBe(400)
expect(yield* Effect.promise(() => response.text())).toContain("Authorization failed")
expect(yield* test.status(login.attempt.attemptID)).toMatchObject({ status: "failed", message: invalid.message })
expect(test.requests).toHaveLength(0)
expect(yield* test.credentials.list(integrationID)).toEqual([])
}),
)
}
for (const response of [
{
status: 400,
body: JSON.stringify({ error: "invalid_grant", error_description: "Code expired" }),
message: "Poe token exchange failed: Code expired",
},
{
status: 400,
body: JSON.stringify({ error: "invalid_grant" }),
message: "Poe token exchange failed: invalid_grant",
},
{ status: 502, body: "Bad gateway", message: "Poe token exchange failed (502)" },
{
status: 400,
body: JSON.stringify({ error_description: "Rejected auth-code" }),
message: "Poe token exchange failed (400)",
},
...[
"{}",
"{",
'{"api_key":" "}',
'{"api_key":"poe-key","api_key_expires_in":-1}',
'{"api_key":"poe-key","api_key_expires_in":1.5}',
'{"api_key":"poe-key","api_key_expires_in":1e309}',
].map((body) => ({ status: 200, body, message: "Invalid Poe token response" })),
{
status: 200,
body: '{"api_key":"poe-key","api_key_expires_in":8640000000000}',
message: "Invalid Poe API key expiry",
},
]) {
it.live(`preserves the active connection after a failed token exchange (${response.status}: ${response.body})`, () =>
Effect.gen(function* () {
const test = yield* fixture
yield* test.integrations.connection.key({ integrationID, key: "previous-key" })
const previous = yield* test.integrations.connection.active(integrationID)
const saved = yield* test.credentials.list(integrationID)
const login = yield* test.connect
test.replies.push(new Response(response.body, { status: response.status }))
const page = yield* Effect.promise(() => fetch(login.callback, { headers: { Connection: "close" } }))
expect(page.status).toBe(400)
expect(yield* Effect.promise(() => page.text())).toContain(response.message)
expect(yield* test.status(login.attempt.attemptID)).toMatchObject({ status: "failed", message: response.message })
expect(yield* test.credentials.list(integrationID)).toEqual(saved)
expect(yield* test.integrations.connection.active(integrationID)).toEqual(previous)
}),
)
}
for (const cancel of [false, true]) {
it.live(`waits for the token exchange and consumes the callback once (cancel: ${cancel})`, () =>
Effect.gen(function* () {
const test = yield* fixture
const login = yield* test.connect
const started = yield* Deferred.make<void>()
const token = yield* Deferred.make<Response>()
test.replies.push(Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(token))))
const page = yield* Effect.tryPromise(() => fetch(login.callback, { headers: { Connection: "close" } })).pipe(
Effect.exit,
Effect.forkScoped,
)
yield* Deferred.await(started)
expect(
(yield* test.integrations.oauth.status({ integrationID, attemptID: login.attempt.attemptID })).status,
).toBe("pending")
expect(yield* test.credentials.list(integrationID)).toEqual([])
expect((yield* Effect.promise(() => fetch(login.callback, { headers: { Connection: "close" } }))).status).toBe(
409,
)
expect(test.requests).toHaveLength(1)
if (cancel) {
yield* test.integrations.oauth.cancel({ integrationID, attemptID: login.attempt.attemptID })
yield* Deferred.succeed(token, Response.json({ api_key: "cancelled-key" }))
expect((yield* Fiber.join(page))._tag).toBe("Failure")
expect(yield* test.credentials.list(integrationID)).toEqual([])
return
}
yield* Deferred.succeed(token, Response.json({ api_key: "poe-key" }))
const result = yield* Fiber.join(page)
const response = yield* result
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.text())).toContain("Authorization successful")
expect((yield* test.status(login.attempt.attemptID)).status).toBe("complete")
expect(yield* test.credentials.list(integrationID)).toHaveLength(1)
}),
)
}
it.effect("keeps near-expiry keys usable and requires a new login after expiry", () =>
Effect.gen(function* () {
const test = yield* fixture
const saved = yield* test.credentials.create({
integrationID,
value: Credential.OAuth.make({
type: "oauth",
methodID,
access: "poe-key",
refresh: "",
expires: (yield* Clock.currentTimeMillis) + 120_000,
}),
})
const connection = { type: "credential" as const, id: saved.id, label: saved.label }
expect(yield* test.integrations.connection.resolve(connection)).toEqual(saved.value)
yield* TestClock.adjust("2 minutes")
const error = yield* test.integrations.connection.resolve(connection).pipe(Effect.flip)
expect(error.cause).toEqual(new Error("Poe API key expired. Log in with Poe again."))
yield* test.integrations.connection.key({ integrationID, key: "manual-key" })
const active = yield* test.integrations.connection.active(integrationID)
if (!active) throw new Error("Expected key connection")
expect(yield* test.integrations.connection.resolve(active)).toEqual({ type: "key", key: "manual-key" })
expect(test.requests).toHaveLength(0)
}),
)
-95
View File
@@ -40,9 +40,6 @@ import { offlineModels } from "./fixture/models"
import { promptLocationNode } from "./fixture/prompt-location"
import { globalProjectNode } from "./lib/project"
import { tmpdirScoped } from "./fixture/tmpdir"
import { Plugin } from "@opencode/plugin"
import { PluginPromise } from "@opencode/core/plugin/promise"
import { host } from "./plugin/host"
const it = testEffect(
AppNodeBuilder.build(
@@ -564,98 +561,6 @@ describe("Session.create", () => {
}),
)
it.effect("freezes filtered fork history and replays it without invoking the callback", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const parent = yield* session.create({ location })
yield* session.prompt({ sessionID: parent.id, text: "First", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
yield* session.synthetic({ sessionID: parent.id, text: "Original note", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
const last = yield* session.prompt({ sessionID: parent.id, text: "Excluded by boundary", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
const calls: number[] = []
const forked = yield* session.fork({
sessionID: parent.id,
boundary: { type: "before", messageID: last.id },
filter: (messages) => {
calls.push(messages.length)
return messages.flatMap((message) =>
message.type === "synthetic" ? [{ ...message, text: "Filtered note" }] : [],
)
},
})
const original = yield* session.context(forked.id)
expect(calls).toEqual([2])
expect(original).toMatchObject([{ type: "synthetic", text: "Filtered note" }])
const event = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))[0]
if (event.type !== "session.forked") return yield* Effect.die(new Error("Fork event not found"))
expect(typeof event.data.messages?.[0].time.created).toBe("number")
expect((yield* session.context(parent.id))[1]).toMatchObject({ text: "Original note" })
const recorded = yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, forked.id)).get()
if (!recorded) return yield* Effect.die(new Error("Fork event not found"))
yield* bus.remove(forked.id)
yield* db.delete(SessionTable).where(eq(SessionTable.id, forked.id)).run()
yield* bus.replay({
id: recorded.id,
created: recorded.created,
aggregateID: recorded.aggregate_id,
seq: recorded.seq,
type: recorded.type,
data: recorded.data,
})
expect(yield* session.context(forked.id)).toEqual(original)
expect(calls).toEqual([2])
yield* session.prompt({ sessionID: forked.id, text: "Continue", resume: false })
yield* SessionInbox.promote(db, bus, forked.id, "steer")
expect(yield* session.context(forked.id)).toMatchObject([
{ type: "synthetic", text: "Filtered note" },
{ type: "user", text: "Continue" },
])
const empty = yield* session.fork({
sessionID: parent.id,
boundary: { type: "through" },
filter: () => [],
})
expect(yield* session.context(empty.id)).toEqual([])
}),
)
it.effect("exposes filtered forks to Promise plugins with decoded callback messages", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const parent = yield* session.create({ location })
yield* session.prompt({ sessionID: parent.id, text: "Keep", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
yield* session.synthetic({ sessionID: parent.id, text: "Drop", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
const plugin = PluginPromise.fromPromise(
Plugin.define({
id: "filtered-fork",
async setup(ctx) {
const fork = await ctx.session.fork({
sessionID: parent.id,
boundary: { type: "through" },
filter: (messages) => {
expect(DateTime.isDateTime(messages[0].time.created)).toBe(true)
return messages.filter((message) => message.type === "user")
},
})
expect(typeof fork.time.created).toBe("number")
expect(await ctx.session.context({ sessionID: fork.id })).toMatchObject([{ type: "user", text: "Keep" }])
},
}),
)
yield* plugin.effect(
host({ session: { fork: session.fork, context: (input) => session.context(input.sessionID) } }),
)
}),
)
it.effect("keeps a fork untitled when its parent is untitled", () =>
Effect.gen(function* () {
const session = yield* Session.Service
+2 -55
View File
@@ -315,15 +315,6 @@ describe("Worktree", () => {
const bus = yield* Bus.Service
const context = yield* Layer.build(worktreeLayer(selected.directory, selected.id, database, bus, root.path))
const worktrees = Context.get(context, Worktree.Service)
const config = yield* Config.Test
yield* config.setEntries([
new Document({
type: "document",
path: abs(path.join(root.path, "global/opencode.json")),
info: new Info({ worktree: { directory: ".lane/trees" } }),
}),
])
yield* ConfigWorktreePlugin.Plugin.effect(host()).pipe(Effect.provide(context))
yield* projects.update({
projectID: initial.id,
commands: {
@@ -335,11 +326,11 @@ describe("Worktree", () => {
const created = yield* worktrees.create({
strategy: gitWorktree,
from: selected.canonical,
directory: abs(path.join(root.path, "worktrees")),
name: "selected-clone",
})
expect(selected.id).toBe(initial.id)
expect(created.directory).toBe(abs(path.join(clone, ".lane/trees/selected-clone")))
expect((yield* projects.list()).find((project) => project.id === initial.id)?.canonical).toBe(main)
expect(yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(created.directory).text())).toBe(
yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(clone).text()),
@@ -920,7 +911,7 @@ describe("Worktree", () => {
}),
)
const first = yield* worktrees.create({ name: "one" })
expect(first.directory).toBe(abs(path.join(input.root.path, "copies/one")))
expect(first.directory).toBe(abs(path.join(input.root.path, "nested/copies/one")))
expect(yield* stored(input.projectID)).toContainEqual({ directory: first.directory, strategy: "custom" })
yield* config.setEntries(documents.slice(0, 1))
yield* bus.publish(Event.Updated, {})
@@ -934,50 +925,6 @@ describe("Worktree", () => {
expect(third.directory).toBe(abs(path.join(input.root.path, "worktree", input.projectID.slice(0, 6), "three")))
}),
)
;["relative", "absolute", "home"].forEach((mode) => {
it.live(`resolves ${mode} global directory config from a linked checkout's subdirectory`, () =>
Effect.gen(function* () {
const input = yield* setup()
const config = yield* Config.Test
const projects = yield* Project.Service
const global = yield* Global.Service
const worktrees = yield* Worktree.Service
const linked = abs(path.join(input.root.path, "linked"))
const nested = abs(path.join(linked, "src"))
const home = abs(path.join(input.root.path, "home"))
yield* Effect.promise(async () => {
await $`git worktree add ${linked} -b linked`.cwd(input.sourceDirectory).quiet()
await fs.mkdir(nested)
})
const project = yield* projects.resolve(nested)
const directory =
mode === "relative" ? ".lane/trees" : mode === "home" ? "~/copies" : path.join(home, "absolute")
yield* config.setEntries([
new Document({
type: "document",
path: abs(path.join(home, ".config/opencode/opencode.json")),
info: new Info({ worktree: { directory } }),
}),
])
yield* ConfigWorktreePlugin.Plugin.effect(host()).pipe(
Effect.provideService(Location.Service, { directory: nested, project }),
Effect.provideService(Global.Service, { ...global, home }),
)
const created = yield* worktrees.create({ name: "task" })
expect(project.directory).toBe(linked)
expect(project.canonical).toBe(input.sourceDirectory)
expect(created.directory).toBe(
abs(
mode === "relative"
? path.join(input.sourceDirectory, ".lane/trees/task")
: path.join(home, mode === "home" ? "copies/task" : "absolute/task"),
),
)
}),
)
})
it.effect("normalization retains worktree directory and rejects invalid configuration", () =>
Effect.sync(() => {
-6
View File
@@ -98,11 +98,5 @@ export type SessionDomain = Pick<
| "wait"
| "context"
> & {
readonly fork: (
input: Parameters<SessionApi<unknown>["fork"]>[0] & {
/** Select or transform settled history in chronological order. Runs once before the fork is recorded. */
readonly filter?: (messages: readonly SessionMessage.Info[]) => readonly SessionMessage.Info[]
},
) => ReturnType<SessionApi<unknown>["fork"]>
readonly hook: ModelHooks<SessionHooks>
}
-6
View File
@@ -566,12 +566,6 @@ export function fromPromise(plugin: Plugin) {
host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))), options),
),
create: adaptApiMethod(SessionEndpoints["session.create"], host.session.create),
fork: (input) =>
adaptApiMethod<PromiseContext["session"]["fork"]>(
SessionEndpoints["session.fork"],
(request: Parameters<typeof host.session.fork>[0]) =>
host.session.fork({ ...request, filter: input.filter }),
)(input),
get: adaptApiMethod(SessionEndpoints["session.get"], host.session.get),
switchAgent: adaptApiMethod(SessionEndpoints["session.switchAgent"], host.session.switchAgent),
switchModel: adaptApiMethod(SessionEndpoints["session.switchModel"], host.session.switchModel),
-6
View File
@@ -98,11 +98,5 @@ export type SessionDomain = Pick<
| "wait"
| "context"
> & {
readonly fork: (
input: Parameters<SessionApi["fork"]>[0] & {
/** Select or transform settled history in chronological order. Uses decoded Session messages, like session hooks. */
readonly filter?: (messages: readonly SessionMessage.Info[]) => readonly SessionMessage.Info[]
},
) => ReturnType<SessionApi["fork"]>
readonly hook: ModelHooks<SessionHooks>
}
-2
View File
@@ -1,6 +1,5 @@
export interface WorktreeCreateInput {
readonly sourceDirectory: string
/** Suggested destination after naming and collision handling. Strategies may return a different directory. */
readonly directory: string
/** Starting ref, not the name of a new branch. Reject unsupported refs rather than ignoring them. */
readonly branch?: string
@@ -12,7 +11,6 @@ export interface WorktreeRemoveInput {
}
export interface WorktreeResult {
/** Actual directory created by the strategy, used for inventory and startup commands. */
readonly directory: string
}
+8 -1
View File
@@ -1621,7 +1621,14 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
"anyOf": [
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
}
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { Schema } from "effect"
export const Info = Schema.Struct({
directory: Schema.Trim.pipe(Schema.check(Schema.isNonEmpty())).annotate({
description: "Parent directory for new worktrees, relative to the project's primary checkout when not absolute",
description: "Parent directory for new worktrees, relative to the declaring config file when not absolute",
}),
}).annotate({ identifier: "Config.Worktree" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
-2
View File
@@ -174,8 +174,6 @@ export const Forked = Event.durable({
...Base,
parentID: SessionID,
boundary: SessionFork.Boundary,
/** Frozen plugin-selected history. Omitted for ordinary boundary-based forks. */
messages: Schema.Array(SessionMessage.InfoEncoded).pipe(optional),
instructions: Instruction.Values.pipe(optional),
instructionEntries: InstructionEntry.Snapshot.pipe(optional),
},
-3
View File
@@ -296,6 +296,3 @@ export type Info =
| Assistant
| Compaction
export type Type = Info["type"]
export const InfoEncoded = Schema.toEncoded(Info).annotate({ identifier: "Session.Message.Info.Encoded" })
export type InfoEncoded = typeof InfoEncoded.Type
@@ -7,6 +7,7 @@ import type {
import { useI18n } from "@opencode/ui/context/i18n"
import { Tooltip } from "@opencode/ui/tooltip"
import { For, Show, createMemo, type Accessor, type JSX } from "solid-js"
import { Dynamic } from "solid-js/web"
import type { SessionUserActions, SessionUserComment } from "../actions"
import { useData } from "../context"
import { TimelineSeparator } from "../components/timeline-separator"
@@ -388,6 +389,33 @@ export function createSessionTimelineRowRenderer(input: {
const value = message()
return value ? notice(value) : undefined
})
const childID = createMemo(() => {
const value = message()
if (value?.type !== "synthetic" || value.metadata?.source !== "subagent") return
const id = value.metadata.childID
if (typeof id === "string" && id) return id
})
const href = createMemo(() => {
const id = childID()
if (id) return data.sessionHref?.(id)
})
const clickable = createMemo(() => !!(childID() && (data.navigateToSession || href())))
const open = () => {
const id = childID()
if (id) data.navigateToSession?.(id)
}
const navigate = (event: MouseEvent) => {
if (!childID() || !data.navigateToSession) return
if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
event.preventDefault()
open()
}
const navigateKey = (event: KeyboardEvent) => {
if (!clickable() || href()) return
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
open()
}
return (
<>
<Show when={compaction()}>
@@ -410,9 +438,16 @@ export function createSessionTimelineRowRenderer(input: {
<Show
when={content().items?.length}
fallback={
<div
<Dynamic
component={href() ? "a" : "div"}
data-slot="session-timeline-notice"
class={`w-full truncate ${props.grouped ? "py-1" : "pt-3 pb-1"} text-13-regular leading-text-compact text-text-weak ${inset()}`}
class={`block w-full truncate ${props.grouped ? "py-1" : "pt-3 pb-1"} text-13-regular leading-text-compact text-text-weak ${inset()}`}
classList={{ "cursor-pointer": clickable() }}
href={href()}
role={clickable() && !href() ? "link" : undefined}
tabIndex={clickable() && !href() ? 0 : undefined}
onClick={navigate}
onKeyDown={navigateKey}
>
<bdi
dir="auto"
@@ -429,7 +464,7 @@ export function createSessionTimelineRowRenderer(input: {
</span>
)}
</Show>
</div>
</Dynamic>
}
>
<div data-slot="session-timeline-notice" class={`w-full py-1 ${inset()}`}>
+1
View File
@@ -821,6 +821,7 @@ function App(props: { pair?: DialogPairCredentials }) {
title: "Switch model",
suggested: true,
category: "Agent",
// Bias /mo toward /models over /move without changing global fuzzy scoring.
slash: { name: "models", aliases: ["mo"] },
run: () => {
dialog.replace(() => <DialogModel />)
@@ -20,23 +20,23 @@ import type { WorktreeListOutput } from "@opencode/client"
import { useRoute } from "../context/route"
import { DialogWorktreeName } from "./dialog-worktree-name"
export type WorkspaceSelection =
export type MoveSessionSelection =
| { type: "directory"; directory: string; subdirectory: boolean }
| { type: "new"; name: string }
type ProjectDirectory = WorktreeListOutput[number]
type DialogWorkspacesProps = {
type DialogMoveSessionProps = {
projectID: string
location?: { directory: string; workspaceID?: string }
current?: WorkspaceSelection
onSelect: (selection: WorkspaceSelection) => void
onCurrentChange?: (selection: WorkspaceSelection) => void
current?: MoveSessionSelection
onSelect: (selection: MoveSessionSelection) => void
onCurrentChange?: (selection: MoveSessionSelection) => void
initialDirectories?: ReadonlyArray<ProjectDirectory>
fixture?: boolean
initialRemoving?: string
}
export function DialogWorkspaces(props: DialogWorkspacesProps) {
export function DialogMoveSession(props: DialogMoveSessionProps) {
const dialog = useDialog()
const client = useClient()
const dimensions = useTerminalDimensions()
@@ -60,7 +60,7 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
function reopen(initialRemoving?: string) {
dialog.replace(() => (
<DialogWorkspaces {...props} initialDirectories={directoryData()} initialRemoving={initialRemoving} />
<DialogMoveSession {...props} initialDirectories={directoryData()} initialRemoving={initialRemoving} />
))
}
@@ -113,7 +113,7 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
.toSorted((a, b) => b.directory.length - a.directory.length)[0]
})
const options = createMemo<DialogSelectOption<WorkspaceSelection | undefined>[]>(() => {
const options = createMemo<DialogSelectOption<MoveSessionSelection | undefined>[]>(() => {
if (showError()) return []
const data = directoryData()
const current = currentRoot()?.directory
@@ -213,7 +213,7 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
return true
}
async function remove(option: DialogSelectOption<WorkspaceSelection | undefined>) {
async function remove(option: DialogSelectOption<MoveSessionSelection | undefined>) {
if (!option.value || option.value.type !== "directory" || option.value.subdirectory || removing()) return
const data = directoryData()
const selected = option.value
@@ -299,14 +299,6 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
props.onSelect({ type: "new", name })
}
async function move(option: DialogSelectOption<WorkspaceSelection | undefined>) {
if (route.data.type !== "session" || option.value?.type !== "directory") return
const sessionID = route.data.sessionID
const directory = option.value.directory
dialog.clear()
await client.api.session.move({ sessionID, directory }).catch(toast.error)
}
const fullHeight = createMemo(() =>
Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2)),
)
@@ -314,11 +306,11 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
return (
<box minHeight={showError() ? 5 : fullHeight()}>
<DialogSelect
title="Worktrees"
title="Move session"
titleView={
<box flexDirection="row" gap={1}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
Worktrees
Move session
</text>
<Show when={working() || directories.loading || loadedProject.loading}>
<Spinner />
@@ -335,7 +327,7 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
Could not load worktrees
</text>
<text fg={theme.text.subdued}>{errorMessage(loadError())}</text>
<text fg={theme.text.subdued}>Close and reopen Worktrees to try again.</text>
<text fg={theme.text.subdued}>Close and reopen Move session to try again.</text>
</box>
) : directories.loading || loadedProject.loading ? (
<box paddingLeft={4} paddingRight={4}>
@@ -362,9 +354,6 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) {
showError() || props.fixture
? []
: [
...(route.data.type === "session"
? [{ command: "dialog.move_session.move", title: "move", onTrigger: move }]
: []),
{
command: "dialog.move_session.new",
title: "new",
@@ -458,6 +458,8 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
directory: data.project.get(id)!.canonical,
workspace: workspaceID(),
},
strategy: "git",
directory: path.join(paths.worktree, id.slice(0, 6)),
...(value.trim() ? { name: value.trim() } : {}),
})
.then((created) => {
+3 -3
View File
@@ -615,11 +615,11 @@ export function Prompt(props: PromptProps) {
},
},
{
title: "Manage workspaces",
desc: "Manage workspaces",
title: "Move session",
desc: "Move to another project dir",
name: "session.move",
category: "Session",
slash: { name: "worktrees", aliases: ["move", "mov"] },
slash: { name: "move" },
run: () => {
move.open()
},
+18 -10
View File
@@ -4,10 +4,9 @@ import { errorMessage } from "../../util/error"
import { useDialog } from "../../ui/dialog"
import { useClient } from "../../context/client"
import { useToast } from "../../ui/toast"
import { DialogWorkspaces, type WorkspaceSelection } from "../dialog-workspaces"
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
import { useData } from "../../context/data"
import { useLocation } from "../../context/location"
import { useRoute } from "../../context/route"
export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) {
const dialog = useDialog()
@@ -15,12 +14,11 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
const toast = useToast()
const data = useData()
const currentLocation = useLocation()
const route = useRoute()
const paths = useTuiPaths()
const [creating, setCreating] = createSignal(false)
const [creatingDots, setCreatingDots] = createSignal(3)
const [progress, setProgress] = createSignal<string>()
const [destination, setDestination] = createSignal<WorkspaceSelection>()
const [destination, setDestination] = createSignal<MoveSessionSelection>()
function homeLocation() {
const location = currentLocation.ref ?? data.location.default()
@@ -70,7 +68,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
const sessionID = input.sessionID()
const session = sessionID ? await resolveSession(sessionID) : undefined
dialog.replace(() => (
<DialogWorkspaces
<DialogMoveSession
projectID={projectID}
location={session?.location ?? homeLocation()}
current={
@@ -89,18 +87,19 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
}
onCurrentChange={setDestination}
onSelect={(selection) => {
if (!input.sessionID() && selection.type === "new") {
const sessionID = input.sessionID()
if (!sessionID) {
setDestination(selection)
dialog.clear()
return
}
void selectWorkspace(selection)
void moveExistingSession(sessionID, selection)
}}
/>
))
}
async function selectWorkspace(selection: WorkspaceSelection) {
async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) {
dialog.clear()
const directory = selection.type === "new" ? await create(selection.name) : selection.directory
if (!directory) {
@@ -108,8 +107,17 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
dialog.clear()
return
}
finishSubmit()
route.navigate({ type: "home", location: { directory } })
setProgress("Moving session")
try {
await client.api.session.move({ sessionID, directory })
dialog.clear()
} catch (error) {
toast.error(error)
dialog.clear()
} finally {
setProgress(undefined)
setCreating(false)
}
}
async function resolveProjectID() {
+2 -3
View File
@@ -105,7 +105,7 @@ export const Definitions = {
"session.export": keybind("<leader>x", "Export session to editor"),
"session.copy": keybind("none", "Copy session transcript"),
"session.copy.id": keybind("none", "Copy session ID"),
"session.move": keybind("none", "Manage workspaces"),
"session.move": keybind("none", "Move session"),
"session.new": keybind("<leader>n", "Create a new session"),
"session.list": keybind("<leader>l", "List all sessions"),
"open.menu": keybind("ctrl+o", "Open recent sessions and projects"),
@@ -262,8 +262,7 @@ export const Definitions = {
"dialog.integration.rename": keybind("ctrl+r", "Rename integration account"),
"dialog.integration.delete": keybind("ctrl+d", "Delete integration account"),
"dialog.worktree.generate": keybind("tab", "Generate worktree name"),
"dialog.move_session.new": keybind("ctrl+a", "New worktree"),
"dialog.move_session.move": keybind("ctrl+m", "Move session to worktree"),
"dialog.move_session.new": keybind("ctrl+m", "New worktree"),
"dialog.move_session.delete": keybind("ctrl+d", "Delete worktree"),
"dialog.move_session.refresh": keybind("ctrl+r", "Refresh worktrees"),
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
@@ -2,7 +2,7 @@ import type { Plugin } from "@opencode/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { TextAttributes } from "@opentui/core"
import { createSignal } from "solid-js"
import { DialogWorkspaces } from "../../../component/dialog-workspaces"
import { DialogMoveSession } from "../../../component/dialog-move-session"
import { SessionLocationUnavailable } from "../../../routes/session/location-missing"
import type { Story } from "./index"
import { StoryFooter } from "./footer"
@@ -15,7 +15,7 @@ function SessionLocationMissingStory(props: { context: Plugin.Context }) {
const [message, setMessage] = createSignal("Choose another directory to continue")
const open = () =>
props.context.ui.dialog.show(() => (
<DialogWorkspaces
<DialogMoveSession
projectID="fixture-project"
initialDirectories={[
{ directory: "/Users/kit/code/open-source/opencode" },
@@ -45,7 +45,7 @@ export function useWorkingDirectoryActions(input: { directory: () => string | un
...(input.onMove
? [
{
title: "Workspaces",
title: "Move session",
value: "session.move",
description: "to another working directory",
onSelect: () => void input.onMove?.(),
@@ -509,7 +509,11 @@ test.each(["", "search-ui"])("creates a worktree named '%s' and opens it in the
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
expect(payload).toEqual(name ? { name } : {})
expect(payload).toEqual({
strategy: "git",
directory: path.join("/tmp/opencode", projectID.slice(0, 6)),
...(name ? { name } : {}),
})
expect(fixture.route.data).toEqual({ type: "home", location: { directory: created, workspaceID } })
expect(fixture.location.ref).toEqual({ directory: created, workspaceID })
} finally {
@@ -362,7 +362,7 @@ test("dialog actions run without options while row actions still require a selec
)
try {
app.mockInput.pressKey("a", { ctrl: true })
app.mockInput.pressKey("m", { ctrl: true })
app.mockInput.pressKey("d", { ctrl: true })
expect(global).toBe(1)
+7 -56
View File
@@ -8,7 +8,7 @@ import { ClientProvider } from "../../../src/context/client"
import { DataProvider, useData } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { LocationProvider, useLocation } from "../../../src/context/location"
import { RouteProvider, useRoute } from "../../../src/context/route"
import { RouteProvider } from "../../../src/context/route"
import { ThemeProvider } from "../../../src/context/theme"
import { DialogProvider } from "../../../src/ui/dialog"
import { ToastProvider, useToast } from "../../../src/ui/toast"
@@ -49,8 +49,7 @@ test.each([
expect(fixture.data.location.info({ directory: created })?.project.canonical).toBe(clone)
expect(fixture.reads.locations.filter((directory) => directory === input.directory)).toHaveLength(1)
expect(fixture.reads.session).toBe(input.home ? 0 : 1)
expect(fixture.moves).toEqual([])
if (!input.home) expect(fixture.route.data).toEqual({ type: "home", location: { directory: created } })
expect(fixture.moves).toEqual(input.home ? [] : [{ directory: created }])
} finally {
fixture.app.renderer.destroy()
}
@@ -84,30 +83,11 @@ test.each([
}
})
test.each([false, true])("selecting a workspace opens Home without moving a session (home=%s)", async (home) => {
const fixture = await renderMove({ directory: clone, home })
try {
await fixture.move.open()
await fixture.app.waitForFrame((frame) => frame.includes("Worktrees") && frame.includes(linked))
await fixture.app.mockInput.typeText("linked")
await fixture.app.waitForFrame((frame) => frame.includes(linked) && !frame.includes(clone))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home" && fixture.route.data.location?.directory === linked)
expect(fixture.route.data).toEqual({ type: "home", location: { directory: linked } })
expect(fixture.moves).toEqual([])
expect(fixture.requests).toEqual([])
expect(fixture.move.pending()).toBe(false)
if (!home) expect(fixture.data.session.get("ses_clone")?.location.directory).toBe(clone)
} finally {
fixture.app.renderer.destroy()
}
})
test("removal uses the current configuration location, not the destination directory", async () => {
const fixture = await renderMove({ directory: clone, home: true })
try {
await fixture.move.open()
await fixture.app.waitForFrame((frame) => frame.includes("Worktrees") && frame.includes(linked))
await fixture.app.waitForFrame((frame) => frame.includes("Move session") && frame.includes(linked))
await fixture.app.mockInput.typeText("linked")
await fixture.app.waitForFrame((frame) => frame.includes(linked) && !frame.includes(clone))
fixture.app.mockInput.pressKey("d", { ctrl: true })
@@ -120,32 +100,6 @@ test("removal uses the current configuration location, not the destination direc
}
})
test.each([false, true])("Ctrl+M moves only an existing session (home=%s)", async (home) => {
const fixture = await renderMove({ directory: clone, home })
try {
await fixture.move.open()
const frame = await fixture.app.waitForFrame((frame) => frame.includes("Worktrees") && frame.includes(linked))
expect(frame).toContain("new ctrl+a")
expect(frame.includes("move ctrl+m")).toBe(!home)
await fixture.app.mockInput.typeText("linked")
await fixture.app.waitForFrame((frame) => frame.includes(linked) && !frame.includes(clone))
fixture.app.mockInput.pressKey("m", { ctrl: true })
if (home) {
await fixture.app.renderOnce()
expect(fixture.moves).toEqual([])
expect(fixture.route.data).toEqual({ type: "home" })
expect(fixture.requests).toEqual([])
return
}
await fixture.app.waitFor(() => fixture.moves.length === 1)
expect(fixture.moves).toEqual([{ directory: linked }])
expect(fixture.route.data).toEqual({ type: "session", sessionID: "ses_clone" })
expect(fixture.requests).toEqual([])
} finally {
fixture.app.renderer.destroy()
}
})
test.each([
{ name: "session", unavailable: "session" as const },
{ name: "location", unavailable: "location" as const },
@@ -251,13 +205,11 @@ async function renderMove(input: {
let move!: ReturnType<typeof usePromptMove>
let toast!: ReturnType<typeof useToast>
let location!: ReturnType<typeof useLocation>
let route!: ReturnType<typeof useRoute>
function Probe() {
data = useData()
toast = useToast()
location = useLocation()
route = useRoute()
move = usePromptMove({
projectID: () => (input.home ? data.location.info()?.project.id : "proj_test"),
sessionID: () => (input.home ? undefined : "ses_clone"),
@@ -271,7 +223,7 @@ async function renderMove(input: {
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<ToastProvider>
<RouteProvider initialRoute={input.home ? { type: "home" } : { type: "session", sessionID: "ses_clone" }}>
<RouteProvider>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider directory={launch}>
<LocationProvider>
@@ -300,7 +252,6 @@ async function renderMove(input: {
move,
toast,
location,
route,
requests,
removals,
moves,
@@ -308,9 +259,9 @@ async function renderMove(input: {
async create() {
await move.open()
const frame = await app.waitForFrame(
(frame) => frame.includes("Worktrees") && (frame.includes(clone) || frame.includes(launch)),
(frame) => frame.includes("Move session") && (frame.includes(clone) || frame.includes(launch)),
)
app.mockInput.pressKey("a", { ctrl: true })
app.mockInput.pressKey("m", { ctrl: true })
await app.waitForFrame((frame) => frame.includes("Name worktree"))
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
await app.mockInput.typeText("fresh")
@@ -320,7 +271,7 @@ async function renderMove(input: {
await move.getDirectory()
return frame
}
await app.waitFor(() => route.data.type === "home" || toast.currentToast !== null)
await app.waitFor(() => moves.length > 0 || toast.currentToast !== null)
return frame
},
}
+2 -27
View File
@@ -25,31 +25,6 @@ URLs (`opencode` or `github`). Each manifest includes the selected artifact's ve
file URLs, SHA-512 checksums, sizes, and release date. Existing minimum-version selection
and `current`/User-Agent handling also apply to these feeds.
## Channel rollouts
Set each channel's rollout duration in hours on the admin page. All channels default
to `0` (immediate); fractional hours are supported. For example, `6` makes a release
available to roughly half of IPs after three hours and all IPs after six hours.
Eligibility uses the original publication time (`time_created`) and a SHA-256 hash
of the channel and Cloudflare's `CF-Connecting-IP`. Each IP keeps the same rollout
position across releases in that channel. Requests without this header wait for
the full duration. The retired `next` channel is not available or configurable.
Until the active release is eligible, callers receive the newest eligible artifact
published before it, for the same name and distribution. This also handles overlapping
rollouts. Earlier inactive releases can be fallbacks, including manually deactivated
releases; releases newer than the active release cannot. If no eligible artifact
exists, it is omitted from listings and individual artifact requests return 404.
Minimum releases bypass rollout for clients that need them, and identified clients
are not sent a fallback below their configured minimum. Rollout applies to all JSON
endpoints and desktop manifests. Responses, including unavailable artifacts, are not cached.
Duration changes apply immediately to existing releases. Manual activation uses the
original publication time too; set the duration to `0` to make it immediate.
Apply the `0004_channel_rollout.sql` migration before deploying.
## Minimum releases
Each channel/name/distribution can mark one retained artifact as `minimum`, independently
@@ -70,8 +45,8 @@ artifact. All three public API paths apply the same selection and use `Cache-Con
no-store` because responses can depend on the User-Agent.
Version comparison uses semver, normalizing preview run numbers to numeric prerelease
identifiers and historical `next` versions to `beta`. The retired `/api/next`
channel returns 404; use `/api/beta` instead.
identifiers and historical `next` versions to `beta`. The `/api/next` channel also
resolves to `beta`.
Choose a minimum that older clients can install and that can itself consume the active
release. For the CLI package migration, retain a package-aware release published as
@@ -1,4 +0,0 @@
CREATE TABLE channel_rollout (
channel TEXT PRIMARY KEY,
duration_hours REAL NOT NULL CHECK (duration_hours >= 0)
);
+24 -106
View File
@@ -68,7 +68,6 @@ export default {
if (pathname === "/admin" && request.method === "GET") return admin(request, env, prefix)
if (pathname === "/admin/activate" && request.method === "POST") return markArtifact(request, env, "active", prefix)
if (pathname === "/admin/minimum" && request.method === "POST") return markArtifact(request, env, "minimum", prefix)
if (pathname === "/admin/rollout" && request.method === "POST") return configureRollout(request, env, prefix)
if (pathname === "/api/publish" && request.method === "POST") return publishArtifact(request, env)
if (request.method !== "GET") return new Response("Method not allowed", { status: 405 })
@@ -77,59 +76,39 @@ export default {
return new Response("Not found", { status: 404 })
}
if (path[0] === "next") return json({ error: "Channel not found" }, 404)
const resolved = path[0]
const resolved = resolveChannel(path[0])
const agent = request.headers.get("User-Agent")?.match(/^opencode\/(?:([^/]+)\/([^/]+)\/cli|(.*))$/)
const current = url.searchParams.get("current") ?? agent?.[2] ?? agent?.[3]
const source = agent?.[1] ?? current?.match(/^v?0\.0\.0-(.+)-\d+(?:\.\d+)?(?:\+.*)?$/)?.[1]
const caller = source === undefined || resolveChannel(source) === resolved ? current : undefined
const rollout = await env.DB.prepare("SELECT duration_hours FROM channel_rollout WHERE channel = ?")
.bind(resolved)
.first<{ duration_hours: number }>()
const ip = request.headers.get("CF-Connecting-IP")
const hash =
rollout?.duration_hours && ip
? new DataView(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${resolved}:${ip}`))).getUint32(
0,
)
: undefined
// Missing IPs wait for full rollout. The same IP keeps its position across releases.
const cutoff =
Date.now() - (rollout?.duration_hours ?? 0) * 3_600_000 * (hash === undefined ? 1 : (hash + 1) / 2 ** 32)
if (path.length === 4) {
if (path[1] !== "desktop" || !/^latest(?:-mac|-linux(?:-arm64)?)?\.yml$/.test(path[3])) {
return new Response("Not found", { status: 404 })
}
return artifactDistribution(env.DB, resolved, path[1], path[2], caller, cutoff, path[3])
return artifactDistribution(env.DB, resolved, path[1], path[2], caller, path[3])
}
if (path.length === 1) return channel(env.DB, resolved, caller, cutoff)
if (path.length === 2) return artifactName(env.DB, resolved, path[1], caller, cutoff)
return artifactDistribution(env.DB, resolved, path[1], path[2], caller, cutoff)
if (path.length === 1) return channel(env.DB, resolved, caller)
if (path.length === 2) return artifactName(env.DB, resolved, path[1], caller)
return artifactDistribution(env.DB, resolved, path[1], path[2], caller)
},
} satisfies ExportedHandler<Env>
async function channel(db: D1Database, channel: string, current: string | undefined, cutoff: number) {
async function channel(db: D1Database, channel: string, current: string | undefined) {
const result = await db
.prepare(`${select} WHERE channel = ? AND (active = 1 OR minimum = 1) ORDER BY name, distribution`)
.bind(channel)
.all<ArtifactRow>()
const artifacts = await selectArtifacts(db, result.results, current, cutoff)
const artifacts = selectArtifacts(result.results, current)
if (!artifacts.length) return json({ error: "Channel not found" }, 404)
return updateResponse({ channel, artifacts: artifacts.map(decodeArtifact) })
}
async function artifactName(
db: D1Database,
channel: string,
name: string,
current: string | undefined,
cutoff: number,
) {
async function artifactName(db: D1Database, channel: string, name: string, current: string | undefined) {
const result = await db
.prepare(`${select} WHERE channel = ? AND name = ? AND (active = 1 OR minimum = 1) ORDER BY distribution`)
.bind(channel, name)
.all<ArtifactRow>()
const artifacts = await selectArtifacts(db, result.results, current, cutoff)
const artifacts = selectArtifacts(result.results, current)
if (!artifacts.length) return json({ error: "Artifact not found" }, 404)
return updateResponse({ channel, name, artifacts: artifacts.map(decodeArtifact) })
}
@@ -140,14 +119,13 @@ async function artifactDistribution(
name: string,
distribution: string,
current: string | undefined,
cutoff: number,
manifest?: string,
) {
const result = await db
.prepare(`${select} WHERE channel = ? AND name = ? AND distribution = ? AND (active = 1 OR minimum = 1)`)
.bind(channel, name, distribution)
.all<ArtifactRow>()
const artifact = (await selectArtifacts(db, result.results, current, cutoff))[0]
const artifact = selectArtifacts(result.results, current)[0]
if (!artifact) return json({ error: "Artifact not found" }, 404)
if (manifest) {
const metadata = decodeMetadata(artifact.metadata)
@@ -163,57 +141,20 @@ async function artifactDistribution(
return updateResponse(decodeArtifact(artifact))
}
async function selectArtifacts(db: D1Database, rows: ArtifactRow[], current: string | undefined, cutoff: number) {
function selectArtifacts(rows: ArtifactRow[], current: string | undefined) {
const caller = current === undefined ? undefined : releaseVersion(current)
const artifacts = await Promise.all(
rows
.filter((row) => row.active === 1)
.map(async (active) => {
const minimum = rows.find(
(row) => row.minimum === 1 && row.name === active.name && row.distribution === active.distribution,
)
const floor = minimum && releaseVersion(minimum.version)
if (current !== undefined && minimum && (!floor || !caller || semver.lt(caller, floor))) return minimum
if (active.time_created <= cutoff) return active
const previous = await db
.prepare(
`${select} WHERE channel = ? AND name = ? AND distribution = ? AND time_created < ? AND time_created <= ? ORDER BY time_created DESC, version DESC LIMIT 1`,
)
.bind(active.channel, active.name, active.distribution, active.time_created, cutoff)
.first<ArtifactRow>()
// A rollout must not send an identified client back below its compatibility floor.
if (current !== undefined && minimum) {
const version = previous && releaseVersion(previous.version)
if (!version || !floor || semver.lt(version, floor)) return minimum
}
return previous
}),
)
return artifacts.filter((artifact) => artifact !== null)
}
async function configureRollout(request: Request, env: Env, prefix: string) {
const invalid = validMutation(request)
if (invalid) return invalid
const form = await request.formData()
const channel = form.get("channel")
const input = form.get("duration_hours")
const duration = typeof input === "string" && input.trim() ? Number(input) : NaN
if (
!validIdentifier(channel) ||
channel === "next" ||
!Number.isFinite(duration) ||
duration < 0 ||
!Number.isFinite(duration * 3_600_000)
) {
return json({ error: "Channel and a non-negative rollout duration in hours are required" }, 400)
}
await env.DB.prepare(
"INSERT INTO channel_rollout (channel, duration_hours) VALUES (?, ?) ON CONFLICT (channel) DO UPDATE SET duration_hours = excluded.duration_hours",
)
.bind(channel, duration)
.run()
return Response.redirect(new URL(`${prefix}/admin`, request.url), 303)
return rows
.filter((row) => row.active === 1)
.map((active) => {
if (current === undefined) return active
const minimum = rows.find(
(row) => row.minimum === 1 && row.name === active.name && row.distribution === active.distribution,
)
if (!minimum) return active
const floor = releaseVersion(minimum.version)
if (!floor) return minimum
return !caller || semver.lt(caller, floor) ? minimum : active
})
}
function releaseVersion(input: string) {
@@ -229,12 +170,6 @@ function releaseVersion(input: string) {
async function admin(request: Request, env: Env, prefix: string) {
const url = new URL(request.url)
const rollouts = await env.DB.prepare(
`SELECT channels.channel, COALESCE(channel_rollout.duration_hours, 0) AS duration_hours
FROM (SELECT channel FROM artifact UNION SELECT channel FROM channel_rollout) AS channels
LEFT JOIN channel_rollout ON channel_rollout.channel = channels.channel
WHERE channels.channel != 'next' ORDER BY channels.channel`,
).all<{ channel: string; duration_hours: number }>()
const requestedPage = Number.parseInt(url.searchParams.get("page") ?? "1", 10)
const page = Number.isSafeInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1
const pageSize = 100
@@ -311,22 +246,6 @@ async function admin(request: Request, env: Env, prefix: string) {
<div><p>Release control</p><h1>Artifacts</h1></div>
<span class="badge" data-variant="outline">${escape(request.headers.get("Cf-Access-Authenticated-User-Email") ?? "Cloudflare Access pending")}</span>
</header>
<article class="card" style="margin-bottom: 2rem">
<header><h2>Channel rollouts</h2><p>Hours from publication to full availability. Zero is immediate. Changes apply immediately to existing releases.</p></header>
<section>
${
rollouts.results
.map(
(rollout) => `<form action="${prefix}/admin/rollout" method="post">
<input type="hidden" name="channel" value="${escape(rollout.channel)}">
<label>${escape(rollout.channel)} hours <input class="input" type="number" name="duration_hours" min="0" step="any" required value="${rollout.duration_hours}"></label>
<button class="btn" type="submit">Save</button>
</form>`,
)
.join("") || "<p>Publish a release to configure its channel.</p>"
}
</section>
</article>
<article class="card">
<header><h2>Published builds</h2><p>Every build received from the trusted publishing workflow, newest first.</p></header>
<section class="table-wrap">
@@ -456,7 +375,6 @@ function parseArtifact(input: Record<string, unknown>): ArtifactInput | Response
function parseKey(input: Record<string, unknown>): Omit<ArtifactInput, "metadata"> | Response {
if (
!validIdentifier(input.channel) ||
input.channel === "next" ||
!validIdentifier(input.name) ||
!validIdentifier(input.distribution) ||
!validVersion(input.version)
@@ -555,7 +473,7 @@ function updateResponse(value: unknown) {
}
function json(value: unknown, status = 200, headers?: HeadersInit) {
return Response.json(value, { status, headers: { "Cache-Control": "no-store", ...headers } })
return Response.json(value, { status, headers })
}
function escape(value: string) {
+8 -1
View File
@@ -1621,7 +1621,14 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
"anyOf": [
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
}
}
}
+8 -1
View File
@@ -1621,7 +1621,14 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
"anyOf": [
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
}
}
}
@@ -1025,12 +1025,8 @@ force confirmation without depending on Core or Git errors.
#### Reference
Implementations receive a suggested destination after naming and collision handling. Return the actual directory from
`create`; it may differ when a backend requires its own layout. OpenCode resolves the returned path and uses it for
inventory, startup commands, and the API result. The returned directory must exist.
Strategies choosing another destination handle naming collisions there. OpenCode still creates the suggested parent
directory before calling the strategy. `list` must report only directories the strategy owns, plus any repository roots.
Implementations receive the final destination after naming and collision handling. Return that directory from `create`;
`list` must report only directories the strategy owns, plus any repository roots. Core owns inventory and startup commands.
```ts
interface WorktreeDefinition {
+1 -14
View File
@@ -473,20 +473,7 @@ Set the parent directory for new local worktrees. OpenCode appends the requested
}
```
Relative paths resolve against the project's primary checkout, including when called from a subdirectory or linked
worktree. This applies to global and project configuration alike; absolute paths are used as-is, and `~/` resolves
against the user's home directory.
For example, this global configuration places new worktrees under each project's own `.lane/trees/` directory:
```jsonc
{
"worktree": {
"directory": ".lane/trees",
},
}
```
Relative paths resolve against the config file that declares them; `~/` resolves against the user's home directory.
Without this setting, creation uses the server's data directory under `worktree/<first-six-project-ID-characters>`.
Configuration applies to the caller's location, not every clone sharing a project ID. Changing it does not move existing worktrees.