Compare commits

..

26 Commits

Author SHA1 Message Date
Shoubhit Dash
2f81e302f8 Merge branch 'dev' into review-git-changes 2026-03-18 15:51:23 +05:30
Shoubhit Dash
58e26ac413 refactor(opencode): parse vcs status with porcelain 2026-03-18 15:49:20 +05:30
Brendan Allan
331dacf9db app: remove debug text 2026-03-18 17:02:23 +08:00
Brendan Allan
4ba7d3b406 app: replace autoselect effects with single resource 2026-03-18 17:01:38 +08:00
Brendan Allan
a43783a6d4 app: initialize command catalog more efficiently
cuts down load times by like 30-50%
2026-03-18 14:46:53 +08:00
Frank
37c5295111 zen: gpt 5.4 mini and nano 2026-03-18 02:12:30 -04:00
Johannes Loher
56102ff642 fix(core): detect vLLM context overflow errors (#17763)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-17 23:52:16 -05:00
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
Shoubhit Dash
352af13d3a Merge branch 'dev' into review-git-changes 2026-03-18 00:31:41 +05:30
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
948138f385 feat(app): add git review modes 2026-03-17 19:43:44 +05:30
Shoubhit Dash
fbb074480d feat(opencode): add vcs diff api 2026-03-17 19:43:24 +05:30
104 changed files with 2354 additions and 1461 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()
})
@@ -19,42 +22,3 @@ test("server picker dialog opens from home", async ({ page }) => {
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("textbox").first()).toBeVisible()
})
test("home hides desktop history and sidebar controls", async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 })
await page.goto("/")
await expect(page.getByRole("button", { name: "Toggle sidebar" })).toHaveCount(0)
await expect(page.getByRole("button", { name: "Go back" })).toHaveCount(0)
await expect(page.getByRole("button", { name: "Go forward" })).toHaveCount(0)
await expect(page.getByRole("button", { name: "Toggle menu" })).toHaveCount(0)
})
test("home keeps the mobile menu available", async ({ page }) => {
await page.setViewportSize({ width: 430, height: 900 })
await page.goto("/")
const toggle = page.getByRole("button", { name: "Toggle menu" }).first()
await expect(toggle).toBeVisible()
await toggle.click()
const nav = page.locator('[data-component="sidebar-nav-mobile"]')
await expect(nav).toBeVisible()
await expect.poll(async () => (await nav.boundingBox())?.width ?? 0).toBeLessThan(120)
await expect(nav.getByRole("button", { name: "Settings" })).toBeVisible()
await expect(nav.getByRole("button", { name: "Help" })).toBeVisible()
await page.setViewportSize({ width: 1400, height: 900 })
await expect(nav).toBeHidden()
await page.setViewportSize({ width: 430, height: 900 })
await expect(toggle).toBeVisible()
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await expect(nav).toHaveClass(/-translate-x-full/)
await toggle.click()
await expect(nav).toBeVisible()
await nav.getByRole("button", { name: "Settings" }).click()
await expect(page.getByRole("dialog")).toBeVisible()
})

View File

@@ -58,7 +58,6 @@ export function Titlebar() {
})
const path = () => `${location.pathname}${location.search}${location.hash}`
const home = createMemo(() => !params.dir)
const creating = createMemo(() => {
if (!params.dir) return false
if (params.id) return false
@@ -78,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)
@@ -199,60 +199,60 @@ export function Titlebar() {
/>
</div>
</Show>
<Show when={!home()}>
<div class="flex items-center gap-1 shrink-0">
<TooltipKeybind
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
placement="bottom"
title={language.t("command.sidebar.toggle")}
keybind={command.keybind("sidebar.toggle")}
<div class="flex items-center gap-1 shrink-0">
<TooltipKeybind
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
placement="bottom"
title={language.t("command.sidebar.toggle")}
keybind={command.keybind("sidebar.toggle")}
>
<Button
variant="ghost"
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={layout.sidebar.toggle}
aria-label={language.t("command.sidebar.toggle")}
aria-expanded={layout.sidebar.opened()}
>
<Button
variant="ghost"
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={layout.sidebar.toggle}
aria-label={language.t("command.sidebar.toggle")}
aria-expanded={layout.sidebar.opened()}
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
</Button>
</TooltipKeybind>
<div class="hidden xl:flex items-center shrink-0">
<Show when={params.dir}>
<div
class="flex items-center shrink-0 w-8 mr-1"
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
>
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
</Button>
</TooltipKeybind>
<div class="hidden xl:flex items-center shrink-0">
<Show when={params.dir}>
<div
class="flex items-center shrink-0 w-8 mr-1"
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
class="transition-opacity"
classList={{
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
}}
>
<div
class="transition-opacity"
classList={{
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
}}
<TooltipKeybind
placement="bottom"
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
openDelay={2000}
>
<TooltipKeybind
placement="bottom"
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
openDelay={2000}
>
<Button
variant="ghost"
icon={creating() ? "new-session-active" : "new-session"}
class="titlebar-icon w-8 h-6 p-0 box-border"
disabled={layout.sidebar.opened()}
tabIndex={layout.sidebar.opened() ? -1 : undefined}
onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}}
aria-label={language.t("command.session.new")}
aria-current={creating() ? "page" : undefined}
/>
</TooltipKeybind>
</div>
<Button
variant="ghost"
icon={creating() ? "new-session-active" : "new-session"}
class="titlebar-icon w-8 h-6 p-0 box-border"
disabled={layout.sidebar.opened()}
tabIndex={layout.sidebar.opened() ? -1 : undefined}
onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}}
aria-label={language.t("command.session.new")}
aria-current={creating() ? "page" : undefined}
/>
</TooltipKeybind>
</div>
</Show>
</div>
</Show>
<Show when={hasProjects()}>
<div
class="flex items-center gap-0 transition-transform"
classList={{
@@ -283,9 +283,9 @@ export function Titlebar() {
/>
</Tooltip>
</div>
</div>
</Show>
</div>
</Show>
</div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
</div>

View File

@@ -1,10 +1,10 @@
import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { dict as en } from "@/i18n/en"
import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { dict as en } from "@/i18n/en"
import { Persist, persisted } from "@/utils/persist"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
@@ -238,9 +238,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
})
const warnedDuplicates = new Set<string>()
type CommandCatalog = Record<string, CommandCatalogItem>
const [catalog, setCatalog, _, catalogReady] = persisted(
Persist.global("command.catalog.v1"),
createStore<Record<string, CommandCatalogItem>>({}),
createStore<CommandCatalog>({}),
)
const bind = (id: string, def: KeybindConfig | undefined) => {
@@ -259,7 +260,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
if (seen.has(opt.id)) {
if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
warnedDuplicates.add(opt.id)
console.warn(`[command] duplicate command id \"${opt.id}\" registered; keeping first entry`)
console.warn(`[command] duplicate command id "${opt.id}" registered; keeping first entry`)
}
continue
}
@@ -274,16 +275,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
createEffect(() => {
if (!catalogReady()) return
for (const opt of registered()) {
const id = actionId(opt.id)
setCatalog(id, {
title: opt.title,
description: opt.description,
category: opt.category,
keybind: opt.keybind,
slash: opt.slash,
})
}
setCatalog(
registered().reduce((acc, opt) => {
const id = actionId(opt.id)
acc[id] = {
title: opt.title,
description: opt.description,
category: opt.category,
keybind: opt.keybind,
slash: opt.slash,
}
return acc
}, {} as CommandCatalog),
)
})
const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta })))

