Compare commits

...

20 Commits

Author SHA1 Message Date
Frank
1b86c27fb8 wip: zen 2026-03-17 23:31:14 -04:00
Frank
fe43bdb699 wip: zen 2026-03-17 22:50:54 -04:00
Ryan Vogel
a849a17e93 feat(enterprise): contact form now pushes to salesforce 🙄 (#17964)
Co-authored-by: slickstef11 <stefan@wundergraph.com>
Co-authored-by: Frank <frank@anoma.ly>
2026-03-17 22:43:43 -04:00
opencode-agent[bot]
0292f1b559 chore: generate 2026-03-18 02:01:02 +00:00
Kit Langton
5dfe86dcb1 refactor(truncation): effectify TruncateService, delete Scheduler (#17957) 2026-03-17 21:59:54 -04:00
Ariane Emory
4b4dd2b882 fix: Add apply_patch to EDIT_TOOLS filter (#18009) 2026-03-17 20:11:42 -05:00
opencode-agent[bot]
bc949af623 chore: generate 2026-03-18 01:05:16 +00:00
Kit Langton
9e7c136de7 refactor(snapshot): effectify SnapshotService (#17878) 2026-03-17 21:04:16 -04:00
Kit Langton
fee3c196c5 add prompt schema validation debug logs (#17812) 2026-03-17 19:18:16 -04:00
Frank
6c047391bb wip: zen 2026-03-17 19:06:22 -04:00
Frank
350df0b261 zen: add missing model lab names 2026-03-17 18:41:38 -04:00
Frank
fbabc97c4c zen: error logging 2026-03-17 16:53:10 -04:00
David Hill
7daea69e13 tweak(ui): add an empty state to the sidebar when no projects (#17971)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-17 19:54:14 +00:00
Frank
0772a95918 wip: zen 2026-03-17 15:00:44 -04:00
Frank
dadddc9c8c zen: deprecate gemini 3 pro 2026-03-17 12:31:54 -04:00
OpeOginni
6708c3f6cf docs: mark tools config as deprecated (#17951) 2026-03-17 10:07:35 -05:00
Shoubhit Dash
ba22976568 fix: inline review comment submit and layout (#17948) 2026-03-17 19:54:20 +05:30
Brendan Allan
0afeaea21f app: inherit owner when creating prompt session 2026-03-17 19:47:06 +08:00
opencode-agent[bot]
b07b5a9b7f chore: generate 2026-03-17 11:15:35 +00:00
Luke Parker
dbbe931a18 fix(app): avoid prompt tooltip Switch on startup (#17857) 2026-03-17 06:14:30 -05:00
95 changed files with 1744 additions and 1267 deletions

View File

@@ -201,6 +201,10 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
handler: "packages/console/function/src/log-processor.ts",
link: [new sst.Secret("HONEYCOMB_API_KEY")],
@@ -219,6 +223,9 @@ new sst.cloudflare.x.SolidStart("Console", {
EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY,
SALESFORCE_CLIENT_ID,
SALESFORCE_CLIENT_SECRET,
SALESFORCE_INSTANCE_URL,
ZEN_BLACK_PRICE,
ZEN_LITE_PRICE,
new sst.Secret("ZEN_LIMITS"),

View File

@@ -9,6 +9,7 @@
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"typecheck": "bun turbo typecheck",
"prepare": "husky",

View File

@@ -3,8 +3,11 @@ import { serverNamePattern } from "../utils"
test("home renders and shows core entrypoints", async ({ page }) => {
await page.goto("/")
const nav = page.locator('[data-component="sidebar-nav-desktop"]')
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
await expect(nav.getByText("No projects open")).toBeVisible()
await expect(nav.getByText("Open a project to get started")).toBeVisible()
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
})

View File

@@ -123,6 +123,101 @@ async function spot(page: Parameters<typeof test>[0]["page"], file: string) {
}, file)
}
async function comment(page: Parameters<typeof test>[0]["page"], file: string, note: string) {
const row = page.locator(`[data-file="${file}"]`).first()
await expect(row).toBeVisible()
const line = row.locator('diffs-container [data-line="2"]').first()
await expect(line).toBeVisible()
await line.hover()
const add = row.getByRole("button", { name: /^Comment$/ }).first()
await expect(add).toBeVisible()
await add.click()
const area = row.locator('[data-slot="line-comment-textarea"]').first()
await expect(area).toBeVisible()
await area.fill(note)
const submit = row.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
await expect(submit).toBeEnabled()
await submit.click()
await expect(row.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
await expect(row.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
}
async function overflow(page: Parameters<typeof test>[0]["page"], file: string) {
const row = page.locator(`[data-file="${file}"]`).first()
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
const pop = row.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
const tools = row.locator('[data-slot="line-comment-tools"]').first()
const [width, viewBox, popBox, toolsBox] = await Promise.all([
view.evaluate((el) => el.scrollWidth - el.clientWidth),
view.boundingBox(),
pop.boundingBox(),
tools.boundingBox(),
])
if (!viewBox || !popBox || !toolsBox) return null
return {
width,
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
}
}
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
test.setTimeout(180_000)
const tag = `review-comment-${Date.now()}`
const file = `review-comment-${tag}.txt`
const note = `comment ${tag}`
await page.setViewportSize({ width: 1280, height: 900 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review comment ${tag}`, async (session) => {
await patch(sdk, session.id, seed([{ file, mark: tag }]))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(1)
await project.gotoSession(session.id)
await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible()
await tab.click()
await expand(page)
await waitMark(page, file, tag)
await comment(page, file, note)
await expect
.poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
})
})
})
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
test.setTimeout(180_000)

View File

@@ -1,6 +1,6 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
@@ -244,6 +244,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
},
)
const working = createMemo(() => status()?.type !== "idle")
const tip = () => {
if (working()) {
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
)
}
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
)
}
const imageAttachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
)
@@ -1362,26 +1379,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/>
<div class="flex items-center gap-1 pointer-events-auto">
<Tooltip
placement="top"
inactive={!prompt.dirty() && !working()}
value={
<Switch>
<Match when={working()}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
</Match>
<Match when={true}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
</Match>
</Switch>
}
>
<Tooltip placement="top" inactive={!prompt.dirty() && !working()} value={tip()}>
<IconButton
data-action="prompt-submit"
type="submit"

View File

@@ -77,6 +77,7 @@ export function Titlebar() {
const canBack = createMemo(() => history.index > 0)
const canForward = createMemo(() => history.index < history.stack.length - 1)
const hasProjects = createMemo(() => layout.projects.list().length > 0)
const back = () => {
const next = backPath(history)
@@ -251,36 +252,38 @@ export function Titlebar() {
</div>
</div>
</Show>
<div
class="flex items-center gap-0 transition-transform"
classList={{
"translate-x-0": !layout.sidebar.opened(),
"-translate-x-[36px]": layout.sidebar.opened(),
"duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(),
}}
>
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
</div>
<Show when={hasProjects()}>
<div
class="flex items-center gap-0 transition-transform"
classList={{
"translate-x-0": !layout.sidebar.opened(),
"-translate-x-[36px]": layout.sidebar.opened(),
"duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(),
}}
>
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
</div>
</Show>
</div>
</div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />

View File

@@ -1,10 +1,10 @@
import { createStore, type SetStoreFunction } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { checksum } from "@opencode-ai/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist"
import { checksum } from "@opencode-ai/util/encode"
interface PartBase {
content: string
@@ -250,6 +250,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
}
}
const owner = getOwner()
const load = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}`
const existing = cache.get(key)
@@ -259,10 +260,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
return existing.value
}
const entry = createRoot((dispose) => ({
value: createPromptSession(dir, id),
dispose,
}))
const entry = createRoot(
(dispose) => ({
value: createPromptSession(dir, id),
dispose,
}),
owner,
)
cache.set(key, entry)
prune()

View File

@@ -674,6 +674,8 @@ export const dict = {
"sidebar.project.recentSessions": "Recent sessions",
"sidebar.project.viewAllSessions": "View all sessions",
"sidebar.project.clearNotifications": "Clear notifications",
"sidebar.empty.title": "No projects open",
"sidebar.empty.description": "Open a project to get started",
"debugBar.ariaLabel": "Development performance diagnostics",
"debugBar.na": "n/a",

View File

@@ -1959,6 +1959,7 @@ export default function Layout(props: ParentProps) {
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened())
const empty = createMemo(() => !params.dir && layout.projects.list().length === 0)
const projectName = createMemo(() => {
const item = project()
if (!item) return ""
@@ -2011,7 +2012,26 @@ export default function Layout(props: ParentProps) {
width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`,
}}
>
<Show when={project()}>
<Show
when={project()}
fallback={
<Show when={empty()}>
<div class="flex-1 min-h-0 -mt-4 flex items-center justify-center px-6 pb-64 text-center">
<div class="mt-8 flex max-w-60 flex-col items-center gap-6 text-center">
<div class="flex flex-col gap-3">
<div class="text-14-medium text-text-strong">{language.t("sidebar.empty.title")}</div>
<div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
{language.t("sidebar.empty.description")}
</div>
</div>
<Button size="large" icon="folder-add-left" onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
</div>
</Show>
}
>
<>
<div class="shrink-0 pl-1 py-1">
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
@@ -2260,13 +2280,7 @@ export default function Layout(props: ParentProps) {
helpLabel={() => language.t("sidebar.help")}
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
renderPanel={() =>
mobile ? (
<SidebarPanel project={currentProject} mobile />
) : (
<Show when={currentProject()}>
<SidebarPanel project={currentProject} merged />
</Show>
)
mobile ? <SidebarPanel project={currentProject} mobile /> : <SidebarPanel project={currentProject} merged />
}
/>
)

View File

@@ -191,6 +191,33 @@ export function IconGemini(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
)
}
export function IconXiaomi(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C8.016 0 4.756.255 2.493 2.516.23 4.776 0 8.033 0 12.012c0 3.98.23 7.235 2.494 9.497C4.757 23.77 8.017 24 12 24c3.983 0 7.243-.23 9.506-2.491C23.77 19.247 24 15.99 24 12.012c0-3.984-.233-7.243-2.502-9.504C19.234.252 15.978 0 12 0zM4.906 7.405h5.624c1.47 0 3.007.068 3.764.827.746.746.827 2.233.83 3.676v4.54a.15.15 0 0 1-.152.147h-1.947a.15.15 0 0 1-.152-.148V11.83c-.002-.806-.048-1.634-.464-2.051-.358-.36-1.026-.441-1.72-.458H7.158a.15.15 0 0 0-.151.147v6.98a.15.15 0 0 1-.152.148H4.906a.15.15 0 0 1-.15-.148V7.554a.15.15 0 0 1 .15-.149zm12.131 0h1.949a.15.15 0 0 1 .15.15v8.892a.15.15 0 0 1-.15.148h-1.949a.15.15 0 0 1-.151-.148V7.554a.15.15 0 0 1 .151-.149zM8.92 10.948h2.046c.083 0 .15.066.15.147v5.352a.15.15 0 0 1-.15.148H8.92a.15.15 0 0 1-.152-.148v-5.352a.15.15 0 0 1 .152-.147Z" />
</svg>
)
}
export function IconNvidia(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M8.948 8.798v-1.43a6.7 6.7 0 0 1 .424-.018c3.922-.124 6.493 3.374 6.493 3.374s-2.774 3.851-5.75 3.851c-.398 0-.787-.062-1.158-.185v-4.346c1.528.185 1.837.857 2.747 2.385l2.04-1.714s-1.492-1.952-4-1.952a6.016 6.016 0 0 0-.796.035m0-4.735v2.138l.424-.027c5.45-.185 9.01 4.47 9.01 4.47s-4.08 4.964-8.33 4.964c-.37 0-.733-.035-1.095-.097v1.325c.3.035.61.062.91.062 3.957 0 6.82-2.023 9.593-4.408.459.371 2.34 1.263 2.73 1.652-2.633 2.208-8.772 3.984-12.253 3.984-.335 0-.653-.018-.971-.053v1.864H24V4.063zm0 10.326v1.131c-3.657-.654-4.673-4.46-4.673-4.46s1.758-1.944 4.673-2.262v1.237H8.94c-1.528-.186-2.73 1.245-2.73 1.245s.68 2.412 2.739 3.11M2.456 10.9s2.164-3.197 6.5-3.533V6.201C4.153 6.59 0 10.653 0 10.653s2.35 6.802 8.948 7.42v-1.237c-4.84-.6-6.492-5.936-6.492-5.936z" />
</svg>
)
}
export function IconArcee(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 37 32" fill="currentColor">
<path d="M20.4062 3.66602L4.24121 31.5928H0L18.2881 0L20.4062 3.66602Z" />
<path d="M25.8838 13.1553L11.0752 31.5928H6.36719L23.9131 9.74512L25.8838 13.1553Z" />
<path d="M36.5352 31.5928H21.6611L34.6191 28.2783L36.5352 31.5928Z" />
<path d="M31.2627 22.4648L19.1699 31.5898H13.0762L29.4131 19.2617L31.2627 22.4648Z" />
</svg>
)
}
export function IconStealth(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 18" fill="none">

View File

@@ -688,8 +688,12 @@ export const dict = {
"enterprise.form.name.placeholder": "جيف بيزوس",
"enterprise.form.role.label": "المنصب",
"enterprise.form.role.placeholder": "رئيس مجلس الإدارة التنفيذي",
"enterprise.form.company.label": "الشركة",
"enterprise.form.company.placeholder": "Acme Inc",
"enterprise.form.email.label": "البريد الإلكتروني للشركة",
"enterprise.form.email.placeholder": "jeff@amazon.com",
"enterprise.form.phone.label": "رقم الهاتف",
"enterprise.form.phone.placeholder": "+1 234 567 8900",
"enterprise.form.message.label": "ما المشكلة التي تحاول حلها؟",
"enterprise.form.message.placeholder": "نحتاج مساعدة في...",
"enterprise.form.send": "إرسال",

View File

@@ -700,8 +700,12 @@ export const dict = {
"enterprise.form.name.placeholder": "Jeff Bezos",
"enterprise.form.role.label": "Cargo",
"enterprise.form.role.placeholder": "Presidente Executivo",
"enterprise.form.company.label": "Empresa",
"enterprise.form.company.placeholder": "Acme Inc",
"enterprise.form.email.label": "E-mail corporativo",
"enterprise.form.email.placeholder": "jeff@amazon.com",
"enterprise.form.phone.label": "Telefone",
"enterprise.form.phone.placeholder": "+1 234 567 8900",
"enterprise.form.message.label": "Qual problema você está tentando resolver?",
"enterprise.form.message.placeholder": "Precisamos de ajuda com...",
"enterprise.form.send": "Enviar",

View File

@@ -694,8 +694,12 @@ export const dict = {
"enterprise.form.name.placeholder": "Jeff Bezos",
"enterprise.form.role.label": "Rolle",
"enterprise.form.role.placeholder": "Bestyrelsesformand",
"enterprise.form.company.label": "Virksomhed",
"enterprise.form.company.placeholder": "Acme Inc",
"enterprise.form.email.label": "Firma-e-mail",
"enterprise.form.email.placeholder": "jeff@amazon.com",
"enterprise.form.phone.label": "Telefonnummer",
"enterprise.form.phone.placeholder": "+1 234 567 8900",
"enterprise.form.message.label": "Hvilket problem prøver du at løse?",
"enterprise.form.message.placeholder": "Vi har brug for hjælp med...",
"enterprise.form.send": "Send",

View File

@@ -699,8 +699,12 @@ export const dict = {
"enterprise.form.name.placeholder": "Jeff Bezos",
"enterprise.form.role.label": "Rolle",
"enterprise.form.role.placeholder": "Executive Chairman",
"enterprise.form.company.label": "Unternehmen",
"enterprise.form.company.placeholder": "Acme Inc",
"enterprise.form.email.label": "Firmen-E-Mail",
"enterprise.form.email.placeholder": "jeff@amazon.com",
"enterprise.form.phone.label": "Telefonnummer",
"enterprise.form.phone.placeholder": "+1 234 567 8900",
"enterprise.form.message.label": "Welches Problem versuchen Sie zu lösen?",
"enterprise.form.message.placeholder": "Wir brauchen Hilfe bei...",
"enterprise.form.send": "Senden",

View File

@@ -689,8 +689,12 @@ export const dict = {
"enterprise.form.name.placeholder": "Jeff Bezos",
"enterprise.form.role.label": "Role",
"enterprise.form.role.placeholder": "Executive Chairman",
"enterprise.form.company.label": "Company",
"enterprise.form.company.placeholder": "Acme Inc",
"enterprise.form.email.label": "Company email",
"enterprise.form.email.placeholder": "jeff@amazon.com",
"enterprise.form.phone.label": "Phone number",
"enterprise.form.phone.placeholder": "+1 234 567 8900",
"enterprise.form.message.label": "What problem are you trying to solve?",
"enterprise.form.message.placeholder": "We need help with...",
"enterprise.form.send": "Send",

View File

@@ -699,8 +699,12 @@ export const dict = {
"enterprise.form.name.placeholder": "Jeff Bezos",
"enterprise.form.role.label": "Rol",
"enterprise.form.role.placeholder": "Presidente Ejecutivo",
"enterprise.form.company.label": "Empresa",
"enterprise.form.company.placeholder": "Acme Inc",
"enterprise.form.email.label": "Correo de empresa",
"enterprise.form.email.placeholder": "jeff@amazon.com",
"enterprise.form.phone.label": "Teléfono",
"enterprise.form.phone.placeholder": "+1 234 567 8900",
"enterprise.form.message.label": "¿Qué problema estás intentando resolver?",
"enterprise.form.message.placeholder": "Necesitamos ayuda con...",
"enterprise.form.send": "Enviar",

View File

@@ -706,8 +706,12 @@ export const dict = {
"enterprise.form.name.placeholder": "Jeff Bezos",
"enterprise.form.role.label": "Poste",
"enterprise.form.role.placeholder": "Président exécutif",
"enterprise.form.company.label": "Entreprise",
"enterprise.form.company.placeholder": "Acme Inc",
"enterprise.form.email.label": "E-mail professionnel",
"enterprise.form.email.placeholder": "jeff@amazon.com",
"enterprise.form.phone.label": "Téléphone",
"enterprise.form.phone.placeholder": "+1 234 567 8900",
"enterprise.form.message.label": "Quel problème essayez-vous de résoudre ?",
"enterprise.form.message.placeholder": "Nous avons besoin d'aide pour...",
"enterprise.form.send": "Envoyer",

View File

@@ -696,8 +696,12 @@ export const dict = {
"enterprise.form.name.placeholder": "Jeff Bezos",
"enterprise.form.role.label": "Ruolo",
"enterprise.form.role.placeholder": "Presidente Esecutivo",
"enterprise.form.company.label": "Azienda",
"enterprise.form.company.placeholder": "Acme Inc",
"enterprise.form.email.label": "Email aziendale",
"enterprise.form.email.placeholder": "jeff@amazon.com",
"enterprise.form.phone.label": "Numero di telefono",
"enterprise.form.phone.placeholder": "+1 234 567 8900",
"enterprise.form.message.label": "Quale problema stai cercando di risolvere?",
"enterprise.form.message.placeholder": "Abbiamo bisogno di aiuto con...",
"enterprise.form.send": "Invia",

View File

@@ -697,8 +697,12 @@ export const dict = {
"enterprise.form.name.placeholder": "ジェフ・ベゾス",
"enterprise.form.role.label": "役職",
"enterprise.form.role.placeholder": "会長",
"enterprise.form.company.label": "会社名",
"enterprise.form.company.placeholder": "Acme Inc",
"enterprise.form.email.label": "会社メールアドレス",
"enterprise.form.email.placeholder": "jeff@amazon.com",
"enterprise.form.phone.label": "電話番号",
"enterprise.form.phone.placeholder": "+1 234 567 8900",
"enterprise.form.message.label": "どのような課題を解決したいですか?",
"enterprise.form.message.placeholder": "これについて支援が必要です...",
"enterprise.form.send": "送信",

View File

@@ -688,8 +688,12 @@ export const dict = {
"enterprise.form.name.placeholder": "홍길동",
"enterprise.form.role.label": "직책",
"enterprise.form.role.placeholder": "CTO / 개발 팀장",
"enterprise.form.company.label": "회사",
"enterprise.form.company.placeholder": "Acme Inc",
"enterprise.form.email.label": "회사 이메일",
"enterprise.form.email.placeholder": "name@company.com",
"enterprise.form.phone.label": "전화번호",
"enterprise.form.phone.placeholder": "+1 234 567 8900",
"enterprise.form.message.label": "어떤 문제를 해결하고 싶으신가요?",
"enterprise.form.message.placeholder": "도움이 필요한 부분은...",
"enterprise.form.send": "전송",

View File

@@ -695,8 +695,12 @@ export const dict = {
"enterprise.form.name.placeholder": "Jeff Bezos",
"enterprise.form.role.label": "Rolle",
"enterprise.form.role.placeholder": "Styreleder",
"enterprise.form.company.label": "Selskap",
"enterprise.form.company.placeholder": "Acme Inc",
"enterprise.form.email.label": "Bedrifts-e-post",
"enterprise.form.email.placeholder": "jeff@amazon.com",
"enterprise.form.phone.label": "Telefonnummer",
"enterprise.form.phone.placeholder": "+1 234 567 8900",
"enterprise.form.message.label": "Hvilket problem prøver dere å løse?",
"enterprise.form.message.placeholder": "Vi trenger hjelp med...",
"enterprise.form.send": "Send",

View File

@@ -698,8 +698,12 @@ export const dict = {
"enterprise.form.name.placeholder": "Jeff Bezos",
"enterprise.form.role.label": "Rola",
"enterprise.form.role.placeholder": "Prezes Zarządu",
"enterprise.form.company.label": "Firma",
"enterprise.form.company.placeholder": "Acme Inc",
"enterprise.form.email.label": "E-mail firmowy",
"enterprise.form.email.placeholder": "jeff@amazon.com",
"enterprise.form.phone.label": "Numer telefonu",
"enterprise.form.phone.placeholder": "+1 234 567 8900",
"enterprise.form.message.label": "Jaki problem próbujesz rozwiązać?",
"enterprise.form.message.placeholder": "Potrzebujemy pomocy z...",
"enterprise.form.send": "Wyślij",

View File

@@ -703,8 +703,12 @@ export const dict = {
"enterprise.form.name.placeholder": "Джефф Безос",
"enterprise.form.role.label": "Роль",
"enterprise.form.role.placeholder": "Исполнительный председатель",
"enterprise.form.company.label": "Компания",
"enterprise.form.company.placeholder": "Acme Inc",
"enterprise.form.email.label": "Корпоративная почта",
"enterprise.form.email.placeholder": "jeff@amazon.com",
"enterprise.form.phone.label": "Номер телефона",
"enterprise.form.phone.placeholder": "+1 234 567 8900",
"enterprise.form.message.label": "Какую проблему вы пытаетесь решить?",
"enterprise.form.message.placeholder": "Нам нужна помощь с...",
"enterprise.form.send": "Отправить",

View File

@@ -691,8 +691,12 @@ export const dict = {
"enterprise.form.name.placeholder": "Jeff Bezos",
"enterprise.form.role.label": "ตำแหน่ง",
"enterprise.form.role.placeholder": "ประธานกรรมการบริหาร",
"enterprise.form.company.label": "บริษัท",
"enterprise.form.company.placeholder": "Acme Inc",
"enterprise.form.email.label": "อีเมลบริษัท",
"enterprise.form.email.placeholder": "jeff@amazon.com",
"enterprise.form.phone.label": "หมายเลขโทรศัพท์",
"enterprise.form.phone.placeholder": "+1 234 567 8900",
"enterprise.form.message.label": "คุณกำลังพยายามแก้ปัญหาอะไร?",
"enterprise.form.message.placeholder": "เราต้องการความช่วยเหลือเรื่อง...",
"enterprise.form.send": "ส่ง",

View File

@@ -700,8 +700,12 @@ export const dict = {
"enterprise.form.name.placeholder": "Jeff Bezos",
"enterprise.form.role.label": "Rol",
"enterprise.form.role.placeholder": "Yönetim Kurulu Başkanı",
"enterprise.form.company.label": "Şirket",
"enterprise.form.company.placeholder": "Acme Inc",
"enterprise.form.email.label": "Şirket e-postası",
"enterprise.form.email.placeholder": "jeff@amazon.com",
"enterprise.form.phone.label": "Telefon numarası",
"enterprise.form.phone.placeholder": "+1 234 567 8900",
"enterprise.form.message.label": "Hangi problemi çözmeye çalışıyorsunuz?",
"enterprise.form.message.placeholder": "Şu konuda yardıma ihtiyacımız var...",
"enterprise.form.send": "Gönder",

View File

@@ -669,8 +669,12 @@ export const dict = {
"enterprise.form.name.placeholder": "Jeff Bezos",
"enterprise.form.role.label": "角色",
"enterprise.form.role.placeholder": "执行主席",
"enterprise.form.company.label": "公司",
"enterprise.form.company.placeholder": "Acme Inc",
"enterprise.form.email.label": "公司邮箱",
"enterprise.form.email.placeholder": "jeff@amazon.com",
"enterprise.form.phone.label": "电话号码",
"enterprise.form.phone.placeholder": "+1 234 567 8900",
"enterprise.form.message.label": "您想解决什么问题?",
"enterprise.form.message.placeholder": "我们需要帮助...",
"enterprise.form.send": "发送",

View File

@@ -668,8 +668,12 @@ export const dict = {
"enterprise.form.name.placeholder": "傑夫·貝佐斯",
"enterprise.form.role.label": "職稱",
"enterprise.form.role.placeholder": "執行董事長",
"enterprise.form.company.label": "公司",
"enterprise.form.company.placeholder": "Acme Inc",
"enterprise.form.email.label": "公司 Email",
"enterprise.form.email.placeholder": "jeff@amazon.com",
"enterprise.form.phone.label": "電話號碼",
"enterprise.form.phone.placeholder": "+1 234 567 8900",
"enterprise.form.message.label": "你想解決什麼問題?",
"enterprise.form.message.placeholder": "我們需要幫助來...",
"enterprise.form.send": "傳送",

View File

@@ -0,0 +1,81 @@
import { Resource } from "@opencode-ai/console-resource"
async function login() {
const url = Resource.SALESFORCE_INSTANCE_URL.value.replace(/\/$/, "")
const clientId = Resource.SALESFORCE_CLIENT_ID.value
const clientSecret = Resource.SALESFORCE_CLIENT_SECRET.value
const params = new URLSearchParams({
grant_type: "client_credentials",
client_id: clientId,
client_secret: clientSecret,
})
const res = await fetch(`${url}/services/oauth2/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
}).catch((err) => {
console.error("Failed to fetch Salesforce access token:", err)
})
if (!res) return
if (!res.ok) {
console.error("Failed to fetch Salesforce access token:", res.status, await res.text())
return
}
const data = (await res.json()) as { access_token?: string; instance_url?: string }
if (!data.access_token) {
console.error("Salesforce auth response did not include an access token")
return
}
return {
token: data.access_token,
url: data.instance_url ?? url,
}
}
export interface SalesforceLeadInput {
name: string
role: string
company?: string
email: string
phone?: string
message: string
}
export async function createLead(input: SalesforceLeadInput): Promise<boolean> {
const auth = await login()
if (!auth) return false
const res = await fetch(`${auth.url}/services/data/v59.0/sobjects/Lead`, {
method: "POST",
headers: {
Authorization: `Bearer ${auth.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
LastName: input.name,
Company: input.company?.trim() || "Website",
Email: input.email,
Phone: input.phone ?? null,
Title: input.role,
Description: input.message,
LeadSource: "Website",
}),
}).catch((err) => {
console.error("Failed to create Salesforce lead:", err)
})
if (!res) return false
if (!res.ok) {
console.error("Failed to create Salesforce lead:", res.status, await res.text())
return false
}
return true
}

View File

@@ -2,11 +2,15 @@ import type { APIEvent } from "@solidjs/start/server"
import { AWS } from "@opencode-ai/console-core/aws.js"
import { i18n } from "~/i18n"
import { localeFromRequest } from "~/lib/language"
import { createLead } from "~/lib/salesforce"
interface EnterpriseFormData {
name: string
role: string
company?: string
email: string
phone?: string
alias?: string
message: string
}
@@ -14,33 +18,56 @@ export async function POST(event: APIEvent) {
const dict = i18n(localeFromRequest(event.request))
try {
const body = (await event.request.json()) as EnterpriseFormData
const trap = typeof body.alias === "string" ? body.alias.trim() : ""
if (trap) {
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
}
// Validate required fields
if (!body.name || !body.role || !body.email || !body.message) {
return Response.json({ error: dict["enterprise.form.error.allFieldsRequired"] }, { status: 400 })
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(body.email)) {
return Response.json({ error: dict["enterprise.form.error.invalidEmailFormat"] }, { status: 400 })
}
// Create email content
const emailContent = `
${body.message}<br><br>
--<br>
${body.name}<br>
${body.role}<br>
${body.email}`.trim()
${body.company ? `${body.company}<br>` : ""}${body.email}<br>
${body.phone ? `${body.phone}<br>` : ""}`.trim()
// Send email using AWS SES
await AWS.sendEmail({
to: "contact@anoma.ly",
subject: `Enterprise Inquiry from ${body.name}`,
body: emailContent,
replyTo: body.email,
})
const [lead, mail] = await Promise.all([
createLead({
name: body.name,
role: body.role,
company: body.company,
email: body.email,
phone: body.phone,
message: body.message,
}),
AWS.sendEmail({
to: "contact@anoma.ly",
subject: `Enterprise Inquiry from ${body.name}`,
body: emailContent,
replyTo: body.email,
}).then(
() => true,
(err) => {
console.error("Failed to send enterprise email:", err)
return false
},
),
])
if (!lead && !mail) {
console.error("Enterprise inquiry delivery failed", { email: body.email })
return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
}
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
} catch (error) {

View File

@@ -23,6 +23,7 @@
--color-text-strong: hsl(0, 5%, 12%);
--color-text-inverted: hsl(0, 20%, 99%);
--color-text-success: hsl(119, 100%, 35%);
--color-text-error: hsl(4, 72%, 45%);
--color-border: hsl(30, 2%, 81%);
--color-border-weak: hsl(0, 1%, 85%);
@@ -50,6 +51,7 @@
--color-text-strong: hsl(0, 15%, 94%);
--color-text-inverted: hsl(0, 9%, 7%);
--color-text-success: hsl(119, 60%, 72%);
--color-text-error: hsl(4, 76%, 72%);
--color-border: hsl(0, 3%, 28%);
--color-border-weak: hsl(0, 4%, 23%);
@@ -454,6 +456,13 @@
color: var(--color-text-success);
text-align: left;
}
[data-component="error-message"] {
margin-top: 1rem;
padding: 1rem 0;
color: var(--color-text-error);
text-align: left;
}
}
}

View File

@@ -13,11 +13,15 @@ export default function Enterprise() {
const [formData, setFormData] = createSignal({
name: "",
role: "",
company: "",
email: "",
phone: "",
alias: "",
message: "",
})
const [isSubmitting, setIsSubmitting] = createSignal(false)
const [showSuccess, setShowSuccess] = createSignal(false)
const [error, setError] = createSignal("")
const handleInputChange = (field: string) => (e: Event) => {
const target = e.target as HTMLInputElement | HTMLTextAreaElement
@@ -26,6 +30,8 @@ export default function Enterprise() {
const handleSubmit = async (e: Event) => {
e.preventDefault()
setError("")
setShowSuccess(false)
setIsSubmitting(true)
try {
@@ -42,13 +48,21 @@ export default function Enterprise() {
setFormData({
name: "",
role: "",
company: "",
email: "",
phone: "",
alias: "",
message: "",
})
setTimeout(() => setShowSuccess(false), 5000)
return
}
const data = (await response.json().catch(() => null)) as { error?: string } | null
setError(data?.error ?? i18n.t("enterprise.form.error.internalServer"))
} catch (error) {
console.error("Failed to submit form:", error)
setError(i18n.t("enterprise.form.error.internalServer"))
} finally {
setIsSubmitting(false)
}
@@ -147,6 +161,19 @@ export default function Enterprise() {
<div data-component="enterprise-column-2">
<div data-component="enterprise-form">
<form onSubmit={handleSubmit}>
<div class="sr-only" aria-hidden="true">
<input
type="text"
name="alias"
tabIndex={-1}
autocomplete="new-password"
inputmode="none"
spellcheck={false}
value={formData().alias}
onInput={handleInputChange("alias")}
/>
</div>
<div data-component="form-group">
<label for="name">{i18n.t("enterprise.form.name.label")}</label>
<input
@@ -171,6 +198,17 @@ export default function Enterprise() {
/>
</div>
<div data-component="form-group">
<label for="company">{i18n.t("enterprise.form.company.label")}</label>
<input
id="company"
type="text"
value={formData().company}
onInput={handleInputChange("company")}
placeholder={i18n.t("enterprise.form.company.placeholder")}
/>
</div>
<div data-component="form-group">
<label for="email">{i18n.t("enterprise.form.email.label")}</label>
<input
@@ -183,6 +221,17 @@ export default function Enterprise() {
/>
</div>
<div data-component="form-group">
<label for="phone">{i18n.t("enterprise.form.phone.label")}</label>
<input
id="phone"
type="tel"
value={formData().phone}
onInput={handleInputChange("phone")}
placeholder={i18n.t("enterprise.form.phone.placeholder")}
/>
</div>
<div data-component="form-group">
<label for="message">{i18n.t("enterprise.form.message.label")}</label>
<textarea
@@ -201,6 +250,7 @@ export default function Enterprise() {
</form>
{showSuccess() && <div data-component="success-message">{i18n.t("enterprise.form.success")}</div>}
{error() && <div data-component="error-message">{error()}</div>}
</div>
</div>
</div>

View File

@@ -8,12 +8,15 @@ import { querySessionInfo } from "../common"
import {
IconAlibaba,
IconAnthropic,
IconArcee,
IconGemini,
IconMiniMax,
IconMoonshotAI,
IconNvidia,
IconOpenAI,
IconStealth,
IconXai,
IconXiaomi,
IconZai,
} from "~/component/icon"
import { useI18n } from "~/context/i18n"
@@ -29,6 +32,9 @@ const getModelLab = (modelId: string) => {
if (modelId.startsWith("qwen")) return "Alibaba"
if (modelId.startsWith("minimax")) return "MiniMax"
if (modelId.startsWith("grok")) return "xAI"
if (modelId.startsWith("mimo")) return "Xiaomi"
if (modelId.startsWith("nemotron")) return "NVIDIA"
if (modelId.startsWith("trinity")) return "Arcee"
return "Stealth"
}
@@ -139,6 +145,12 @@ export function ModelSection() {
return <IconXai width={16} height={16} />
case "MiniMax":
return <IconMiniMax width={16} height={16} />
case "Xiaomi":
return <IconXiaomi width={16} height={16} />
case "NVIDIA":
return <IconNvidia width={16} height={16} />
case "Arcee":
return <IconArcee width={16} height={16} />
default:
return <IconStealth width={16} height={16} />
}

View File

@@ -74,8 +74,9 @@ export async function handler(
const dict = i18n(localeFromRequest(input.request))
const t = (key: Key, params?: Record<string, string | number>) => resolve(dict[key], params)
const ADMIN_WORKSPACES = [
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
"wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // anomaly
"wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // benchmark
"wrk_01KKZDKDWCS1VTJF8QTX62DD50", // contributors
]
try {
@@ -97,8 +98,8 @@ export async function handler(
const zenData = ZenData.list(opts.modelList)
const modelInfo = validateModel(zenData, model)
const dataDumper = createDataDumper(sessionId, requestId, projectId)
const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip)
const trialProvider = await trialLimiter?.check()
const trialLimiter = createTrialLimiter(modelInfo.trialProviders, ip)
const trialProviders = await trialLimiter?.check()
const rateLimiter = createRateLimiter(
modelInfo.id,
modelInfo.allowAnonymous,
@@ -120,7 +121,7 @@ export async function handler(
authInfo,
modelInfo,
sessionId,
trialProvider,
trialProviders,
retry,
stickyProvider,
)
@@ -330,6 +331,7 @@ export async function handler(
logger.metric({
"error.type": error.constructor.name,
"error.message": error.message,
"error.cause": error.cause?.toString(),
})
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
@@ -401,7 +403,7 @@ export async function handler(
authInfo: AuthInfo,
modelInfo: ModelInfo,
sessionId: string,
trialProvider: string | undefined,
trialProviders: string[] | undefined,
retry: RetryOptions,
stickyProvider: string | undefined,
) {
@@ -410,15 +412,17 @@ export async function handler(
return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider)
}
if (trialProvider) {
return modelInfo.providers.find((provider) => provider.id === trialProvider)
}
if (stickyProvider) {
const provider = modelInfo.providers.find((provider) => provider.id === stickyProvider)
if (provider) return provider
}
if (trialProviders) {
const trialProvider = trialProviders[Math.floor(Math.random() * trialProviders.length)]
const provider = modelInfo.providers.find((provider) => provider.id === trialProvider)
if (provider) return provider
}
if (retry.retryCount !== MAX_FAILOVER_RETRIES) {
const providers = modelInfo.providers
.filter((provider) => !provider.disabled)

View File

@@ -175,7 +175,8 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
outputTokens: usage.output_tokens ?? 0,
reasoningTokens: undefined,
cacheReadTokens: usage.cache_read_input_tokens ?? undefined,
cacheWrite5mTokens: usage.cache_creation?.ephemeral_5m_input_tokens ?? undefined,
cacheWrite5mTokens:
usage.cache_creation?.ephemeral_5m_input_tokens ?? usage.cache_creation_input_tokens ?? undefined,
cacheWrite1hTokens: usage.cache_creation?.ephemeral_1h_input_tokens ?? undefined,
}),
}

View File

@@ -3,8 +3,8 @@ import { IpTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { UsageInfo } from "./provider/provider"
import { Subscription } from "@opencode-ai/console-core/subscription.js"
export function createTrialLimiter(trialProvider: string | undefined, ip: string) {
if (!trialProvider) return
export function createTrialLimiter(trialProviders: string[] | undefined, ip: string) {
if (!trialProviders) return
if (!ip) return
const limit = Subscription.getFreeLimits().promoTokens
@@ -24,7 +24,7 @@ export function createTrialLimiter(trialProvider: string | undefined, ip: string
)
_isTrial = (data?.usage ?? 0) < limit
return _isTrial ? trialProvider : undefined
return _isTrial ? trialProviders : undefined
},
track: async (usageInfo: UsageInfo) => {
if (!_isTrial) return

View File

@@ -3,6 +3,7 @@ import { AuthTable } from "../src/schema/auth.sql.js"
import { UserTable } from "../src/schema/user.sql.js"
import { BillingTable, PaymentTable, SubscriptionTable, BlackPlans, UsageTable } from "../src/schema/billing.sql.js"
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
import { KeyTable } from "../src/schema/key.sql.js"
import { BlackData } from "../src/black.js"
import { centsToMicroCents } from "../src/util/price.js"
import { getWeekBounds } from "../src/util/date.js"
@@ -10,13 +11,46 @@ import { getWeekBounds } from "../src/util/date.js"
// get input from command line
const identifier = process.argv[2]
if (!identifier) {
console.error("Usage: bun lookup-user.ts <email|workspaceID>")
console.error("Usage: bun lookup-user.ts <email|workspaceID|apiKey>")
process.exit(1)
}
// loop up by workspace ID
if (identifier.startsWith("wrk_")) {
await printWorkspace(identifier)
} else {
}
// lookup by API key ID
else if (identifier.startsWith("key_")) {
const key = await Database.use((tx) =>
tx
.select()
.from(KeyTable)
.where(eq(KeyTable.id, identifier))
.then((rows) => rows[0]),
)
if (!key) {
console.error("API key not found")
process.exit(1)
}
await printWorkspace(key.workspaceID)
}
// lookup by API key value
else if (identifier.startsWith("sk-")) {
const key = await Database.use((tx) =>
tx
.select()
.from(KeyTable)
.where(eq(KeyTable.key, identifier))
.then((rows) => rows[0]),
)
if (!key) {
console.error("API key not found")
process.exit(1)
}
await printWorkspace(key.workspaceID)
}
// lookup by email
else {
const authData = await Database.use(async (tx) =>
tx.select().from(AuthTable).where(eq(AuthTable.subject, identifier)),
)

View File

@@ -26,7 +26,7 @@ export namespace ZenData {
allowAnonymous: z.boolean().optional(),
byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
stickyProvider: z.enum(["strict", "prefer"]).optional(),
trialProvider: z.string().optional(),
trialProviders: z.array(z.string()).optional(),
fallbackProvider: z.string().optional(),
rateLimit: z.number().optional(),
providers: z.array(

View File

@@ -95,6 +95,18 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_CLIENT_ID": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_CLIENT_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_INSTANCE_URL": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_PUBLISHABLE_KEY": {
"type": "sst.sst.Secret"
"value": string

View File

@@ -95,6 +95,18 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_CLIENT_ID": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_CLIENT_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_INSTANCE_URL": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_PUBLISHABLE_KEY": {
"type": "sst.sst.Secret"
"value": string

View File

@@ -95,6 +95,18 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_CLIENT_ID": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_CLIENT_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_INSTANCE_URL": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_PUBLISHABLE_KEY": {
"type": "sst.sst.Secret"
"value": string

View File

@@ -95,6 +95,18 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_CLIENT_ID": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_CLIENT_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_INSTANCE_URL": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_PUBLISHABLE_KEY": {
"type": "sst.sst.Secret"
"value": string

View File

@@ -95,6 +95,18 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_CLIENT_ID": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_CLIENT_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_INSTANCE_URL": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_PUBLISHABLE_KEY": {
"type": "sst.sst.Secret"
"value": string

View File

@@ -11,46 +11,52 @@ const seed = async () => {
const { Instance } = await import("../src/project/instance")
const { InstanceBootstrap } = await import("../src/project/bootstrap")
const { Config } = await import("../src/config/config")
const { disposeRuntime } = await import("../src/effect/runtime")
const { Session } = await import("../src/session")
const { MessageID, PartID } = await import("../src/session/schema")
const { Project } = await import("../src/project/project")
const { ModelID, ProviderID } = await import("../src/provider/schema")
const { ToolRegistry } = await import("../src/tool/registry")
await Instance.provide({
directory: dir,
init: InstanceBootstrap,
fn: async () => {
await Config.waitForDependencies()
await ToolRegistry.ids()
try {
await Instance.provide({
directory: dir,
init: InstanceBootstrap,
fn: async () => {
await Config.waitForDependencies()
await ToolRegistry.ids()
const session = await Session.create({ title })
const messageID = MessageID.ascending()
const partID = PartID.ascending()
const message = {
id: messageID,
sessionID: session.id,
role: "user" as const,
time: { created: now },
agent: "build",
model: {
providerID: ProviderID.make(providerID),
modelID: ModelID.make(modelID),
},
}
const part = {
id: partID,
sessionID: session.id,
messageID,
type: "text" as const,
text,
time: { start: now },
}
await Session.updateMessage(message)
await Session.updatePart(part)
await Project.update({ projectID: Instance.project.id, name: "E2E Project" })
},
})
const session = await Session.create({ title })
const messageID = MessageID.ascending()
const partID = PartID.ascending()
const message = {
id: messageID,
sessionID: session.id,
role: "user" as const,
time: { created: now },
agent: "build",
model: {
providerID: ProviderID.make(providerID),
modelID: ModelID.make(modelID),
},
}
const part = {
id: partID,
sessionID: session.id,
messageID,
type: "text" as const,
text,
time: { start: now },
}
await Session.updateMessage(message)
await Session.updatePart(part)
await Project.update({ projectID: Instance.project.id, name: "E2E Project" })
},
})
} finally {
await Instance.disposeAll().catch(() => {})
await disposeRuntime().catch(() => {})
}
}
await seed()

View File

@@ -5,7 +5,7 @@ import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai"
import { SystemPrompt } from "../session/system"
import { Instance } from "../project/instance"
import { Truncate } from "../tool/truncation"
import { Truncate } from "../tool/truncate"
import { Auth } from "../auth"
import { ProviderTransform } from "../provider/transform"
@@ -14,7 +14,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt"
import PROMPT_EXPLORE from "./prompt/explore.txt"
import PROMPT_SUMMARY from "./prompt/summary.txt"
import PROMPT_TITLE from "./prompt/title.txt"
import { PermissionNext } from "@/permission/next"
import { PermissionNext } from "@/permission"
import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@/global"
import path from "path"

View File

@@ -7,7 +7,7 @@ import type { MessageV2 } from "../../../session/message-v2"
import { MessageID, PartID } from "../../../session/schema"
import { ToolRegistry } from "../../../tool/registry"
import { Instance } from "../../../project/instance"
import { PermissionNext } from "../../../permission/next"
import { PermissionNext } from "../../../permission"
import { iife } from "../../../util/iife"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"

View File

@@ -11,7 +11,7 @@ import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart
import { Server } from "../../server/server"
import { Provider } from "../../provider/provider"
import { Agent } from "../../agent/agent"
import { PermissionNext } from "../../permission/next"
import { PermissionNext } from "../../permission"
import { Tool } from "../../tool/tool"
import { GlobTool } from "../../tool/glob"
import { GrepTool } from "../../tool/grep"

View File

@@ -4,6 +4,7 @@ import type { Project } from "@/project/project"
export declare namespace InstanceContext {
export interface Shape {
readonly directory: string
readonly worktree: string
readonly project: Project.Info
}
}

View File

@@ -1,22 +1,23 @@
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
import { registerDisposer } from "./instance-registry"
import { InstanceContext } from "./instance-context"
import { FileService } from "@/file"
import { FileTimeService } from "@/file/time"
import { FileWatcherService } from "@/file/watcher"
import { FormatService } from "@/format"
import { PermissionEffect } from "@/permission/service"
import { Instance } from "@/project/instance"
import { VcsService } from "@/project/vcs"
import { ProviderAuthService } from "@/provider/auth-service"
import { QuestionService } from "@/question/service"
import { PermissionService } from "@/permission/service"
import { FileWatcherService } from "@/file/watcher"
import { VcsService } from "@/project/vcs"
import { FileTimeService } from "@/file/time"
import { FormatService } from "@/format"
import { FileService } from "@/file"
import { SkillService } from "@/skill/skill"
import { Instance } from "@/project/instance"
import { SnapshotService } from "@/snapshot"
import { InstanceContext } from "./instance-context"
import { registerDisposer } from "./instance-registry"
export { InstanceContext } from "./instance-context"
export type InstanceServices =
| QuestionService
| PermissionService
| PermissionEffect.Service
| ProviderAuthService
| FileWatcherService
| VcsService
@@ -24,13 +25,19 @@ export type InstanceServices =
| FormatService
| FileService
| SkillService
| SnapshotService
function lookup(directory: string) {
const project = Instance.project
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of({ directory, project }))
// NOTE: LayerMap only passes the key (directory string) to lookup, but we need
// the full instance context (directory, worktree, project). We read from the
// legacy Instance ALS here, which is safe because lookup is only triggered via
// runPromiseInstance -> Instances.get, which always runs inside Instance.provide.
// This should go away once the old Instance type is removed and lookup can load
// the full context directly.
function lookup(_key: string) {
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
return Layer.mergeAll(
Layer.fresh(QuestionService.layer),
Layer.fresh(PermissionService.layer),
Layer.fresh(PermissionEffect.layer),
Layer.fresh(ProviderAuthService.layer),
Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie),
Layer.fresh(VcsService.layer),
@@ -38,6 +45,7 @@ function lookup(directory: string) {
Layer.fresh(FormatService.layer),
Layer.fresh(FileService.layer),
Layer.fresh(SkillService.layer),
Layer.fresh(SnapshotService.layer),
).pipe(Layer.provide(ctx))
}
@@ -47,7 +55,9 @@ export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<s
static readonly layer = Layer.effect(
Instances,
Effect.gen(function* () {
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
const layerMap = yield* LayerMap.make(lookup, {
idleTimeToLive: Infinity,
})
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
yield* Effect.addFinalizer(() => Effect.sync(unregister))
return Instances.of(layerMap)
@@ -57,8 +67,4 @@ export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<s
static get(directory: string): Layer.Layer<InstanceServices, never, Instances> {
return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory))))
}
static invalidate(directory: string): Effect.Effect<void, never, Instances> {
return Instances.use((map) => map.invalidate(directory))
}
}

View File

@@ -3,12 +3,21 @@ import { AccountService } from "@/account/service"
import { AuthService } from "@/auth/service"
import { Instances } from "@/effect/instances"
import type { InstanceServices } from "@/effect/instances"
import { TruncateEffect } from "@/tool/truncate-effect"
import { Instance } from "@/project/instance"
export const runtime = ManagedRuntime.make(
Layer.mergeAll(AccountService.defaultLayer, Instances.layer).pipe(Layer.provideMerge(AuthService.defaultLayer)),
Layer.mergeAll(
AccountService.defaultLayer, //
TruncateEffect.defaultLayer,
Instances.layer,
).pipe(Layer.provideMerge(AuthService.defaultLayer)),
)
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
}
export function disposeRuntime() {
return runtime.dispose()
}

View File

@@ -3,7 +3,7 @@ import { Config } from "@/config/config"
import { fn } from "@/util/fn"
import { Wildcard } from "@/util/wildcard"
import os from "os"
import * as S from "./service"
import { PermissionEffect as S } from "./service"
export namespace PermissionNext {
function expand(pattern: string): string {
@@ -26,7 +26,7 @@ export namespace PermissionNext {
export type Reply = S.Reply
export const Approval = S.Approval
export const Event = S.Event
export const Service = S.PermissionService
export const Service = S.Service
export const RejectedError = S.RejectedError
export const CorrectedError = S.CorrectedError
export const DeniedError = S.DeniedError
@@ -53,23 +53,21 @@ export namespace PermissionNext {
return rulesets.flat()
}
export const ask = fn(S.AskInput, async (input) =>
runPromiseInstance(S.PermissionService.use((service) => service.ask(input))),
)
export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((service) => service.ask(input))))
export const reply = fn(S.ReplyInput, async (input) =>
runPromiseInstance(S.PermissionService.use((service) => service.reply(input))),
runPromiseInstance(S.Service.use((service) => service.reply(input))),
)
export async function list() {
return runPromiseInstance(S.PermissionService.use((service) => service.list()))
return runPromiseInstance(S.Service.use((service) => service.list()))
}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
return S.evaluate(permission, pattern, ...rulesets)
}
const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"]
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
const result = new Set<string>()

View File

@@ -11,121 +11,128 @@ import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import z from "zod"
import { PermissionID } from "./schema"
const log = Log.create({ service: "permission" })
export namespace PermissionEffect {
const log = Log.create({ service: "permission" })
export const Action = z.enum(["allow", "deny", "ask"]).meta({
ref: "PermissionAction",
})
export type Action = z.infer<typeof Action>
export const Rule = z
.object({
permission: z.string(),
pattern: z.string(),
action: Action,
export const Action = z.enum(["allow", "deny", "ask"]).meta({
ref: "PermissionAction",
})
.meta({
ref: "PermissionRule",
export type Action = z.infer<typeof Action>
export const Rule = z
.object({
permission: z.string(),
pattern: z.string(),
action: Action,
})
.meta({
ref: "PermissionRule",
})
export type Rule = z.infer<typeof Rule>
export const Ruleset = Rule.array().meta({
ref: "PermissionRuleset",
})
export type Rule = z.infer<typeof Rule>
export type Ruleset = z.infer<typeof Ruleset>
export const Ruleset = Rule.array().meta({
ref: "PermissionRuleset",
})
export type Ruleset = z.infer<typeof Ruleset>
export const Request = z
.object({
id: PermissionID.zod,
sessionID: SessionID.zod,
permission: z.string(),
patterns: z.string().array(),
metadata: z.record(z.string(), z.any()),
always: z.string().array(),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({
ref: "PermissionRequest",
})
export type Request = z.infer<typeof Request>
export const Reply = z.enum(["once", "always", "reject"])
export type Reply = z.infer<typeof Reply>
export const Approval = z.object({
projectID: ProjectID.zod,
patterns: z.string().array(),
})
export const Event = {
Asked: BusEvent.define("permission.asked", Request),
Replied: BusEvent.define(
"permission.replied",
z.object({
export const Request = z
.object({
id: PermissionID.zod,
sessionID: SessionID.zod,
requestID: PermissionID.zod,
reply: Reply,
}),
),
}
permission: z.string(),
patterns: z.string().array(),
metadata: z.record(z.string(), z.any()),
always: z.string().array(),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({
ref: "PermissionRequest",
})
export type Request = z.infer<typeof Request>
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
override get message() {
return "The user rejected permission to use this specific tool call."
export const Reply = z.enum(["once", "always", "reject"])
export type Reply = z.infer<typeof Reply>
export const Approval = z.object({
projectID: ProjectID.zod,
patterns: z.string().array(),
})
export const Event = {
Asked: BusEvent.define("permission.asked", Request),
Replied: BusEvent.define(
"permission.replied",
z.object({
sessionID: SessionID.zod,
requestID: PermissionID.zod,
reply: Reply,
}),
),
}
}
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
feedback: Schema.String,
}) {
override get message() {
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
override get message() {
return "The user rejected permission to use this specific tool call."
}
}
}
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
ruleset: Schema.Any,
}) {
override get message() {
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
feedback: Schema.String,
}) {
override get message() {
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
}
}
}
export type PermissionError = DeniedError | RejectedError | CorrectedError
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
ruleset: Schema.Any,
}) {
override get message() {
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
}
}
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
}
export type Error = DeniedError | RejectedError | CorrectedError
export const AskInput = Request.partial({ id: true }).extend({
ruleset: Ruleset,
})
export const AskInput = Request.partial({ id: true }).extend({
ruleset: Ruleset,
})
export const ReplyInput = z.object({
requestID: PermissionID.zod,
reply: Reply,
message: z.string().optional(),
})
export const ReplyInput = z.object({
requestID: PermissionID.zod,
reply: Reply,
message: z.string().optional(),
})
export declare namespace PermissionService {
export interface Api {
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, PermissionError>
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]>
}
}
export class PermissionService extends ServiceMap.Service<PermissionService, PermissionService.Api>()(
"@opencode/PermissionNext",
) {
static readonly layer = Layer.effect(
PermissionService,
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
const rules = rulesets.flat()
log.info("evaluate", { permission, pattern, ruleset: rules })
const match = rules.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
}
export class Service extends ServiceMap.Service<Service, Api>()("@opencode/PermissionNext") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { project } = yield* InstanceContext
const row = Database.use((db) =>
@@ -225,27 +232,13 @@ export class PermissionService extends ServiceMap.Service<PermissionService, Per
})
yield* Deferred.succeed(item.deferred, undefined)
}
// TODO: we don't save the permission ruleset to disk yet until there's
// UI to manage it
// db().insert(PermissionTable).values({ projectID: Instance.project.id, data: s.approved })
// .onConflictDoUpdate({ target: PermissionTable.projectID, set: { data: s.approved } }).run()
})
const list = Effect.fn("PermissionService.list")(function* () {
return Array.from(pending.values(), (item) => item.info)
})
return PermissionService.of({ ask, reply, list })
return Service.of({ ask, reply, list })
}),
)
}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
const merged = rulesets.flat()
log.info("evaluate", { permission, pattern, ruleset: merged })
const match = merged.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
}

View File

@@ -10,8 +10,6 @@ import { Instance } from "./instance"
import { VcsService } from "./vcs"
import { Log } from "@/util/log"
import { ShareNext } from "@/share/share-next"
import { Snapshot } from "../snapshot"
import { Truncate } from "../tool/truncation"
import { runPromiseInstance } from "@/effect/runtime"
export async function InstanceBootstrap() {
@@ -23,8 +21,6 @@ export async function InstanceBootstrap() {
await runPromiseInstance(FileWatcherService.use((service) => service.init()))
File.init()
await runPromiseInstance(VcsService.use((s) => s.init()))
Snapshot.init()
Truncate.init()
Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {

View File

@@ -1,11 +1,11 @@
import { GlobalBus } from "@/bus/global"
import { disposeInstance } from "@/effect/instance-registry"
import { Filesystem } from "@/util/filesystem"
import { iife } from "@/util/iife"
import { Log } from "@/util/log"
import { Context } from "../util/context"
import { Project } from "./project"
import { State } from "./state"
import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global"
import { Filesystem } from "@/util/filesystem"
import { disposeInstance } from "@/effect/instance-registry"
interface Context {
directory: string
@@ -80,6 +80,9 @@ export const Instance = {
return input.fn()
})
},
get current() {
return context.use()
},
get directory() {
return context.use().directory
},

View File

@@ -1,61 +0,0 @@
import { Instance } from "../project/instance"
import { Log } from "../util/log"
export namespace Scheduler {
const log = Log.create({ service: "scheduler" })
export type Task = {
id: string
interval: number
run: () => Promise<void>
scope?: "instance" | "global"
}
type Timer = ReturnType<typeof setInterval>
type Entry = {
tasks: Map<string, Task>
timers: Map<string, Timer>
}
const create = (): Entry => {
const tasks = new Map<string, Task>()
const timers = new Map<string, Timer>()
return { tasks, timers }
}
const shared = create()
const state = Instance.state(
() => create(),
async (entry) => {
for (const timer of entry.timers.values()) {
clearInterval(timer)
}
entry.tasks.clear()
entry.timers.clear()
},
)
export function register(task: Task) {
const scope = task.scope ?? "instance"
const entry = scope === "global" ? shared : state()
const current = entry.timers.get(task.id)
if (current && scope === "global") return
if (current) clearInterval(current)
entry.tasks.set(task.id, task)
void run(task)
const timer = setInterval(() => {
void run(task)
}, task.interval)
timer.unref()
entry.timers.set(task.id, timer)
}
async function run(task: Task) {
log.info("run", { id: task.id })
await task.run().catch((error) => {
log.error("run failed", { id: task.id, error })
})
}
}

View File

@@ -1,7 +1,7 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { PermissionNext } from "@/permission/next"
import { PermissionNext } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"

View File

@@ -14,7 +14,7 @@ import { Todo } from "../../session/todo"
import { Agent } from "../../agent/agent"
import { Snapshot } from "@/snapshot"
import { Log } from "../../util/log"
import { PermissionNext } from "@/permission/next"
import { PermissionNext } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { errors } from "../error"

View File

@@ -28,7 +28,7 @@ import { SessionID, MessageID, PartID } from "./schema"
import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { PermissionNext } from "@/permission/next"
import { PermissionNext } from "@/permission"
import { Global } from "@/global"
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
import { iife } from "@/util/iife"

View File

@@ -20,7 +20,7 @@ import type { MessageV2 } from "./message-v2"
import { Plugin } from "@/plugin"
import { SystemPrompt } from "./system"
import { Flag } from "@/flag/flag"
import { PermissionNext } from "@/permission/next"
import { PermissionNext } from "@/permission"
import { Auth } from "@/auth"
export namespace LLM {

View File

@@ -12,7 +12,7 @@ import type { Provider } from "@/provider/provider"
import { LLM } from "./llm"
import { Config } from "@/config/config"
import { SessionCompaction } from "./compaction"
import { PermissionNext } from "@/permission/next"
import { PermissionNext } from "@/permission"
import { Question } from "@/question"
import { PartID } from "./schema"
import type { SessionID, MessageID } from "./schema"

View File

@@ -41,12 +41,12 @@ import { fn } from "@/util/fn"
import { SessionProcessor } from "./processor"
import { TaskTool } from "@/tool/task"
import { Tool } from "@/tool/tool"
import { PermissionNext } from "@/permission/next"
import { PermissionNext } from "@/permission"
import { SessionStatus } from "./status"
import { LLM } from "./llm"
import { iife } from "@/util/iife"
import { Shell } from "@/shell/shell"
import { Truncate } from "@/tool/truncation"
import { Truncate } from "@/tool/truncate"
import { decodeDataUrl } from "@/util/data-url"
// @ts-ignore
@@ -1318,6 +1318,31 @@ export namespace SessionPrompt {
},
)
const parsedInfo = MessageV2.Info.safeParse(info)
if (!parsedInfo.success) {
log.error("invalid user message before save", {
sessionID: input.sessionID,
messageID: info.id,
agent: info.agent,
model: info.model,
issues: parsedInfo.error.issues,
})
}
parts.forEach((part, index) => {
const parsedPart = MessageV2.Part.safeParse(part)
if (parsedPart.success) return
log.error("invalid user part before save", {
sessionID: input.sessionID,
messageID: info.id,
partID: part.id,
partType: part.type,
index,
issues: parsedPart.error.issues,
part,
})
})
await Session.updateMessage(info)
for (const part of parts) {
await Session.updatePart(part)

View File

@@ -2,7 +2,7 @@ import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlit
import { ProjectTable } from "../project/project.sql"
import type { MessageV2 } from "./message-v2"
import type { Snapshot } from "../snapshot"
import type { PermissionNext } from "../permission/next"
import type { PermissionNext } from "../permission"
import type { ProjectID } from "../project/schema"
import type { SessionID, MessageID, PartID } from "./schema"
import type { WorkspaceID } from "../control-plane/schema"

View File

@@ -11,7 +11,7 @@ import PROMPT_CODEX from "./prompt/codex_header.txt"
import PROMPT_TRINITY from "./prompt/trinity.txt"
import type { Provider } from "@/provider/provider"
import type { Agent } from "@/agent/agent"
import { PermissionNext } from "@/permission/next"
import { PermissionNext } from "@/permission"
import { Skill } from "@/skill"
export namespace SystemPrompt {

View File

@@ -14,7 +14,7 @@ import { DiscoveryService } from "./discovery"
import { Glob } from "../util/glob"
import { pathToFileURL } from "url"
import type { Agent } from "@/agent/agent"
import { PermissionNext } from "@/permission/next"
import { PermissionNext } from "@/permission"
import { InstanceContext } from "@/effect/instance-context"
import { Effect, Layer, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime"

View File

@@ -1,257 +1,35 @@
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
import { Flag } from "../flag/flag"
import { Global } from "../global"
import z from "zod"
import { InstanceContext } from "@/effect/instance-context"
import { runPromiseInstance } from "@/effect/runtime"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { Scheduler } from "../scheduler"
import { Process } from "@/util/process"
import { Global } from "../global"
import { Log } from "../util/log"
const log = Log.create({ service: "snapshot" })
const PRUNE = "7.days"
// Common git config flags shared across snapshot operations
const GIT_CORE = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
const GIT_CFG = ["-c", "core.autocrlf=false", ...GIT_CORE]
const GIT_CFG_QUOTE = [...GIT_CFG, "-c", "core.quotepath=false"]
interface GitResult {
readonly code: ChildProcessSpawner.ExitCode
readonly text: string
readonly stderr: string
}
export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
const hour = 60 * 60 * 1000
const prune = "7.days"
function args(git: string, cmd: string[]) {
return ["--git-dir", git, "--work-tree", Instance.worktree, ...cmd]
}
export function init() {
Scheduler.register({
id: "snapshot.cleanup",
interval: hour,
run: cleanup,
scope: "instance",
})
}
export async function cleanup() {
if (Instance.project.vcs !== "git") return
const cfg = await Config.get()
if (cfg.snapshot === false) return
const git = gitdir()
const exists = await fs
.stat(git)
.then(() => true)
.catch(() => false)
if (!exists) return
const result = await Process.run(["git", ...args(git, ["gc", `--prune=${prune}`])], {
cwd: Instance.directory,
nothrow: true,
})
if (result.code !== 0) {
log.warn("cleanup failed", {
exitCode: result.code,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
return
}
log.info("cleanup", { prune })
}
export async function track() {
if (Instance.project.vcs !== "git") return
const cfg = await Config.get()
if (cfg.snapshot === false) return
const git = gitdir()
if (await fs.mkdir(git, { recursive: true })) {
await Process.run(["git", "init"], {
env: {
...process.env,
GIT_DIR: git,
GIT_WORK_TREE: Instance.worktree,
},
nothrow: true,
})
// Configure git to not convert line endings on Windows
await Process.run(["git", "--git-dir", git, "config", "core.autocrlf", "false"], { nothrow: true })
await Process.run(["git", "--git-dir", git, "config", "core.longpaths", "true"], { nothrow: true })
await Process.run(["git", "--git-dir", git, "config", "core.symlinks", "true"], { nothrow: true })
await Process.run(["git", "--git-dir", git, "config", "core.fsmonitor", "false"], { nothrow: true })
log.info("initialized")
}
await add(git)
const hash = await Process.text(["git", ...args(git, ["write-tree"])], {
cwd: Instance.directory,
nothrow: true,
}).then((x) => x.text)
log.info("tracking", { hash, cwd: Instance.directory, git })
return hash.trim()
}
export const Patch = z.object({
hash: z.string(),
files: z.string().array(),
})
export type Patch = z.infer<typeof Patch>
export async function patch(hash: string): Promise<Patch> {
const git = gitdir()
await add(git)
const result = await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--name-only", hash, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)
// If git diff fails, return empty patch
if (result.code !== 0) {
log.warn("failed to get diff", { hash, exitCode: result.code })
return { hash, files: [] }
}
const files = result.text
return {
hash,
files: files
.trim()
.split("\n")
.map((x) => x.trim())
.filter(Boolean)
.map((x) => path.join(Instance.worktree, x).replaceAll("\\", "/")),
}
}
export async function restore(snapshot: string) {
log.info("restore", { commit: snapshot })
const git = gitdir()
const result = await Process.run(
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["read-tree", snapshot])],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (result.code === 0) {
const checkout = await Process.run(
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["checkout-index", "-a", "-f"])],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (checkout.code === 0) return
log.error("failed to restore snapshot", {
snapshot,
exitCode: checkout.code,
stderr: checkout.stderr.toString(),
stdout: checkout.stdout.toString(),
})
return
}
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.code,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
}
export async function revert(patches: Patch[]) {
const files = new Set<string>()
const git = gitdir()
for (const item of patches) {
for (const file of item.files) {
if (files.has(file)) continue
log.info("reverting", { file, hash: item.hash })
const result = await Process.run(
[
"git",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["checkout", item.hash, "--", file]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (result.code !== 0) {
const relativePath = path.relative(Instance.worktree, file)
const checkTree = await Process.text(
[
"git",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["ls-tree", item.hash, "--", relativePath]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (checkTree.code === 0 && checkTree.text.trim()) {
log.info("file existed in snapshot but checkout failed, keeping", {
file,
})
} else {
log.info("file did not exist in snapshot, deleting", { file })
await fs.unlink(file).catch(() => {})
}
}
files.add(file)
}
}
}
export async function diff(hash: string) {
const git = gitdir()
await add(git)
const result = await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", hash, "--", "."]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (result.code !== 0) {
log.warn("failed to get diff", {
hash,
exitCode: result.code,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
return ""
}
return result.text.trim()
}
export const FileDiff = z
.object({
file: z.string(),
@@ -265,152 +43,339 @@ export namespace Snapshot {
ref: "FileDiff",
})
export type FileDiff = z.infer<typeof FileDiff>
export async function diffFull(from: string, to: string): Promise<FileDiff[]> {
const git = gitdir()
const result: FileDiff[] = []
const status = new Map<string, "added" | "deleted" | "modified">()
const statuses = await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
).then((x) => x.text)
for (const line of statuses.trim().split("\n")) {
if (!line) continue
const [code, file] = line.split("\t")
if (!code || !file) continue
const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified"
status.set(file, kind)
}
for (const line of await Process.lines(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)) {
if (!line) continue
const [additions, deletions, file] = line.split("\t")
const isBinaryFile = additions === "-" && deletions === "-"
const before = isBinaryFile
? ""
: await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["show", `${from}:${file}`]),
],
{ nothrow: true },
).then((x) => x.text)
const after = isBinaryFile
? ""
: await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["show", `${to}:${file}`]),
],
{ nothrow: true },
).then((x) => x.text)
const added = isBinaryFile ? 0 : parseInt(additions)
const deleted = isBinaryFile ? 0 : parseInt(deletions)
result.push({
file,
before,
after,
additions: Number.isFinite(added) ? added : 0,
deletions: Number.isFinite(deleted) ? deleted : 0,
status: status.get(file) ?? "modified",
})
}
return result
// Promise facade — existing callers use these
export function init() {
void runPromiseInstance(SnapshotService.use((s) => s.init()))
}
function gitdir() {
const project = Instance.project
return path.join(Global.Path.data, "snapshot", project.id)
export async function cleanup() {
return runPromiseInstance(SnapshotService.use((s) => s.cleanup()))
}
async function add(git: string) {
await syncExclude(git)
await Process.run(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["add", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)
export async function track() {
return runPromiseInstance(SnapshotService.use((s) => s.track()))
}
async function syncExclude(git: string) {
const file = await excludes()
const target = path.join(git, "info", "exclude")
await fs.mkdir(path.join(git, "info"), { recursive: true })
if (!file) {
await Filesystem.write(target, "")
return
}
const text = await Filesystem.readText(file).catch(() => "")
await Filesystem.write(target, text)
export async function patch(hash: string) {
return runPromiseInstance(SnapshotService.use((s) => s.patch(hash)))
}
async function excludes() {
const file = await Process.text(["git", "rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
cwd: Instance.worktree,
nothrow: true,
}).then((x) => x.text)
if (!file.trim()) return
const exists = await fs
.stat(file.trim())
.then(() => true)
.catch(() => false)
if (!exists) return
return file.trim()
export async function restore(snapshot: string) {
return runPromiseInstance(SnapshotService.use((s) => s.restore(snapshot)))
}
export async function revert(patches: Patch[]) {
return runPromiseInstance(SnapshotService.use((s) => s.revert(patches)))
}
export async function diff(hash: string) {
return runPromiseInstance(SnapshotService.use((s) => s.diff(hash)))
}
export async function diffFull(from: string, to: string) {
return runPromiseInstance(SnapshotService.use((s) => s.diffFull(from, to)))
}
}
export namespace SnapshotService {
export interface Service {
readonly init: () => Effect.Effect<void>
readonly cleanup: () => Effect.Effect<void>
readonly track: () => Effect.Effect<string | undefined>
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
readonly restore: (snapshot: string) => Effect.Effect<void>
readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>
readonly diff: (hash: string) => Effect.Effect<string>
readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
}
}
export class SnapshotService extends ServiceMap.Service<SnapshotService, SnapshotService.Service>()(
"@opencode/Snapshot",
) {
static readonly layer = Layer.effect(
SnapshotService,
Effect.gen(function* () {
const ctx = yield* InstanceContext
const fileSystem = yield* FileSystem.FileSystem
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const { directory, worktree, project } = ctx
const isGit = project.vcs === "git"
const snapshotGit = path.join(Global.Path.data, "snapshot", project.id)
const gitArgs = (cmd: string[]) => ["--git-dir", snapshotGit, "--work-tree", worktree, ...cmd]
// Run git with nothrow semantics — always returns a result, never fails
const git = (args: string[], opts?: { cwd?: string; env?: Record<string, string> }): Effect.Effect<GitResult> =>
Effect.gen(function* () {
const command = ChildProcess.make("git", args, {
cwd: opts?.cwd,
env: opts?.env,
extendEnv: true,
})
const handle = yield* spawner.spawn(command)
const [text, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
return { code, text, stderr }
}).pipe(
Effect.scoped,
Effect.catch((err) =>
Effect.succeed({
code: ChildProcessSpawner.ExitCode(1),
text: "",
stderr: String(err),
}),
),
)
// FileSystem helpers — orDie converts PlatformError to defects
const exists = (p: string) => fileSystem.exists(p).pipe(Effect.orDie)
const mkdir = (p: string) => fileSystem.makeDirectory(p, { recursive: true }).pipe(Effect.orDie)
const writeFile = (p: string, content: string) => fileSystem.writeFileString(p, content).pipe(Effect.orDie)
const readFile = (p: string) => fileSystem.readFileString(p).pipe(Effect.catch(() => Effect.succeed("")))
const removeFile = (p: string) => fileSystem.remove(p).pipe(Effect.catch(() => Effect.void))
// --- internal Effect helpers ---
const isEnabled = Effect.gen(function* () {
if (!isGit) return false
const cfg = yield* Effect.promise(() => Config.get())
return cfg.snapshot !== false
})
const excludesPath = Effect.gen(function* () {
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
cwd: worktree,
})
const file = result.text.trim()
if (!file) return undefined
if (!(yield* exists(file))) return undefined
return file
})
const syncExclude = Effect.gen(function* () {
const file = yield* excludesPath
const target = path.join(snapshotGit, "info", "exclude")
yield* mkdir(path.join(snapshotGit, "info"))
if (!file) {
yield* writeFile(target, "")
return
}
const text = yield* readFile(file)
yield* writeFile(target, text)
})
const add = Effect.gen(function* () {
yield* syncExclude
yield* git([...GIT_CFG, ...gitArgs(["add", "."])], { cwd: directory })
})
// --- service methods ---
const cleanup = Effect.fn("SnapshotService.cleanup")(function* () {
if (!(yield* isEnabled)) return
if (!(yield* exists(snapshotGit))) return
const result = yield* git(gitArgs(["gc", `--prune=${PRUNE}`]), {
cwd: directory,
})
if (result.code !== 0) {
log.warn("cleanup failed", {
exitCode: result.code,
stderr: result.stderr,
})
return
}
log.info("cleanup", { prune: PRUNE })
})
const track = Effect.fn("SnapshotService.track")(function* () {
if (!(yield* isEnabled)) return undefined
const existed = yield* exists(snapshotGit)
yield* mkdir(snapshotGit)
if (!existed) {
yield* git(["init"], {
env: { GIT_DIR: snapshotGit, GIT_WORK_TREE: worktree },
})
yield* git(["--git-dir", snapshotGit, "config", "core.autocrlf", "false"])
yield* git(["--git-dir", snapshotGit, "config", "core.longpaths", "true"])
yield* git(["--git-dir", snapshotGit, "config", "core.symlinks", "true"])
yield* git(["--git-dir", snapshotGit, "config", "core.fsmonitor", "false"])
log.info("initialized")
}
yield* add
const result = yield* git(gitArgs(["write-tree"]), { cwd: directory })
const hash = result.text.trim()
log.info("tracking", { hash, cwd: directory, git: snapshotGit })
return hash
})
const patch = Effect.fn("SnapshotService.patch")(function* (hash: string) {
yield* add
const result = yield* git(
[...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])],
{ cwd: directory },
)
if (result.code !== 0) {
log.warn("failed to get diff", { hash, exitCode: result.code })
return { hash, files: [] } as Snapshot.Patch
}
return {
hash,
files: result.text
.trim()
.split("\n")
.map((x: string) => x.trim())
.filter(Boolean)
.map((x: string) => path.join(worktree, x).replaceAll("\\", "/")),
} as Snapshot.Patch
})
const restore = Effect.fn("SnapshotService.restore")(function* (snapshot: string) {
log.info("restore", { commit: snapshot })
const result = yield* git([...GIT_CORE, ...gitArgs(["read-tree", snapshot])], { cwd: worktree })
if (result.code === 0) {
const checkout = yield* git([...GIT_CORE, ...gitArgs(["checkout-index", "-a", "-f"])], { cwd: worktree })
if (checkout.code === 0) return
log.error("failed to restore snapshot", {
snapshot,
exitCode: checkout.code,
stderr: checkout.stderr,
})
return
}
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.code,
stderr: result.stderr,
})
})
const revert = Effect.fn("SnapshotService.revert")(function* (patches: Snapshot.Patch[]) {
const seen = new Set<string>()
for (const item of patches) {
for (const file of item.files) {
if (seen.has(file)) continue
log.info("reverting", { file, hash: item.hash })
const result = yield* git([...GIT_CORE, ...gitArgs(["checkout", item.hash, "--", file])], {
cwd: worktree,
})
if (result.code !== 0) {
const relativePath = path.relative(worktree, file)
const checkTree = yield* git([...GIT_CORE, ...gitArgs(["ls-tree", item.hash, "--", relativePath])], {
cwd: worktree,
})
if (checkTree.code === 0 && checkTree.text.trim()) {
log.info("file existed in snapshot but checkout failed, keeping", { file })
} else {
log.info("file did not exist in snapshot, deleting", { file })
yield* removeFile(file)
}
}
seen.add(file)
}
}
})
const diff = Effect.fn("SnapshotService.diff")(function* (hash: string) {
yield* add
const result = yield* git([...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", hash, "--", "."])], {
cwd: worktree,
})
if (result.code !== 0) {
log.warn("failed to get diff", {
hash,
exitCode: result.code,
stderr: result.stderr,
})
return ""
}
return result.text.trim()
})
const diffFull = Effect.fn("SnapshotService.diffFull")(function* (from: string, to: string) {
const result: Snapshot.FileDiff[] = []
const status = new Map<string, "added" | "deleted" | "modified">()
const statuses = yield* git(
[
...GIT_CFG_QUOTE,
...gitArgs(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
],
{ cwd: directory },
)
for (const line of statuses.text.trim().split("\n")) {
if (!line) continue
const [code, file] = line.split("\t")
if (!code || !file) continue
const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified"
status.set(file, kind)
}
const numstat = yield* git(
[...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
{ cwd: directory },
)
for (const line of numstat.text.trim().split("\n")) {
if (!line) continue
const [additions, deletions, file] = line.split("\t")
const isBinaryFile = additions === "-" && deletions === "-"
const [before, after] = isBinaryFile
? ["", ""]
: yield* Effect.all(
[
git([...GIT_CFG, ...gitArgs(["show", `${from}:${file}`])]).pipe(Effect.map((r) => r.text)),
git([...GIT_CFG, ...gitArgs(["show", `${to}:${file}`])]).pipe(Effect.map((r) => r.text)),
],
{ concurrency: 2 },
)
const added = isBinaryFile ? 0 : parseInt(additions!)
const deleted = isBinaryFile ? 0 : parseInt(deletions!)
result.push({
file: file!,
before,
after,
additions: Number.isFinite(added) ? added : 0,
deletions: Number.isFinite(deleted) ? deleted : 0,
status: status.get(file!) ?? "modified",
})
}
return result
})
// Start hourly cleanup fiber — scoped to instance lifetime
yield* cleanup().pipe(
Effect.catchCause((cause) => {
log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
return Effect.void
}),
Effect.repeat(Schedule.spaced(Duration.hours(1))),
Effect.forkScoped,
)
return SnapshotService.of({
init: Effect.fn("SnapshotService.init")(function* () {}),
cleanup,
track,
patch,
restore,
revert,
diff,
diffFull,
})
}),
).pipe(
Layer.provide(NodeChildProcessSpawner.layer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
)
}

View File

@@ -15,7 +15,7 @@ import { Flag } from "@/flag/flag.ts"
import { Shell } from "@/shell/shell"
import { BashArity } from "@/permission/arity"
import { Truncate } from "./truncation"
import { Truncate } from "./truncate"
import { Plugin } from "@/plugin"
const MAX_METADATA_LENGTH = 30_000

View File

@@ -26,7 +26,7 @@ import { CodeSearchTool } from "./codesearch"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { LspTool } from "./lsp"
import { Truncate } from "./truncation"
import { Truncate } from "./truncate"
import { ApplyPatchTool } from "./apply_patch"
import { Glob } from "../util/glob"

View File

@@ -10,7 +10,7 @@ import { SessionPrompt } from "../session/prompt"
import { iife } from "@/util/iife"
import { defer } from "@/util/defer"
import { Config } from "../config/config"
import { PermissionNext } from "@/permission/next"
import { PermissionNext } from "@/permission"
const parameters = z.object({
description: z.string().describe("A short (3-5 words) description of the task"),

View File

@@ -1,9 +1,9 @@
import z from "zod"
import type { MessageV2 } from "../session/message-v2"
import type { Agent } from "../agent/agent"
import type { PermissionNext } from "../permission/next"
import type { PermissionNext } from "../permission"
import type { SessionID, MessageID } from "../session/schema"
import { Truncate } from "./truncation"
import { Truncate } from "./truncate"
export namespace Tool {
interface Metadata {

View File

@@ -0,0 +1,140 @@
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap } from "effect"
import path from "path"
import type { Agent } from "../agent/agent"
import { PermissionEffect } from "../permission/service"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
import { ToolID } from "./schema"
import { TRUNCATION_DIR } from "./truncation-dir"
export namespace TruncateEffect {
const log = Log.create({ service: "truncation" })
const RETENTION = Duration.days(7)
export const MAX_LINES = 2000
export const MAX_BYTES = 50 * 1024
export const DIR = TRUNCATION_DIR
export const GLOB = path.join(TRUNCATION_DIR, "*")
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
export interface Options {
maxLines?: number
maxBytes?: number
direction?: "head" | "tail"
}
function hasTaskTool(agent?: Agent.Info) {
if (!agent?.permission) return false
return PermissionEffect.evaluate("task", "*", agent.permission).action !== "deny"
}
export interface Api {
readonly cleanup: () => Effect.Effect<void>
/**
* Returns output unchanged when it fits within the limits, otherwise writes the full text
* to the truncation directory and returns a preview plus a hint to inspect the saved file.
*/
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
}
export class Service extends ServiceMap.Service<Service, Api>()("@opencode/Truncate") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const cleanup = Effect.fn("TruncateEffect.cleanup")(function* () {
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
Effect.catch(() => Effect.succeed([])),
)
for (const entry of entries) {
if (Identifier.timestamp(entry) >= cutoff) continue
yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
}
})
const output = Effect.fn("TruncateEffect.output")(function* (
text: string,
options: Options = {},
agent?: Agent.Info,
) {
const maxLines = options.maxLines ?? MAX_LINES
const maxBytes = options.maxBytes ?? MAX_BYTES
const direction = options.direction ?? "head"
const lines = text.split("\n")
const totalBytes = Buffer.byteLength(text, "utf-8")
if (lines.length <= maxLines && totalBytes <= maxBytes) {
return { content: text, truncated: false } as const
}
const out: string[] = []
let i = 0
let bytes = 0
let hitBytes = false
if (direction === "head") {
for (i = 0; i < lines.length && i < maxLines; i++) {
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
out.push(lines[i])
bytes += size
}
} else {
for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
out.unshift(lines[i])
bytes += size
}
}
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
const unit = hitBytes ? "bytes" : "lines"
const preview = out.join("\n")
const file = path.join(TRUNCATION_DIR, ToolID.ascending())
yield* fs.makeDirectory(TRUNCATION_DIR, { recursive: true }).pipe(Effect.orDie)
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
const hint = hasTaskTool(agent)
? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
: `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
return {
content:
direction === "head"
? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
: `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
truncated: true,
outputPath: file,
} as const
})
yield* cleanup().pipe(
Effect.catchCause((cause) => {
log.error("truncation cleanup failed", { cause: Cause.pretty(cause) })
return Effect.void
}),
Effect.repeat(Schedule.spaced(Duration.hours(1))),
Effect.delay(Duration.minutes(1)),
Effect.forkScoped,
)
return Service.of({ cleanup, output })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
}

View File

@@ -0,0 +1,18 @@
import type { Agent } from "../agent/agent"
import { runtime } from "@/effect/runtime"
import { TruncateEffect as S } from "./truncate-effect"
export namespace Truncate {
export const MAX_LINES = S.MAX_LINES
export const MAX_BYTES = S.MAX_BYTES
export const DIR = S.DIR
export const GLOB = S.GLOB
export type Result = S.Result
export type Options = S.Options
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
return runtime.runPromise(S.Service.use((s) => s.output(text, options, agent)))
}
}

View File

@@ -0,0 +1,4 @@
import path from "path"
import { Global } from "../global"
export const TRUNCATION_DIR = path.join(Global.Path.data, "tool-output")

View File

@@ -1,108 +0,0 @@
import fs from "fs/promises"
import path from "path"
import { Global } from "../global"
import { Identifier } from "../id/id"
import { PermissionNext } from "../permission/next"
import type { Agent } from "../agent/agent"
import { Scheduler } from "../scheduler"
import { Filesystem } from "../util/filesystem"
import { Glob } from "../util/glob"
import { ToolID } from "./schema"
export namespace Truncate {
export const MAX_LINES = 2000
export const MAX_BYTES = 50 * 1024
export const DIR = path.join(Global.Path.data, "tool-output")
export const GLOB = path.join(DIR, "*")
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
const HOUR_MS = 60 * 60 * 1000
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
export interface Options {
maxLines?: number
maxBytes?: number
direction?: "head" | "tail"
}
export function init() {
Scheduler.register({
id: "tool.truncation.cleanup",
interval: HOUR_MS,
run: cleanup,
scope: "global",
})
}
export async function cleanup() {
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - RETENTION_MS))
const entries = await Glob.scan("tool_*", { cwd: DIR, include: "file" }).catch(() => [] as string[])
for (const entry of entries) {
if (Identifier.timestamp(entry) >= cutoff) continue
await fs.unlink(path.join(DIR, entry)).catch(() => {})
}
}
function hasTaskTool(agent?: Agent.Info): boolean {
if (!agent?.permission) return false
const rule = PermissionNext.evaluate("task", "*", agent.permission)
return rule.action !== "deny"
}
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
const maxLines = options.maxLines ?? MAX_LINES
const maxBytes = options.maxBytes ?? MAX_BYTES
const direction = options.direction ?? "head"
const lines = text.split("\n")
const totalBytes = Buffer.byteLength(text, "utf-8")
if (lines.length <= maxLines && totalBytes <= maxBytes) {
return { content: text, truncated: false }
}
const out: string[] = []
let i = 0
let bytes = 0
let hitBytes = false
if (direction === "head") {
for (i = 0; i < lines.length && i < maxLines; i++) {
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
out.push(lines[i])
bytes += size
}
} else {
for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
out.unshift(lines[i])
bytes += size
}
}
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
const unit = hitBytes ? "bytes" : "lines"
const preview = out.join("\n")
const id = ToolID.ascending()
const filepath = path.join(DIR, id)
await Filesystem.write(filepath, text)
const hint = hasTaskTool(agent)
? `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
: `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
const message =
direction === "head"
? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
: `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`
return { content: message, truncated: true, outputPath: filepath }
}
}

View File

@@ -7,6 +7,9 @@ export function fn<T extends z.ZodType, Result>(schema: T, cb: (input: z.infer<T
parsed = schema.parse(input)
} catch (e) {
console.trace("schema validation failure stack trace:")
if (e instanceof z.ZodError) {
console.error("schema validation issues:", JSON.stringify(e.issues, null, 2))
}
throw e
}

View File

@@ -4,7 +4,7 @@ import { Effect, Layer, Option } from "effect"
import { AccountRepo } from "../../src/account/repo"
import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema"
import { Database } from "../../src/storage/db"
import { testEffect } from "../fixture/effect"
import { testEffect } from "../lib/effect"
const truncate = Layer.effectDiscard(
Effect.sync(() => {
@@ -16,24 +16,21 @@ const truncate = Layer.effectDiscard(
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
it.effect(
"list returns empty when no accounts exist",
it.effect("list returns empty when no accounts exist", () =>
Effect.gen(function* () {
const accounts = yield* AccountRepo.use((r) => r.list())
expect(accounts).toEqual([])
}),
)
it.effect(
"active returns none when no accounts exist",
it.effect("active returns none when no accounts exist", () =>
Effect.gen(function* () {
const active = yield* AccountRepo.use((r) => r.active())
expect(Option.isNone(active)).toBe(true)
}),
)
it.effect(
"persistAccount inserts and getRow retrieves",
it.effect("persistAccount inserts and getRow retrieves", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
@@ -59,8 +56,7 @@ it.effect(
}),
)
it.effect(
"persistAccount sets the active account and org",
it.effect("persistAccount sets the active account and org", () =>
Effect.gen(function* () {
const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2")
@@ -97,8 +93,7 @@ it.effect(
}),
)
it.effect(
"list returns all accounts",
it.effect("list returns all accounts", () =>
Effect.gen(function* () {
const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2")
@@ -133,8 +128,7 @@ it.effect(
}),
)
it.effect(
"remove deletes an account",
it.effect("remove deletes an account", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
@@ -157,8 +151,7 @@ it.effect(
}),
)
it.effect(
"use stores the selected org and marks the account active",
it.effect("use stores the selected org and marks the account active", () =>
Effect.gen(function* () {
const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2")
@@ -198,8 +191,7 @@ it.effect(
}),
)
it.effect(
"persistToken updates token fields",
it.effect("persistToken updates token fields", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
@@ -233,8 +225,7 @@ it.effect(
}),
)
it.effect(
"persistToken with no expiry sets token_expiry to null",
it.effect("persistToken with no expiry sets token_expiry to null", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
@@ -264,8 +255,7 @@ it.effect(
}),
)
it.effect(
"persistAccount upserts on conflict",
it.effect("persistAccount upserts on conflict", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
@@ -305,8 +295,7 @@ it.effect(
}),
)
it.effect(
"remove clears active state when deleting the active account",
it.effect("remove clears active state when deleting the active account", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
@@ -329,8 +318,7 @@ it.effect(
}),
)
it.effect(
"getRow returns none for nonexistent account",
it.effect("getRow returns none for nonexistent account", () =>
Effect.gen(function* () {
const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope")))
expect(Option.isNone(row)).toBe(true)

View File

@@ -1,12 +1,12 @@
import { expect } from "bun:test"
import { Duration, Effect, Layer, Option, Ref, Schema } from "effect"
import { Duration, Effect, Layer, Option, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AccountRepo } from "../../src/account/repo"
import { AccountService } from "../../src/account/service"
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
import { Database } from "../../src/storage/db"
import { testEffect } from "../fixture/effect"
import { testEffect } from "../lib/effect"
const truncate = Layer.effectDiscard(
Effect.sync(() => {
@@ -34,8 +34,7 @@ const encodeOrg = Schema.encodeSync(Org)
const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name }))
it.effect(
"orgsByAccount groups orgs per account",
it.effect("orgsByAccount groups orgs per account", () =>
Effect.gen(function* () {
yield* AccountRepo.use((r) =>
r.persistAccount({
@@ -61,10 +60,10 @@ it.effect(
}),
)
const seen = yield* Ref.make<string[]>([])
const seen: Array<string> = []
const client = HttpClient.make((req) =>
Effect.gen(function* () {
yield* Ref.update(seen, (xs) => [...xs, `${req.method} ${req.url}`])
seen.push(`${req.method} ${req.url}`)
if (req.url === "https://one.example.com/api/orgs") {
return json(req, [org("org-1", "One")])
@@ -84,15 +83,11 @@ it.effect(
[AccountID.make("user-1"), [OrgID.make("org-1")]],
[AccountID.make("user-2"), [OrgID.make("org-2"), OrgID.make("org-3")]],
])
expect(yield* Ref.get(seen)).toEqual([
"GET https://one.example.com/api/orgs",
"GET https://two.example.com/api/orgs",
])
expect(seen).toEqual(["GET https://one.example.com/api/orgs", "GET https://two.example.com/api/orgs"])
}),
)
it.effect(
"token refresh persists the new token",
it.effect("token refresh persists the new token", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
@@ -133,8 +128,7 @@ it.effect(
}),
)
it.effect(
"config sends the selected org header",
it.effect("config sends the selected org header", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
@@ -150,13 +144,11 @@ it.effect(
}),
)
const seen = yield* Ref.make<{ auth?: string; org?: string }>({})
const seen: { auth?: string; org?: string } = {}
const client = HttpClient.make((req) =>
Effect.gen(function* () {
yield* Ref.set(seen, {
auth: req.headers.authorization,
org: req.headers["x-org-id"],
})
seen.auth = req.headers.authorization
seen.org = req.headers["x-org-id"]
if (req.url === "https://one.example.com/api/config") {
return json(req, { config: { theme: "light", seats: 5 } })
@@ -169,15 +161,14 @@ it.effect(
const cfg = yield* AccountService.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
expect(yield* Ref.get(seen)).toEqual({
expect(seen).toEqual({
auth: "Bearer at_1",
org: "org-9",
})
}),
)
it.effect(
"poll stores the account and first org on success",
it.effect("poll stores the account and first org on success", () =>
Effect.gen(function* () {
const login = new Login({
code: DeviceCode.make("device-code"),

View File

@@ -3,7 +3,7 @@ import path from "path"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Agent } from "../../src/agent/agent"
import { PermissionNext } from "../../src/permission/next"
import { PermissionNext } from "../../src/permission"
// Helper to evaluate permission for a tool with wildcard pattern
function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined {
@@ -76,7 +76,7 @@ test("explore agent denies edit and write", async () => {
})
test("explore agent asks for external directories and allows Truncate.GLOB", async () => {
const { Truncate } = await import("../../src/tool/truncation")
const { Truncate } = await import("../../src/tool/truncate")
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
@@ -463,7 +463,7 @@ test("legacy tools config maps write/edit/patch/multiedit to edit permission", a
})
test("Truncate.GLOB is allowed even when user denies external_directory globally", async () => {
const { Truncate } = await import("../../src/tool/truncation")
const { Truncate } = await import("../../src/tool/truncate")
await using tmp = await tmpdir({
config: {
permission: {
@@ -483,7 +483,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
})
test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => {
const { Truncate } = await import("../../src/tool/truncation")
const { Truncate } = await import("../../src/tool/truncate")
await using tmp = await tmpdir({
config: {
agent: {
@@ -507,7 +507,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen
})
test("explicit Truncate.GLOB deny is respected", async () => {
const { Truncate } = await import("../../src/tool/truncation")
const { Truncate } = await import("../../src/tool/truncate")
await using tmp = await tmpdir({
config: {
permission: {

View File

@@ -1,7 +0,0 @@
import { test } from "bun:test"
import { Effect, Layer } from "effect"
export const testEffect = <R, E>(layer: Layer.Layer<R, E, never>) => ({
effect: <A, E2>(name: string, value: Effect.Effect<A, E2, R>) =>
test(name, () => Effect.runPromise(value.pipe(Effect.provide(layer)))),
})

View File

@@ -28,7 +28,11 @@ export function withServices<S>(
directory,
fn: async () => {
const ctx = Layer.sync(InstanceContext, () =>
InstanceContext.of({ directory: Instance.directory, project: Instance.project }),
InstanceContext.of({
directory: Instance.directory,
worktree: Instance.worktree,
project: Instance.project,
}),
)
let resolved: Layer.Layer<S> = Layer.fresh(layer).pipe(Layer.provide(ctx)) as any
if (options?.provide) {

View File

@@ -0,0 +1,37 @@
import { test, type TestOptions } from "bun:test"
import { Cause, Effect, Exit, Layer } from "effect"
import type * as Scope from "effect/Scope"
import * as TestConsole from "effect/testing/TestConsole"
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
const env = TestConsole.layer
const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2, never>) =>
Effect.gen(function* () {
const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
if (Exit.isFailure(exit)) {
for (const err of Cause.prettyErrors(exit.cause)) {
yield* Effect.logError(err)
}
}
return yield* exit
}).pipe(Effect.runPromise)
const make = <R, E>(layer: Layer.Layer<R, E, never>) => {
const effect = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test(name, () => run(value, layer), opts)
effect.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.only(name, () => run(value, layer), opts)
effect.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.skip(name, () => run(value, layer), opts)
return { effect }
}
export const it = make(env)
export const testEffect = <R, E>(layer: Layer.Layer<R, E, never>) => make(Layer.provideMerge(layer, env))

View File

@@ -0,0 +1,10 @@
import path from "path"
import { Effect, FileSystem } from "effect"
export const writeFileStringScoped = Effect.fn("test.writeFileStringScoped")(function* (file: string, text: string) {
const fs = yield* FileSystem.FileSystem
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
yield* fs.writeFileString(file, text)
yield* Effect.addFinalizer(() => fs.remove(file, { force: true }).pipe(Effect.orDie))
return file
})

View File

@@ -1,5 +1,5 @@
import { describe, test, expect } from "bun:test"
import { PermissionNext } from "../src/permission/next"
import { PermissionNext } from "../src/permission"
import { Config } from "../src/config/config"
import { Instance } from "../src/project/instance"
import { tmpdir } from "./fixture/fixture"

View File

@@ -4,7 +4,7 @@ import { Effect } from "effect"
import { Bus } from "../../src/bus"
import { runtime } from "../../src/effect/runtime"
import { Instances } from "../../src/effect/instances"
import { PermissionNext } from "../../src/permission/next"
import { PermissionNext } from "../../src/permission"
import * as S from "../../src/permission/service"
import { PermissionID } from "../../src/permission/schema"
import { Instance } from "../../src/project/instance"
@@ -395,9 +395,9 @@ test("disabled - disables tool when denied", () => {
expect(result.has("read")).toBe(false)
})
test("disabled - disables edit/write/patch/multiedit when edit denied", () => {
test("disabled - disables edit/write/apply_patch/multiedit when edit denied", () => {
const result = PermissionNext.disabled(
["edit", "write", "patch", "multiedit", "bash"],
["edit", "write", "apply_patch", "multiedit", "bash"],
[
{ permission: "*", pattern: "*", action: "allow" },
{ permission: "edit", pattern: "*", action: "deny" },
@@ -405,7 +405,7 @@ test("disabled - disables edit/write/patch/multiedit when edit denied", () => {
)
expect(result.has("edit")).toBe(true)
expect(result.has("write")).toBe(true)
expect(result.has("patch")).toBe(true)
expect(result.has("apply_patch")).toBe(true)
expect(result.has("multiedit")).toBe(true)
expect(result.has("bash")).toBe(false)
})
@@ -1005,7 +1005,7 @@ test("ask - abort should clear pending request", async () => {
fn: async () => {
const ctl = new AbortController()
const ask = runtime.runPromise(
S.PermissionService.use((svc) =>
S.PermissionEffect.Service.use((svc) =>
svc.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",

View File

@@ -1,73 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Scheduler } from "../src/scheduler"
import { Instance } from "../src/project/instance"
import { tmpdir } from "./fixture/fixture"
describe("Scheduler.register", () => {
const hour = 60 * 60 * 1000
test("defaults to instance scope per directory", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
const runs = { count: 0 }
const id = "scheduler.instance." + Math.random().toString(36).slice(2)
const task = {
id,
interval: hour,
run: async () => {
runs.count += 1
},
}
await Instance.provide({
directory: one.path,
fn: async () => {
Scheduler.register(task)
await Instance.dispose()
},
})
expect(runs.count).toBe(1)
await Instance.provide({
directory: two.path,
fn: async () => {
Scheduler.register(task)
await Instance.dispose()
},
})
expect(runs.count).toBe(2)
})
test("global scope runs once across instances", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
const runs = { count: 0 }
const id = "scheduler.global." + Math.random().toString(36).slice(2)
const task = {
id,
interval: hour,
run: async () => {
runs.count += 1
},
scope: "global" as const,
}
await Instance.provide({
directory: one.path,
fn: async () => {
Scheduler.register(task)
await Instance.dispose()
},
})
expect(runs.count).toBe(1)
await Instance.provide({
directory: two.path,
fn: async () => {
Scheduler.register(task)
await Instance.dispose()
},
})
expect(runs.count).toBe(1)
})
})

View File

@@ -5,8 +5,8 @@ import { BashTool } from "../../src/tool/bash"
import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
import type { PermissionNext } from "../../src/permission/next"
import { Truncate } from "../../src/tool/truncation"
import type { PermissionNext } from "../../src/permission"
import { Truncate } from "../../src/tool/truncate"
import { SessionID, MessageID } from "../../src/session/schema"
const ctx = {

View File

@@ -3,7 +3,7 @@ import path from "path"
import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance"
import { assertExternalDirectory } from "../../src/tool/external-directory"
import type { PermissionNext } from "../../src/permission/next"
import type { PermissionNext } from "../../src/permission"
import { SessionID, MessageID } from "../../src/session/schema"
const baseCtx: Omit<Tool.Context, "ask"> = {

View File

@@ -4,7 +4,7 @@ import { ReadTool } from "../../src/tool/read"
import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
import { PermissionNext } from "../../src/permission/next"
import { PermissionNext } from "../../src/permission"
import { Agent } from "../../src/agent/agent"
import { SessionID, MessageID } from "../../src/session/schema"

View File

@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
import type { PermissionNext } from "../../src/permission/next"
import type { PermissionNext } from "../../src/permission"
import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance"
import { SkillTool } from "../../src/tool/skill"

View File

@@ -1,9 +1,13 @@
import { describe, test, expect, afterAll } from "bun:test"
import { Truncate } from "../../src/tool/truncation"
import { describe, test, expect } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, FileSystem, Layer } from "effect"
import { Truncate } from "../../src/tool/truncate"
import { TruncateEffect } from "../../src/tool/truncate-effect"
import { Identifier } from "../../src/id/id"
import { Filesystem } from "../../src/util/filesystem"
import fs from "fs/promises"
import path from "path"
import { testEffect } from "../lib/effect"
import { writeFileStringScoped } from "../lib/filesystem"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
@@ -125,36 +129,24 @@ describe("Truncate", () => {
describe("cleanup", () => {
const DAY_MS = 24 * 60 * 60 * 1000
let oldFile: string
let recentFile: string
const it = testEffect(Layer.mergeAll(TruncateEffect.defaultLayer, NodeFileSystem.layer))
afterAll(async () => {
await fs.unlink(oldFile).catch(() => {})
await fs.unlink(recentFile).catch(() => {})
})
it.effect("deletes files older than 7 days and preserves recent files", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
test("deletes files older than 7 days and preserves recent files", async () => {
await fs.mkdir(Truncate.DIR, { recursive: true })
yield* fs.makeDirectory(Truncate.DIR, { recursive: true })
// Create an old file (10 days ago)
const oldTimestamp = Date.now() - 10 * DAY_MS
const oldId = Identifier.create("tool", false, oldTimestamp)
oldFile = path.join(Truncate.DIR, oldId)
await Filesystem.write(oldFile, "old content")
const old = path.join(Truncate.DIR, Identifier.create("tool", false, Date.now() - 10 * DAY_MS))
const recent = path.join(Truncate.DIR, Identifier.create("tool", false, Date.now() - 3 * DAY_MS))
// Create a recent file (3 days ago)
const recentTimestamp = Date.now() - 3 * DAY_MS
const recentId = Identifier.create("tool", false, recentTimestamp)
recentFile = path.join(Truncate.DIR, recentId)
await Filesystem.write(recentFile, "recent content")
yield* writeFileStringScoped(old, "old content")
yield* writeFileStringScoped(recent, "recent content")
yield* TruncateEffect.Service.use((s) => s.cleanup())
await Truncate.cleanup()
// Old file should be deleted
expect(await Filesystem.exists(oldFile)).toBe(false)
// Recent file should still exist
expect(await Filesystem.exists(recentFile)).toBe(true)
})
expect(yield* fs.exists(old)).toBe(false)
expect(yield* fs.exists(recent)).toBe(true)
}),
)
})
})

View File

@@ -47,6 +47,13 @@ export type EventProjectUpdated = {
properties: Project
}
export type EventFileEdited = {
type: "file.edited"
properties: {
file: string
}
}
export type EventServerInstanceDisposed = {
type: "server.instance.disposed"
properties: {
@@ -54,6 +61,50 @@ export type EventServerInstanceDisposed = {
}
}
export type EventFileWatcherUpdated = {
type: "file.watcher.updated"
properties: {
file: string
event: "add" | "change" | "unlink"
}
}
export type PermissionRequest = {
id: string
sessionID: string
permission: string
patterns: Array<string>
metadata: {
[key: string]: unknown
}
always: Array<string>
tool?: {
messageID: string
callID: string
}
}
export type EventPermissionAsked = {
type: "permission.asked"
properties: PermissionRequest
}
export type EventPermissionReplied = {
type: "permission.replied"
properties: {
sessionID: string
requestID: string
reply: "once" | "always" | "reject"
}
}
export type EventVcsBranchUpdated = {
type: "vcs.branch.updated"
properties: {
branch?: string
}
}
export type QuestionOption = {
/**
* Display text (1-5 words, concise)
@@ -125,57 +176,6 @@ export type EventQuestionRejected = {
}
}
export type PermissionRequest = {
id: string
sessionID: string
permission: string
patterns: Array<string>
metadata: {
[key: string]: unknown
}
always: Array<string>
tool?: {
messageID: string
callID: string
}
}
export type EventPermissionAsked = {
type: "permission.asked"
properties: PermissionRequest
}
export type EventPermissionReplied = {
type: "permission.replied"
properties: {
sessionID: string
requestID: string
reply: "once" | "always" | "reject"
}
}
export type EventFileWatcherUpdated = {
type: "file.watcher.updated"
properties: {
file: string
event: "add" | "change" | "unlink"
}
}
export type EventVcsBranchUpdated = {
type: "vcs.branch.updated"
properties: {
branch?: string
}
}
export type EventFileEdited = {
type: "file.edited"
properties: {
file: string
}
}
export type EventServerConnected = {
type: "server.connected"
properties: {
@@ -961,15 +961,15 @@ export type Event =
| EventInstallationUpdated
| EventInstallationUpdateAvailable
| EventProjectUpdated
| EventFileEdited
| EventServerInstanceDisposed
| EventFileWatcherUpdated
| EventPermissionAsked
| EventPermissionReplied
| EventVcsBranchUpdated
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
| EventPermissionAsked
| EventPermissionReplied
| EventFileWatcherUpdated
| EventVcsBranchUpdated
| EventFileEdited
| EventServerConnected
| EventGlobalDisposed
| EventLspClientDiagnostics

View File

@@ -7043,6 +7043,25 @@
},
"required": ["type", "properties"]
},
"Event.file.edited": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "file.edited"
},
"properties": {
"type": "object",
"properties": {
"file": {
"type": "string"
}
},
"required": ["file"]
}
},
"required": ["type", "properties"]
},
"Event.server.instance.disposed": {
"type": "object",
"properties": {
@@ -7062,6 +7081,149 @@
},
"required": ["type", "properties"]
},
"Event.file.watcher.updated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "file.watcher.updated"
},
"properties": {
"type": "object",
"properties": {
"file": {
"type": "string"
},
"event": {
"anyOf": [
{
"type": "string",
"const": "add"
},
{
"type": "string",
"const": "change"
},
{
"type": "string",
"const": "unlink"
}
]
}
},
"required": ["file", "event"]
}
},
"required": ["type", "properties"]
},
"PermissionRequest": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^per.*"
},
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"permission": {
"type": "string"
},
"patterns": {
"type": "array",
"items": {
"type": "string"
}
},
"metadata": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"always": {
"type": "array",
"items": {
"type": "string"
}
},
"tool": {
"type": "object",
"properties": {
"messageID": {
"type": "string",
"pattern": "^msg.*"
},
"callID": {
"type": "string"
}
},
"required": ["messageID", "callID"]
}
},
"required": ["id", "sessionID", "permission", "patterns", "metadata", "always"]
},
"Event.permission.asked": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "permission.asked"
},
"properties": {
"$ref": "#/components/schemas/PermissionRequest"
}
},
"required": ["type", "properties"]
},
"Event.permission.replied": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "permission.replied"
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"requestID": {
"type": "string",
"pattern": "^per.*"
},
"reply": {
"type": "string",
"enum": ["once", "always", "reject"]
}
},
"required": ["sessionID", "requestID", "reply"]
}
},
"required": ["type", "properties"]
},
"Event.vcs.branch.updated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "vcs.branch.updated"
},
"properties": {
"type": "object",
"properties": {
"branch": {
"type": "string"
}
}
}
},
"required": ["type", "properties"]
},
"QuestionOption": {
"type": "object",
"properties": {
@@ -7212,168 +7374,6 @@
},
"required": ["type", "properties"]
},
"PermissionRequest": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^per.*"
},
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"permission": {
"type": "string"
},
"patterns": {
"type": "array",
"items": {
"type": "string"
}
},
"metadata": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"always": {
"type": "array",
"items": {
"type": "string"
}
},
"tool": {
"type": "object",
"properties": {
"messageID": {
"type": "string",
"pattern": "^msg.*"
},
"callID": {
"type": "string"
}
},
"required": ["messageID", "callID"]
}
},
"required": ["id", "sessionID", "permission", "patterns", "metadata", "always"]
},
"Event.permission.asked": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "permission.asked"
},
"properties": {
"$ref": "#/components/schemas/PermissionRequest"
}
},
"required": ["type", "properties"]
},
"Event.permission.replied": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "permission.replied"
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"requestID": {
"type": "string",
"pattern": "^per.*"
},
"reply": {
"type": "string",
"enum": ["once", "always", "reject"]
}
},
"required": ["sessionID", "requestID", "reply"]
}
},
"required": ["type", "properties"]
},
"Event.file.watcher.updated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "file.watcher.updated"
},
"properties": {
"type": "object",
"properties": {
"file": {
"type": "string"
},
"event": {
"anyOf": [
{
"type": "string",
"const": "add"
},
{
"type": "string",
"const": "change"
},
{
"type": "string",
"const": "unlink"
}
]
}
},
"required": ["file", "event"]
}
},
"required": ["type", "properties"]
},
"Event.vcs.branch.updated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "vcs.branch.updated"
},
"properties": {
"type": "object",
"properties": {
"branch": {
"type": "string"
}
}
}
},
"required": ["type", "properties"]
},
"Event.file.edited": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "file.edited"
},
"properties": {
"type": "object",
"properties": {
"file": {
"type": "string"
}
},
"required": ["file"]
}
},
"required": ["type", "properties"]
},
"Event.server.connected": {
"type": "object",
"properties": {
@@ -9608,9 +9608,24 @@
{
"$ref": "#/components/schemas/Event.project.updated"
},
{
"$ref": "#/components/schemas/Event.file.edited"
},
{
"$ref": "#/components/schemas/Event.server.instance.disposed"
},
{
"$ref": "#/components/schemas/Event.file.watcher.updated"
},
{
"$ref": "#/components/schemas/Event.permission.asked"
},
{
"$ref": "#/components/schemas/Event.permission.replied"
},
{
"$ref": "#/components/schemas/Event.vcs.branch.updated"
},
{
"$ref": "#/components/schemas/Event.question.asked"
},
@@ -9620,21 +9635,6 @@
{
"$ref": "#/components/schemas/Event.question.rejected"
},
{
"$ref": "#/components/schemas/Event.permission.asked"
},
{
"$ref": "#/components/schemas/Event.permission.replied"
},
{
"$ref": "#/components/schemas/Event.file.watcher.updated"
},
{
"$ref": "#/components/schemas/Event.vcs.branch.updated"
},
{
"$ref": "#/components/schemas/Event.file.edited"
},
{
"$ref": "#/components/schemas/Event.server.connected"
},

View File

@@ -15,6 +15,7 @@ export const lineCommentStyles = `
right: auto;
display: flex;
width: 100%;
min-width: 0;
align-items: flex-start;
}
@@ -64,6 +65,7 @@ export const lineCommentStyles = `
z-index: var(--line-comment-popover-z, 40);
min-width: 200px;
max-width: none;
box-sizing: border-box;
border-radius: 8px;
background: var(--surface-raised-stronger-non-alpha);
box-shadow: var(--shadow-xxs-border);
@@ -75,9 +77,10 @@ export const lineCommentStyles = `
top: auto;
right: auto;
margin-left: 8px;
flex: 0 1 600px;
width: min(100%, 600px);
max-width: min(100%, 600px);
flex: 1 1 0%;
width: auto;
max-width: 100%;
min-width: 0;
}
[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"][data-inline-body] {
@@ -96,23 +99,27 @@ export const lineCommentStyles = `
}
[data-component="line-comment"][data-inline][data-variant="editor"] [data-slot="line-comment-popover"] {
flex-basis: 600px;
width: 100%;
}
[data-component="line-comment"] [data-slot="line-comment-content"] {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
min-width: 0;
}
[data-component="line-comment"] [data-slot="line-comment-head"] {
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 0;
}
[data-component="line-comment"] [data-slot="line-comment-text"] {
flex: 1;
min-width: 0;
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-weight: var(--font-weight-regular);
@@ -120,6 +127,7 @@ export const lineCommentStyles = `
letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
[data-component="line-comment"] [data-slot="line-comment-tools"] {
@@ -127,6 +135,7 @@ export const lineCommentStyles = `
display: flex;
align-items: center;
justify-content: flex-end;
min-width: 0;
}
[data-component="line-comment"] [data-slot="line-comment-label"],
@@ -137,17 +146,22 @@ export const lineCommentStyles = `
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-weak);
white-space: nowrap;
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
}
[data-component="line-comment"] [data-slot="line-comment-editor"] {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
min-width: 0;
}
[data-component="line-comment"] [data-slot="line-comment-textarea"] {
width: 100%;
box-sizing: border-box;
resize: vertical;
padding: 8px;
border-radius: var(--radius-md);
@@ -167,11 +181,14 @@ export const lineCommentStyles = `
[data-component="line-comment"] [data-slot="line-comment-actions"] {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding-left: 8px;
min-width: 0;
}
[data-component="line-comment"] [data-slot="line-comment-editor-label"] {
flex: 1 1 220px;
margin-right: auto;
}

View File

@@ -206,6 +206,16 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
const [text, setText] = createSignal(split.value)
const focus = () => refs.textarea?.focus()
const hold: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> = (e) => {
e.preventDefault()
e.stopPropagation()
}
const click =
(fn: VoidFunction): JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> =>
(e) => {
e.stopPropagation()
fn()
}
createEffect(() => {
setText(split.value)
@@ -268,7 +278,8 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
type="button"
data-slot="line-comment-action"
data-variant="ghost"
on:click={split.onCancel as any}
on:mousedown={hold as any}
on:click={click(split.onCancel) as any}
>
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
</button>
@@ -277,7 +288,8 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
data-slot="line-comment-action"
data-variant="primary"
disabled={text().trim().length === 0}
on:click={submit as any}
on:mousedown={hold as any}
on:click={click(submit) as any}
>
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
</button>

View File

@@ -366,9 +366,11 @@ The model ID in your OpenCode config uses the format `provider/model-id`. For ex
---
### Tools
### Tools (deprecated)
Control which tools are available in this agent with the `tools` config. You can enable or disable specific tools by setting them to `true` or `false`.
`tools` is **deprecated**. Prefer the agent's [`permission`](#permissions) field for new configs, updates and more fine-grained control.
Allows you to control which tools are available in this agent. You can enable or disable specific tools by setting them to `true` or `false`. In an agent's `tools` config, `true` is equivalent to `{"*": "allow"}` permission and `false` is equivalent to `{"*": "deny"}` permission.
```json title="opencode.json" {3-6,9-12}
{
@@ -392,7 +394,7 @@ Control which tools are available in this agent with the `tools` config. You can
The agent-specific config overrides the global config.
:::
You can also use wildcards to control multiple tools at once. For example, to disable all tools from an MCP server:
You can also use wildcards in legacy `tools` entries to control multiple tools at once. For example, to disable all tools from an MCP server:
```json title="opencode.json"
{

View File

@@ -86,7 +86,6 @@ You can also access our models through the following API endpoints.
| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
| Gemini 3 Pro | gemini-3-pro | `https://opencode.ai/zen/v1/models/gemini-3-pro` | `@ai-sdk/google` |
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -95,8 +94,6 @@ You can also access our models through the following API endpoints.
| GLM 4.7 | glm-4.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| GLM 4.6 | glm-4.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2 Thinking | kimi-k2-thinking | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2 | kimi-k2 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Qwen3 Coder 480B | qwen3-coder | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo V2 Flash Free | mimo-v2-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -134,8 +131,6 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
| GLM 4.7 | $0.60 | $2.20 | $0.10 | - |
| GLM 4.6 | $0.60 | $2.20 | $0.10 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Kimi K2 Thinking | $0.40 | $2.50 | - | - |
| Kimi K2 | $0.40 | $2.50 | - | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
@@ -149,8 +144,6 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
| Claude Haiku 3.5 | $0.80 | $4.00 | $0.08 | $1.00 |
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.4 Pro | $30.00 | $180.00 | $30.00 | - |
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
@@ -206,12 +199,13 @@ charging you more than $20 if your balance goes below $5.
| Model | Deprecation date |
| ---------------- | ---------------- |
| Qwen3 Coder 480B | Feb 6, 2026 |
| Kimi K2 Thinking | March 6, 2026 |
| Kimi K2 | March 6, 2026 |
| MiniMax M2.1 | March 15, 2026 |
| GLM 4.7 | March 15, 2026 |
| GLM 4.6 | March 15, 2026 |
| Gemini 3 Pro | March 9, 2026 |
| Kimi K2 Thinking | March 6, 2026 |
| Kimi K2 | March 6, 2026 |
| Qwen3 Coder 480B | Feb 6, 2026 |
---

12
sst-env.d.ts vendored
View File

@@ -121,6 +121,18 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_CLIENT_ID": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_CLIENT_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"SALESFORCE_INSTANCE_URL": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_PUBLISHABLE_KEY": {
"type": "sst.sst.Secret"
"value": string