View File

@@ -158,7 +158,7 @@ export async function bootstrapDirectory(input: {
input.sdk.vcs.get().then((x) => {
const next = x.data ?? input.store.vcs
input.setStore("vcs", next)
if (next?.branch) input.vcsCache.setStore("value", next)
if (next) input.vcsCache.setStore("value", next)
}),
input.sdk.permission.list().then((x) => {
const grouped = groupBySession(

View File

@@ -268,9 +268,9 @@ export function applyDirectoryEvent(input: {
break
}
case "vcs.branch.updated": {
const props = event.properties as { branch: string }
const props = event.properties as { branch?: string }
if (input.store.vcs?.branch === props.branch) break
const next = { branch: props.branch }
const next = { ...input.store.vcs, branch: props.branch }
input.setStore("vcs", next)
if (input.vcsCache) input.vcsCache.setStore("value", next)
break

View File

@@ -532,6 +532,8 @@ export const dict = {
"session.review.noVcs.createGit.action": "Create Git repository",
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
"session.review.noChanges": "No changes",
"session.review.noUncommittedChanges": "No uncommitted changes yet",
"session.review.noBranchChanges": "No branch changes yet",
"session.files.selectToOpen": "Select a file to open",
"session.files.all": "All files",
@@ -674,6 +676,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

@@ -2,6 +2,7 @@ import {
batch,
createEffect,
createMemo,
createResource,
For,
on,
onCleanup,
@@ -200,20 +201,13 @@ export default function Layout(props: ParentProps) {
onMount(() => {
const stop = () => setState("sizing", false)
const sync = () => {
if (!window.matchMedia("(min-width: 1280px)").matches) return
layout.mobileSidebar.hide()
}
window.addEventListener("pointerup", stop)
window.addEventListener("pointercancel", stop)
window.addEventListener("blur", stop)
window.addEventListener("resize", sync)
sync()
onCleanup(() => {
window.removeEventListener("pointerup", stop)
window.removeEventListener("pointercancel", stop)
window.removeEventListener("blur", stop)
window.removeEventListener("resize", sync)
})
})
@@ -284,16 +278,6 @@ export default function Layout(props: ParentProps) {
setHoverProject(undefined)
})
const autoselecting = createMemo(() => {
if (params.dir) return false
if (!state.autoselect) return false
if (!pageReady()) return true
if (!layoutReady()) return true
const list = layout.projects.list()
if (list.length > 0) return true
return !!server.projects.last()
})
createEffect(() => {
if (!state.autoselect) return
const dir = params.dir
@@ -579,33 +563,22 @@ export default function Layout(props: ParentProps) {
return projects.find((p) => p.worktree === root)
})
createEffect(
on(
() => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }),
(value) => {
if (!value.ready) return
if (!value.layoutReady) return
if (!state.autoselect) return
if (value.dir) return
const [autoselecting] = createResource(async () => {
await ready.promise
await layout.ready.promise
const last = server.projects.last()
const list = layout.projects.list()
const last = server.projects.last()
if (value.list.length === 0) {
if (!last) return
setState("autoselect", false)
openProject(last, false)
navigateToProject(last)
return
}
const next = value.list.find((project) => project.worktree === last) ?? value.list[0]
if (!next) return
setState("autoselect", false)
openProject(next.worktree, false)
navigateToProject(next.worktree)
},
),
)
if (list.length === 0) {
if (!last) return
await openProject(last, true)
} else {
const next = list.find((project) => project.worktree === last) ?? list[0]
if (!next) return
await openProject(next.worktree, true)
}
})
const workspaceName = (directory: string, projectId?: string, branch?: string) => {
const key = workspaceKey(directory)
@@ -1318,7 +1291,7 @@ export default function Layout(props: ParentProps) {
function openProject(directory: string, navigate = true) {
layout.projects.open(directory)
if (navigate) navigateToProject(directory)
if (navigate) return navigateToProject(directory)
}
const handleDeepLinks = (urls: string[]) => {
@@ -1966,6 +1939,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 ""
@@ -2018,7 +1992,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">
@@ -2249,7 +2242,6 @@ export default function Layout(props: ParentProps) {
<SidebarContent
mobile={mobile}
opened={() => layout.sidebar.opened()}
hasPanel={() => !!currentProject()}
aimMove={aim.move}
projects={projects}
renderProject={(project) => (
@@ -2268,13 +2260,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 />
}
/>
)
@@ -2348,9 +2334,7 @@ export default function Layout(props: ParentProps) {
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-mobile"
classList={{
"@container fixed top-10 bottom-0 left-0 z-50 overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"w-16": !currentProject(),
"w-full max-w-[400px]": !!currentProject(),
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
@@ -2377,7 +2361,7 @@ export default function Layout(props: ParentProps) {
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
}}
>
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>

View File

@@ -15,7 +15,6 @@ import { type LocalProject } from "@/context/layout"
export const SidebarContent = (props: {
mobile?: boolean
opened: Accessor<boolean>
hasPanel?: Accessor<boolean>
aimMove: (event: MouseEvent) => void
projects: Accessor<LocalProject[]>
renderProject: (project: LocalProject) => JSX.Element
@@ -34,7 +33,6 @@ export const SidebarContent = (props: {
renderPanel: () => JSX.Element
}): JSX.Element => {
const expanded = createMemo(() => !!props.mobile || props.opened())
const hasPanel = createMemo(() => props.hasPanel?.() ?? true)
const placement = () => (props.mobile ? "bottom" : "right")
let panel: HTMLDivElement | undefined
@@ -113,17 +111,15 @@ export const SidebarContent = (props: {
</div>
</div>
<Show when={hasPanel()}>
<div
ref={(el) => {
panel = el
}}
classList={{ "flex-1 flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }}
aria-hidden={!expanded()}
>
{props.renderPanel()}
</div>
</Show>
<div
ref={(el) => {
panel = el
}}
classList={{ "flex-1 flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }}
aria-hidden={!expanded()}
>
{props.renderPanel()}
</div>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import {
batch,
@@ -57,6 +57,9 @@ import { formatServerError } from "@/utils/server-errors"
const emptyUserMessages: UserMessage[] = []
const emptyFollowups: (FollowupDraft & { id: string })[] = []
type ChangeMode = "git" | "branch" | "session" | "turn"
type VcsMode = "git" | "branch"
type SessionHistoryWindowInput = {
sessionID: () => string | undefined
messagesReady: () => boolean
@@ -415,15 +418,16 @@ export default function Page() {
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasReview = createMemo(() => reviewCount() > 0)
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasSessionReview = createMemo(() => sessionCount() > 0)
const canReview = createMemo(() => !!params.id)
const reviewTab = createMemo(() => isDesktop())
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: reviewTab,
hasReview,
hasReview: canReview,
})
const contextOpen = tabState.contextOpen
const openedTabs = tabState.openedTabs
@@ -499,11 +503,22 @@ export default function Page() {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "changes",
changes: "session" as "session" | "turn",
changes: "git" as ChangeMode,
newSessionWorktree: "main",
deferRender: false,
})
const [vcs, setVcs] = createStore({
diff: {
git: [] as FileDiff[],
branch: [] as FileDiff[],
},
ready: {
git: false,
branch: false,
},
})
const [followup, setFollowup] = createStore({
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
sending: {} as Record<string, string | undefined>,
@@ -531,6 +546,40 @@ export default function Page() {
let refreshTimer: number | undefined
let diffFrame: number | undefined
let diffTimer: number | undefined
const vcsTask = new Map<VcsMode, Promise<void>>()
const resetVcs = () => {
vcsTask.clear()
setVcs({
diff: { git: [], branch: [] },
ready: { git: false, branch: false },
})
}
const loadVcs = (mode: VcsMode, force = false) => {
if (sync.project?.vcs !== "git") return Promise.resolve()
if (vcs.ready[mode] && !force) return Promise.resolve()
const current = vcsTask.get(mode)
if (current) return current
const task = sdk.client.vcs
.diff({ mode })
.then((result) => {
setVcs("diff", mode, result.data ?? [])
setVcs("ready", mode, true)
})
.catch((error) => {
console.debug("[session-review] failed to load vcs diff", { mode, error })
setVcs("diff", mode, [])
setVcs("ready", mode, true)
})
.finally(() => {
vcsTask.delete(mode)
})
vcsTask.set(mode, task)
return task
}
createComputed((prev) => {
const open = desktopReviewOpen()
@@ -546,7 +595,38 @@ export default function Page() {
}, desktopReviewOpen())
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
const changesOptions = createMemo<ChangeMode[]>(() => {
const list: ChangeMode[] = []
const git = sync.project?.vcs === "git"
if (git) list.push("git")
if (git && sync.data.vcs?.branch && sync.data.vcs?.default_branch && sync.data.vcs.branch !== sync.data.vcs.default_branch) {
list.push("branch")
}
list.push("session", "turn")
return list
})
const vcsMode = createMemo<VcsMode | undefined>(() => {
if (store.changes === "git" || store.changes === "branch") return store.changes
})
const reviewDiffs = createMemo(() => {
if (store.changes === "git") return vcs.diff.git
if (store.changes === "branch") return vcs.diff.branch
if (store.changes === "session") return diffs()
return turnDiffs()
})
const reviewCount = createMemo(() => {
if (store.changes === "git") return vcs.diff.git.length
if (store.changes === "branch") return vcs.diff.branch.length
if (store.changes === "session") return sessionCount()
return turnDiffs().length
})
const hasReview = createMemo(() => reviewCount() > 0)
const reviewReady = createMemo(() => {
if (store.changes === "git") return vcs.ready.git
if (store.changes === "branch") return vcs.ready.branch
if (store.changes === "session") return !hasSessionReview() || diffsReady()
return true
})
const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create"
@@ -615,10 +695,10 @@ export default function Page() {
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasReview()) return true
if (!hasSessionReview()) return true
return sync.data.session_diff[id] !== undefined
})
const reviewEmptyKey = createMemo(() => {
const sessionEmptyKey = createMemo(() => {
const project = sync.project
if (project && !project.vcs) return "session.review.noVcs"
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
@@ -741,13 +821,23 @@ export default function Page() {
sessionKey,
() => {
setStore("messageId", undefined)
setStore("changes", "session")
setStore("changes", "git")
setUi("pendingMessage", undefined)
},
{ defer: true },
),
)
createEffect(
on(
() => sdk.directory,
() => {
resetVcs()
},
{ defer: true },
),
)
createEffect(
on(
() => params.dir,
@@ -870,6 +960,38 @@ export default function Page() {
}
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
const wantsReview = createMemo(() =>
isDesktop() ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") : store.mobileTab === "changes",
)
createEffect(() => {
const list = changesOptions()
if (list.includes(store.changes)) return
const next = list[0]
if (!next) return
setStore("changes", next)
})
createEffect(() => {
const mode = vcsMode()
if (!mode) return
if (!wantsReview()) return
void loadVcs(mode)
})
createEffect(
on(
() => sync.data.session_status[params.id ?? ""]?.type,
(next, prev) => {
const mode = vcsMode()
if (!mode) return
if (!wantsReview()) return
if (next !== "idle" || prev === undefined || prev === "idle") return
void loadVcs(mode, true)
},
{ defer: true },
),
)
const fileTreeTab = () => layout.fileTree.tab()
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
@@ -916,21 +1038,23 @@ export default function Page() {
loadFile: file.load,
})
const changesOptions = ["session", "turn"] as const
const changesOptionsList = [...changesOptions]
const changesTitle = () => {
if (!hasReview()) {
if (!canReview()) {
return null
}
const label = (option: ChangeMode) => {
if (option === "git") return language.t("ui.sessionReview.title.git")
if (option === "branch") return language.t("ui.sessionReview.title.branch")
if (option === "session") return language.t("ui.sessionReview.title")
return language.t("ui.sessionReview.title.lastTurn")
}
return (
<Select
options={changesOptionsList}
options={changesOptions()}
current={store.changes}
label={(option) =>
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
}
label={label}
onSelect={(option) => option && setStore("changes", option)}
variant="ghost"
size="small"
@@ -939,20 +1063,34 @@ export default function Page() {
)
}
const emptyTurn = () => (
const empty = (text: string) => (
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
<div class="text-14-regular text-text-weak max-w-56">{text}</div>
</div>
)
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
if (store.changes === "turn") return emptyTurn()
const reviewEmptyText = createMemo(() => {
if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
if (store.changes === "turn") return language.t("session.review.noChanges")
return language.t(sessionEmptyKey())
})
if (hasReview() && !diffsReady()) {
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
if (store.changes === "git" || store.changes === "branch") {
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
return empty(reviewEmptyText())
}
if (store.changes === "turn") {
return empty(reviewEmptyText())
}
if (hasSessionReview() && !diffsReady()) {
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
}
if (reviewEmptyKey() === "session.review.noVcs") {
if (sessionEmptyKey() === "session.review.noVcs") {
return (
<div class={input.emptyClass}>
<div class="flex flex-col gap-3">
@@ -972,7 +1110,7 @@ export default function Page() {
return (
<div class={input.emptyClass}>
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
<div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>
</div>
)
}
@@ -1076,7 +1214,7 @@ export default function Page() {
const pending = tree.pendingDiff
if (!pending) return
if (!tree.reviewScroll) return
if (!diffsReady()) return
if (!reviewReady()) return
const attempt = (count: number) => {
if (tree.pendingDiff !== pending) return
@@ -1808,6 +1946,12 @@ export default function Page() {
</div>
<SessionSidePanel
canReview={canReview}
diffs={reviewDiffs}
diffsReady={reviewReady}
empty={reviewEmptyText}
hasReview={hasReview}
reviewCount={reviewCount}
reviewPanel={reviewPanel}
activeDiff={tree.activeDiff}
focusReviewDiff={focusReviewDiff}

View File

@@ -8,6 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import type { FileDiff } from "@opencode-ai/sdk/v2"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -19,7 +20,6 @@ import { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
@@ -27,6 +27,12 @@ import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
export function SessionSidePanel(props: {
canReview: () => boolean
diffs: () => FileDiff[]
diffsReady: () => boolean
empty: () => string
hasReview: () => boolean
reviewCount: () => number
reviewPanel: () => JSX.Element
activeDiff?: string
focusReviewDiff: (path: string) => void
@@ -34,12 +40,11 @@ export function SessionSidePanel(props: {
size: Sizing
}) {
const layout = useLayout()
const sync = useSync()
const file = useFile()
const language = useLanguage()
const command = useCommand()
const dialog = useDialog()
const { params, sessionKey, tabs, view } = useSessionLayout()
const { sessionKey, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
@@ -54,24 +59,7 @@ export function SessionSidePanel(props: {
})
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasReview = createMemo(() => reviewCount() > 0)
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasReview()) return true
return sync.data.session_diff[id] !== undefined
})
const reviewEmptyKey = createMemo(() => {
if (sync.project && !sync.project.vcs) return "session.review.noVcs"
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
return "session.review.noChanges"
})
const diffFiles = createMemo(() => diffs().map((d) => d.file))
const diffFiles = createMemo(() => props.diffs().map((d) => d.file))
const kinds = createMemo(() => {
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
if (!a) return b
@@ -82,7 +70,7 @@ export function SessionSidePanel(props: {
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
const out = new Map<string, "add" | "del" | "mix">()
for (const diff of diffs()) {
for (const diff of props.diffs()) {
const file = normalize(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
@@ -136,7 +124,7 @@ export function SessionSidePanel(props: {
pathFromTab: file.pathFromTab,
normalizeTab,
review: reviewTab,
hasReview,
hasReview: props.canReview,
})
const contextOpen = tabState.contextOpen
const openedTabs = tabState.openedTabs
@@ -241,12 +229,12 @@ export function SessionSidePanel(props: {
onCleanup(stop)
}}
>
<Show when={reviewTab()}>
<Show when={reviewTab() && props.canReview()}>
<Tabs.Trigger value="review">
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={hasReview()}>
<div>{reviewCount()}</div>
<Show when={props.hasReview()}>
<div>{props.reviewCount()}</div>
</Show>
</div>
</Tabs.Trigger>
@@ -303,7 +291,7 @@ export function SessionSidePanel(props: {
</Tabs.List>
</div>
<Show when={reviewTab()}>
<Show when={reviewTab() && props.canReview()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
</Tabs.Content>
@@ -377,8 +365,8 @@ export function SessionSidePanel(props: {
>
<Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
{reviewCount()}{" "}
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
{props.reviewCount()}{" "}
{language.t(props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
</Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
{language.t("session.files.all")}
@@ -386,9 +374,9 @@ export function SessionSidePanel(props: {
</Tabs.List>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={hasReview()}>
<Match when={props.hasReview() || !props.diffsReady()}>
<Show
when={diffsReady()}
when={props.diffsReady()}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
@@ -408,9 +396,7 @@ export function SessionSidePanel(props: {
</Show>
</Match>
<Match when={true}>
{empty(
language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
)}
{empty(props.empty())}
</Match>
</Switch>
</Tabs.Content>

View File

@@ -56,11 +56,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (!id) return
return sync.session.get(id)
}
const hasReview = () => {
const id = params.id
if (!id) return false
return Math.max(info()?.summary?.files ?? 0, (sync.data.session_diff[id] ?? []).length) > 0
}
const hasReview = () => !!params.id
const normalizeTab = (tab: string) => {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)

View File

@@ -5,7 +5,12 @@ import { createResource, type Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
type InitType = Promise<string> | string | null
type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>]
type PersistedWithReady<T> = [
Store<T>,
SetStoreFunction<T>,
InitType,
Accessor<boolean> & { promise: undefined | Promise<any> },
]
type PersistTarget = {
storage?: string
@@ -460,5 +465,12 @@ export function persisted<T>(
{ initialValue: !isAsync },
)
return [state, setState, init, () => ready() === true]
return [
state,
setState,
init,
Object.assign(() => ready() === true, {
promise: init instanceof Promise ? init : undefined,
}),
]
}

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

@@ -6,11 +6,278 @@ import { Instance } from "./instance"
import { InstanceContext } from "@/effect/instance-context"
import { FileWatcher } from "@/file/watcher"
import { git } from "@/util/git"
import { Filesystem } from "@/util/filesystem"
import { Snapshot } from "@/snapshot"
import { Effect, Layer, ServiceMap } from "effect"
import path from "path"
const log = Log.create({ service: "vcs" })
const cfg = [
"--no-optional-locks",
"-c",
"core.autocrlf=false",
"-c",
"core.fsmonitor=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
] as const
type Base = { name: string; ref: string }
type Item = { file: string; code: string; status: Snapshot.FileDiff["status"] }
async function mapLimit<T, R>(list: T[], limit: number, fn: (item: T) => Promise<R>) {
const size = Math.max(1, limit)
const out: R[] = new Array(list.length)
let idx = 0
await Promise.all(
Array.from({ length: Math.min(size, list.length) }, async () => {
while (true) {
const i = idx
idx += 1
if (i >= list.length) return
out[i] = await fn(list[i]!)
}
}),
)
return out
}
function out(result: { text(): string }) {
return result.text().trim()
}
async function run(cwd: string, args: string[]) {
return git([...cfg, ...args], { cwd })
}
async function branch(cwd: string) {
const result = await run(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])
if (result.exitCode !== 0) return
const text = out(result)
return text || undefined
}
async function prefix(cwd: string) {
const result = await run(cwd, ["rev-parse", "--show-prefix"])
if (result.exitCode !== 0) return ""
return out(result)
}
async function branches(cwd: string) {
const result = await run(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads"])
if (result.exitCode !== 0) return []
return out(result)
.split("\n")
.map((item) => item.trim())
.filter(Boolean)
}
async function configured(cwd: string, list: string[]) {
const result = await run(cwd, ["config", "init.defaultBranch"])
if (result.exitCode !== 0) return
const name = out(result)
if (!name || !list.includes(name)) return
const ref = await run(cwd, ["rev-parse", "--verify", name])
if (ref.exitCode !== 0) return
return { name, ref: name } satisfies Base
}
async function remoteHead(cwd: string, remote: string) {
const result = await run(cwd, ["ls-remote", "--symref", remote, "HEAD"])
if (result.exitCode !== 0) return
for (const line of result.text().split("\n")) {
const match = /^ref: refs\/heads\/(.+)\tHEAD$/.exec(line.trim())
if (!match?.[1]) continue
return { name: match[1], ref: `${remote}/${match[1]}` } satisfies Base
}
}
async function primary(cwd: string) {
const result = await run(cwd, ["remote"])
const list =
result.exitCode !== 0
? []
: out(result)
.split("\n")
.map((item) => item.trim())
.filter(Boolean)
if (list.includes("origin")) return "origin"
if (list.length === 1) return list[0]
if (list.includes("upstream")) return "upstream"
return list[0]
}
async function base(cwd: string) {
const remote = await primary(cwd)
if (remote) {
const head = await run(cwd, ["symbolic-ref", `refs/remotes/${remote}/HEAD`])
if (head.exitCode === 0) {
const ref = out(head).replace(/^refs\/remotes\//, "")
const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : ""
if (name) return { name, ref } satisfies Base
}
const next = await remoteHead(cwd, remote)
if (next) return next
}
const list = await branches(cwd)
const next = await configured(cwd, list)
if (next) return next
for (const name of ["main", "master"]) {
if (list.includes(name)) return { name, ref: name }
}
}
async function head(cwd: string) {
const result = await run(cwd, ["rev-parse", "--verify", "HEAD"])
return result.exitCode === 0
}
async function work(cwd: string, file: string) {
const full = path.join(cwd, file)
if (!(await Filesystem.exists(full))) return ""
const buf = await Filesystem.readBytes(full).catch(() => Buffer.alloc(0))
if (buf.includes(0)) return ""
return buf.toString("utf8")
}
async function show(cwd: string, ref: string | undefined, file: string, base: string) {
if (!ref) return ""
const target = base ? `${base}${file}` : file
const result = await run(cwd, ["show", `${ref}:${target}`])
if (result.exitCode !== 0) return ""
return result.text()
}
function kind(code: string | undefined): "added" | "deleted" | "modified" {
if (code === "??") return "added"
if (code?.includes("U")) return "modified"
if (code?.includes("A") && !code.includes("D")) return "added"
if (code?.includes("D") && !code.includes("A")) return "deleted"
return "modified"
}
function count(text: string) {
if (!text) return 0
if (!text.endsWith("\n")) return text.split("\n").length
return text.slice(0, -1).split("\n").length
}
function split(text: string) {
return text.split("\0").filter(Boolean)
}
function parseStatus(text: string) {
return split(text).flatMap((item) => {
const file = item.slice(3)
if (!file) return []
const code = item.slice(0, 2)
return [{ file, code, status: kind(code) } satisfies Item]
})
}
function parseNames(text: string) {
const list = split(text)
const out: Item[] = []
for (let i = 0; i < list.length; i += 2) {
const code = list[i]
const file = list[i + 1]
if (!code || !file) continue
out.push({ file, code, status: kind(code) })
}
return out
}
function parseNums(text: string) {
const out = new Map<string, { additions: number; deletions: number }>()
for (const item of split(text)) {
const a = item.indexOf("\t")
const b = item.indexOf("\t", a + 1)
if (a === -1 || b === -1) continue
const file = item.slice(b + 1)
if (!file) continue
const adds = item.slice(0, a)
const dels = item.slice(a + 1, b)
const additions = adds === "-" ? 0 : Number.parseInt(adds || "0", 10)
const deletions = dels === "-" ? 0 : Number.parseInt(dels || "0", 10)
out.set(file, {
additions: Number.isFinite(additions) ? additions : 0,
deletions: Number.isFinite(deletions) ? deletions : 0,
})
}
return out
}
function merge(...lists: Item[][]) {
const out = new Map<string, Item>()
for (const list of lists) {
for (const item of list) {
if (!out.has(item.file)) out.set(item.file, item)
}
}
return [...out.values()]
}
async function files(cwd: string, ref: string | undefined, list: Item[], nums: Map<string, { additions: number; deletions: number }>) {
const base = ref ? await prefix(cwd) : ""
const next = await mapLimit(list, 8, async (item) => {
const before = item.status === "added" ? "" : await show(cwd, ref, item.file, base)
const after = item.status === "deleted" ? "" : await work(cwd, item.file)
const stat = nums.get(item.file)
return {
file: item.file,
before,
after,
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
status: item.status,
} satisfies Snapshot.FileDiff
})
return next.toSorted((a, b) => a.file.localeCompare(b.file))
}
async function status(cwd: string) {
const result = await run(cwd, ["status", "--porcelain=v1", "--untracked-files=all", "--no-renames", "-z", "--", "."])
return parseStatus(result.text())
}
async function stats(cwd: string, ref: string) {
const result = await run(cwd, ["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."])
return parseNums(result.text())
}
async function diff(cwd: string, ref: string) {
const result = await run(cwd, ["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."])
return parseNames(result.text())
}
async function track(cwd: string, ref: string | undefined) {
const [list, nums] = ref ? await Promise.all([status(cwd), stats(cwd, ref)]) : [await status(cwd), new Map()]
return files(cwd, ref, list, nums)
}
async function compare(cwd: string, ref: string) {
const [list, nums, extra] = await Promise.all([diff(cwd, ref), stats(cwd, ref), status(cwd)])
return files(
cwd,
ref,
merge(
list,
extra.filter((item) => item.code === "??"),
),
nums,
)
}
export namespace Vcs {
export const Mode = z.enum(["git", "branch"])
export type Mode = z.infer<typeof Mode>
export const Event = {
BranchUpdated: BusEvent.define(
"vcs.branch.updated",
@@ -22,7 +289,8 @@ export namespace Vcs {
export const Info = z
.object({
branch: z.string(),
branch: z.string().optional(),
default_branch: z.string().optional(),
})
.meta({
ref: "VcsInfo",
@@ -34,6 +302,8 @@ export namespace VcsService {
export interface Service {
readonly init: () => Effect.Effect<void>
readonly branch: () => Effect.Effect<string | undefined>
readonly defaultBranch: () => Effect.Effect<string | undefined>
readonly diff: (mode: Vcs.Mode) => Effect.Effect<Snapshot.FileDiff[]>
}
}
@@ -43,19 +313,15 @@ export class VcsService extends ServiceMap.Service<VcsService, VcsService.Servic
Effect.gen(function* () {
const instance = yield* InstanceContext
let current: string | undefined
let root: Base | undefined
if (instance.project.vcs === "git") {
const currentBranch = async () => {
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
cwd: instance.project.worktree,
})
if (result.exitCode !== 0) return undefined
const text = result.text().trim()
return text || undefined
return branch(instance.directory)
}
current = yield* Effect.promise(() => currentBranch())
log.info("initialized", { branch: current })
;[current, root] = yield* Effect.promise(() => Promise.all([currentBranch(), base(instance.directory)]))
log.info("initialized", { branch: current, default_branch: root?.name })
const unsubscribe = Bus.subscribe(
FileWatcher.Event.Updated,
@@ -78,6 +344,24 @@ export class VcsService extends ServiceMap.Service<VcsService, VcsService.Servic
branch: Effect.fn("VcsService.branch")(function* () {
return current
}),
defaultBranch: Effect.fn("VcsService.defaultBranch")(function* () {
return root?.name
}),
diff: Effect.fn("VcsService.diff")(function* (mode: Vcs.Mode) {
if (instance.project.vcs !== "git") return []
if (mode === "git") {
const ok = yield* Effect.promise(() => head(instance.directory))
return yield* Effect.promise(() => track(instance.directory, ok ? "HEAD" : undefined))
}
if (!root) return []
if (current && current === root.name) return []
const ref = yield* Effect.promise(() => run(instance.directory, ["merge-base", root.ref, "HEAD"]))
if (ref.exitCode !== 0) return []
const text = out(ref)
if (!text) return []
return yield* Effect.promise(() => compare(instance.directory, text))
}),
})
}),
)

View File

@@ -13,7 +13,7 @@ export namespace ProviderError {
/input token count.*exceeds the maximum/i, // Google (Gemini)
/maximum prompt length is \d+/i, // xAI (Grok)
/reduce the length of the messages/i, // Groq
/maximum context length is \d+ tokens/i, // OpenRouter, DeepSeek
/maximum context length is \d+ tokens/i, // OpenRouter, DeepSeek, vLLM
/exceeds the limit of \d+/i, // GitHub Copilot
/exceeds the available context size/i, // llama.cpp server
/greater than the context length/i, // LM Studio
@@ -21,6 +21,8 @@ export namespace ProviderError {
/exceeded model token limit/i, // Kimi For Coding, Moonshot
/context[_ ]length[_ ]exceeded/i, // Generic fallback
/request entity too large/i, // HTTP 413
/context length is only \d+ tokens/i, // vLLM
/input length.*exceeds.*context length/i, // vLLM
]
function isOpenAiErrorRetryable(e: APICallError) {

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

@@ -41,6 +41,7 @@ import { websocket } from "hono/bun"
import { HTTPException } from "hono/http-exception"
import { errors } from "./error"
import { Filesystem } from "@/util/filesystem"
import { Snapshot } from "@/snapshot"
import { QuestionRoutes } from "./routes/question"
import { PermissionRoutes } from "./routes/permission"
import { GlobalRoutes } from "./routes/global"
@@ -331,10 +332,39 @@ export namespace Server {
},
}),
async (c) => {
const branch = await runPromiseInstance(VcsService.use((s) => s.branch()))
return c.json({
branch,
})
const [branch, default_branch] = await Promise.all([
runPromiseInstance(VcsService.use((s) => s.branch())),
runPromiseInstance(VcsService.use((s) => s.defaultBranch())),
])
return c.json({ branch, default_branch })
},
)
.get(
"/vcs/diff",
describeRoute({
summary: "Get VCS diff",
description: "Retrieve the current git diff for the working tree or against the default branch.",
operationId: "vcs.diff",
responses: {
200: {
description: "VCS diff",
content: {
"application/json": {
schema: resolver(Snapshot.FileDiff.array()),
},
},
},
},
}),
validator(
"query",
z.object({
mode: Vcs.Mode,
}),
),
async (c) => {
const mode = c.req.valid("query").mode
return c.json(await runPromiseInstance(VcsService.use((s) => s.diff(mode))))
},
)
.get(

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

@@ -34,6 +34,10 @@ function withVcs(
)
}
function withVcsOnly(directory: string, body: (rt: ManagedRuntime.ManagedRuntime<VcsService, never>) => Promise<void>) {
return withServices(directory, VcsService.layer, body)
}
type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } }
/** Wait for a Vcs.Event.BranchUpdated event on GlobalBus */
@@ -115,3 +119,104 @@ describeVcs("Vcs", () => {
})
})
})
describe("Vcs diff", () => {
afterEach(() => Instance.disposeAll())
test("defaultBranch() falls back to main", async () => {
await using tmp = await tmpdir({ git: true })
await $`git branch -M main`.cwd(tmp.path).quiet()
await withVcsOnly(tmp.path, async (rt) => {
const branch = await rt.runPromise(VcsService.use((s) => s.defaultBranch()))
expect(branch).toBe("main")
})
})
test("defaultBranch() uses init.defaultBranch when available", async () => {
await using tmp = await tmpdir({ git: true })
await $`git branch -M trunk`.cwd(tmp.path).quiet()
await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet()
await withVcsOnly(tmp.path, async (rt) => {
const branch = await rt.runPromise(VcsService.use((s) => s.defaultBranch()))
expect(branch).toBe("trunk")
})
})
test("detects current branch from the active worktree", async () => {
await using tmp = await tmpdir({ git: true })
await using wt = await tmpdir()
await $`git branch -M main`.cwd(tmp.path).quiet()
const dir = path.join(wt.path, "feature")
await $`git worktree add -b feature/test ${dir} HEAD`.cwd(tmp.path).quiet()
await withVcsOnly(dir, async (rt) => {
const [branch, base] = await Promise.all([
rt.runPromise(VcsService.use((s) => s.branch())),
rt.runPromise(VcsService.use((s) => s.defaultBranch())),
])
expect(branch).toBe("feature/test")
expect(base).toBe("main")
})
})
test("diff('git') returns uncommitted changes", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "file.txt"), "original\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
await fs.writeFile(path.join(tmp.path, "file.txt"), "changed\n", "utf-8")
await withVcsOnly(tmp.path, async (rt) => {
const diff = await rt.runPromise(VcsService.use((s) => s.diff("git")))
expect(diff).toEqual(
expect.arrayContaining([
expect.objectContaining({
file: "file.txt",
status: "modified",
}),
]),
)
})
})
test("diff('git') handles tabs in filenames", async () => {
await using tmp = await tmpdir({ git: true })
const file = "tab\tfile.txt"
await fs.writeFile(path.join(tmp.path, file), "hello\n", "utf-8")
await withVcsOnly(tmp.path, async (rt) => {
const diff = await rt.runPromise(VcsService.use((s) => s.diff("git")))
expect(diff).toEqual(
expect.arrayContaining([
expect.objectContaining({
file,
status: "added",
}),
]),
)
})
})
test("diff('branch') returns changes against default branch", async () => {
await using tmp = await tmpdir({ git: true })
await $`git branch -M main`.cwd(tmp.path).quiet()
await $`git checkout -b feature/test`.cwd(tmp.path).quiet()
await fs.writeFile(path.join(tmp.path, "branch.txt"), "hello\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet()
await withVcsOnly(tmp.path, async (rt) => {
const diff = await rt.runPromise(VcsService.use((s) => s.diff("branch")))
expect(diff).toEqual(
expect.arrayContaining([
expect.objectContaining({
file: "branch.txt",
status: "added",
}),
]),
)
})
})
})

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

@@ -172,6 +172,7 @@ import type {
TuiSelectSessionResponses,
TuiShowToastResponses,
TuiSubmitPromptResponses,
VcsDiffResponses,
VcsGetResponses,
WorktreeCreateErrors,
WorktreeCreateInput,
@@ -3657,6 +3658,38 @@ export class Vcs extends HeyApiClient {
...params,
})
}
/**
* Get VCS diff
*
* Retrieve the current git diff for the working tree or against the default branch.
*/
public diff<ThrowOnError extends boolean = false>(
parameters: {
directory?: string
workspace?: string
mode: "git" | "branch"
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "query", key: "mode" },
],
},
],
)
return (options?.client ?? this.client).get<VcsDiffResponses, unknown, ThrowOnError>({
url: "/vcs/diff",
...options,
...params,
})
}
}
export class Command extends HeyApiClient {

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
@@ -1861,7 +1861,8 @@ export type Path = {
}
export type VcsInfo = {
branch: string
branch?: string
default_branch?: string
}
export type Command = {
@@ -4799,6 +4800,26 @@ export type VcsGetResponses = {
export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses]
export type VcsDiffData = {
body?: never
path?: never
query: {
directory?: string
workspace?: string
mode: "git" | "branch"
}
url: "/vcs/diff"
}
export type VcsDiffResponses = {
/**
* VCS diff
*/
200: Array<FileDiff>
}
export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses]
export type CommandListData = {
body?: never
path?: never

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"
},

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