diff --git a/.opencode/agent/translator.md b/.opencode/agent/translator.md index 9cad228a21..3bea9df6bc 100644 --- a/.opencode/agent/translator.md +++ b/.opencode/agent/translator.md @@ -594,7 +594,6 @@ KILO_DISABLE_CLAUDE_CODE KILO_DISABLE_CLAUDE_CODE_PROMPT KILO_DISABLE_CLAUDE_CODE_SKILLS KILO_DISABLE_DEFAULT_PLUGINS -KILO_DISABLE_FILETIME_CHECK KILO_DISABLE_LSP_DOWNLOAD KILO_DISABLE_MODELS_FETCH KILO_DISABLE_PRUNE diff --git a/bun.lock b/bun.lock index 33d04612ee..f10612e92a 100644 --- a/bun.lock +++ b/bun.lock @@ -422,8 +422,8 @@ "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", - "@opentui/core": "0.1.99", - "@opentui/solid": "0.1.99", + "@opentui/core": "catalog:", + "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", @@ -525,16 +525,16 @@ "zod": "catalog:", }, "devDependencies": { - "@opentui/core": "0.1.99", - "@opentui/solid": "0.1.99", + "@opentui/core": "catalog:", + "@opentui/solid": "catalog:", "@tsconfig/node22": "catalog:", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.1.99", - "@opentui/solid": ">=0.1.99", + "@opentui/core": ">=0.1.100", + "@opentui/solid": ">=0.1.100", }, "optionalPeers": [ "@opentui/core", @@ -569,7 +569,7 @@ }, "packages/shared": { "name": "@opencode-ai/shared", - "version": "7.2.14", + "version": "7.2.20", "bin": { "opencode": "./bin/opencode", }, @@ -703,6 +703,8 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", + "@opentui/core": "0.1.99", + "@opentui/solid": "0.1.99", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@solid-primitives/storage": "4.3.3", diff --git a/package.json b/package.json index 00bf079938..8c5db2e769 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", + "@opentui/core": "0.1.99", + "@opentui/solid": "0.1.99", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 7acfdfc374..d61335d9f0 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -8,7 +8,7 @@ import { Spinner } from "@opencode-ai/ui/spinner" import { showToast } from "@opencode-ai/ui/toast" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { getFilename } from "@opencode-ai/shared/util/path" -import { createEffect, createMemo, For, Show } from "solid-js" +import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { useCommand } from "@/context/command" @@ -16,6 +16,7 @@ import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" import { useServer } from "@/context/server" +import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { focusTerminalById } from "@/pages/session/helpers" @@ -134,6 +135,7 @@ export function SessionHeader() { const server = useServer() const platform = usePlatform() const language = useLanguage() + const settings = useSettings() const sync = useSync() const terminal = useTerminal() const { params, view } = useSessionLayout() @@ -151,6 +153,11 @@ export function SessionHeader() { }) const hotkey = createMemo(() => command.keybind("file.open")) const os = createMemo(() => detectOS(platform)) + const isDesktopBeta = platform.platform === "desktop" && import.meta.env.VITE_KILO_CHANNEL === "beta" + const search = createMemo(() => !isDesktopBeta || settings.general.showSearch()) + const tree = createMemo(() => !isDesktopBeta || settings.general.showFileTree()) + const term = createMemo(() => !isDesktopBeta || settings.general.showTerminal()) + const status = createMemo(() => !isDesktopBeta || settings.general.showStatus()) const [exists, setExists] = createStore>>({ finder: true, @@ -262,12 +269,16 @@ export function SessionHeader() { .catch((err: unknown) => showRequestError(language, err)) } - const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center")) - const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) + const [centerMount, setCenterMount] = createSignal(null) + const [rightMount, setRightMount] = createSignal(null) + onMount(() => { + setCenterMount(document.getElementById("opencode-titlebar-center")) + setRightMount(document.getElementById("opencode-titlebar-right")) + }) return ( <> - + {(mount) => ( - + + + diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 43c45f44af..c8eac6e63f 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -106,6 +106,7 @@ export const SettingsGeneral: Component = () => { permission.disableAutoAccept(params.id, value) } + const desktop = createMemo(() => platform.platform === "desktop") const check = () => { if (!platform.checkUpdate) return @@ -279,6 +280,74 @@ export const SettingsGeneral: Component = () => { ) + const AdvancedSection = () => ( +
+

{language.t("settings.general.section.advanced")}

+ + + +
+ settings.general.setShowFileTree(checked)} + /> +
+
+ + +
+ settings.general.setShowNavigation(checked)} + /> +
+
+ + +
+ settings.general.setShowSearch(checked)} + /> +
+
+ + +
+ settings.general.setShowTerminal(checked)} + /> +
+
+ + +
+ settings.general.setShowStatus(checked)} + /> +
+
+
+
+ ) + const AppearanceSection = () => (

{language.t("settings.general.section.appearance")}

@@ -527,6 +596,7 @@ export const SettingsGeneral: Component = () => {
) + console.log(import.meta.env) return (
@@ -609,6 +679,10 @@ export const SettingsGeneral: Component = () => { ) }} + + + +
) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 0677a91496..86da0d684a 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -11,6 +11,7 @@ import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" +import { useSettings } from "@/context/settings" import { applyPath, backPath, forwardPath } from "./titlebar-history" type TauriDesktopWindow = { @@ -40,6 +41,7 @@ export function Titlebar() { const platform = usePlatform() const command = useCommand() const language = useLanguage() + const settings = useSettings() const theme = useTheme() const navigate = useNavigate() const location = useLocation() @@ -78,6 +80,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 nav = createMemo(() => import.meta.env.VITE_KILO_CHANNEL !== "beta" || settings.general.showNavigation()) const back = () => { const next = backPath(history) @@ -255,13 +258,12 @@ export function Titlebar() {
- +
+
+
+ +
+ + } + > <>
-
- +
+ + + +
{ + if (!panelProps.mobile) scrollContainerRef = el + }} + class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]" + > + + + {(directory) => ( + + )} + + +
+ + store.activeWorkspace} + workspaceLabel={workspaceLabel} + /> + +
- } - > - <> -
- -
-
- - - -
{ - if (!panelProps.mobile) scrollContainerRef = el - }} - class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]" - > - - - {(directory) => ( - - )} - - -
- - store.activeWorkspace} - workspaceLabel={workspaceLabel} - /> - -
-
- - -
- +
+
+ + )}
) - const [loading] = createResource( - () => route()?.store?.[0]?.bootstrapPromise, - (p) => p, - ) - return (
- {(autoselecting(), loading()) ?? ""} + {autoselecting() ?? ""}
diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 645810cd34..8a685af3fe 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -317,12 +317,11 @@ export const SortableWorkspace = (props: { }) const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local())) const boot = createMemo(() => open() || active()) - const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false) const count = createMemo(() => sessions()?.length ?? 0) const hasMore = createMemo(() => workspaceStore.sessionTotal > count()) + const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) })) const busy = createMemo(() => props.ctx.isBusy(props.directory)) - const wasBusy = createMemo((prev) => prev || busy(), false) - const loading = createMemo(() => open() && !booted() && count() === 0 && !wasBusy()) + const loading = () => query.isLoading const touch = createMediaQuery("(hover: none)") const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id))) const loadMore = async () => { @@ -427,7 +426,7 @@ export const SortableWorkspace = (props: { mobile={props.mobile} ctx={props.ctx} showNew={showNew} - loading={loading} + loading={() => query.isLoading && count() === 0} sessions={sessions} hasMore={hasMore} loadMore={loadMore} @@ -453,11 +452,10 @@ export const LocalWorkspace = (props: { }) const slug = createMemo(() => base64Encode(props.project.worktree)) const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) - const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) const count = createMemo(() => sessions()?.length ?? 0) const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) })) - const loading = createMemo(() => query.isPending && count() === 0) const hasMore = createMemo(() => workspace().store.sessionTotal > count()) + const loading = () => query.isLoading && count() === 0 const loadMore = async () => { workspace().setStore("limit", (limit) => (limit ?? 0) + 5) await globalSync.project.loadSessions(props.project.worktree) @@ -473,7 +471,7 @@ export const LocalWorkspace = (props: { mobile={props.mobile} ctx={props.ctx} showNew={() => false} - loading={() => query.isLoading} + loading={loading} sessions={sessions} hasMore={hasMore} loadMore={loadMore} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 90994bde67..787b219973 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,6 +1,6 @@ -import type { Project, UserMessage, VcsFileDiff } from "@kilocode/sdk/v2" +import type { Project, UserMessage } from "@kilocode/sdk/v2" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useMutation } from "@tanstack/solid-query" +import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query" import { batch, onCleanup, @@ -324,6 +324,7 @@ export default function Page() { const local = useLocal() const file = useFile() const sync = useSync() + const queryClient = useQueryClient() const dialog = useDialog() const language = useLanguage() const sdk = useSDK() @@ -518,26 +519,6 @@ export default function Page() { deferRender: false, }) - const [vcs, setVcs] = createStore<{ - diff: { - git: VcsFileDiff[] - branch: VcsFileDiff[] - } - ready: { - git: boolean - branch: boolean - } - }>({ - diff: { - git: [] as VcsFileDiff[], - branch: [] as VcsFileDiff[], - }, - ready: { - git: false, - branch: false, - }, - }) - const [followup, setFollowup] = persisted( Persist.workspace(sdk.directory, "followup", ["followup.v1"]), createStore<{ @@ -571,68 +552,6 @@ export default function Page() { let todoTimer: number | undefined let diffFrame: number | undefined let diffTimer: number | undefined - const vcsTask = new Map>() - const vcsRun = new Map() - - const bumpVcs = (mode: VcsMode) => { - const next = (vcsRun.get(mode) ?? 0) + 1 - vcsRun.set(mode, next) - return next - } - - const resetVcs = (mode?: VcsMode) => { - const list = mode ? [mode] : (["git", "branch"] as const) - list.forEach((item) => { - bumpVcs(item) - vcsTask.delete(item) - setVcs("diff", item, []) - setVcs("ready", item, false) - }) - } - - const loadVcs = (mode: VcsMode, force = false) => { - if (sync.project?.vcs !== "git") return Promise.resolve() - if (!force && vcs.ready[mode]) return Promise.resolve() - - if (force) { - if (vcsTask.has(mode)) bumpVcs(mode) - vcsTask.delete(mode) - setVcs("ready", mode, false) - } - - const current = vcsTask.get(mode) - if (current) return current - - const run = bumpVcs(mode) - - const task = sdk.client.vcs - .diff({ mode }) - .then((result) => { - if (vcsRun.get(mode) !== run) return - setVcs("diff", mode, list(result.data)) - setVcs("ready", mode, true) - }) - .catch((error) => { - if (vcsRun.get(mode) !== run) return - console.debug("[session-review] failed to load vcs diff", { mode, error }) - setVcs("diff", mode, []) - setVcs("ready", mode, true) - }) - .finally(() => { - if (vcsTask.get(mode) === task) vcsTask.delete(mode) - }) - - vcsTask.set(mode, task) - return task - } - - const refreshVcs = () => { - resetVcs() - const mode = untrack(vcsMode) - if (!mode) return - if (!untrack(wantsReview)) return - void loadVcs(mode, true) - } createComputed((prev) => { const open = desktopReviewOpen() @@ -663,21 +582,52 @@ export default function Page() { list.push("turn") return list }) + const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") + const wantsReview = createMemo(() => + isDesktop() + ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") + : store.mobileTab === "changes", + ) const vcsMode = createMemo(() => { if (store.changes === "git" || store.changes === "branch") return store.changes }) - const reviewDiffs = createMemo(() => { - if (store.changes === "git") return list(vcs.diff.git) - if (store.changes === "branch") return list(vcs.diff.branch) + const vcsKey = createMemo( + () => ["session-vcs", sdk.directory, sync.data.vcs?.branch ?? "", sync.data.vcs?.default_branch ?? ""] as const, + ) + const vcsQuery = createQuery(() => { + const mode = vcsMode() + const enabled = wantsReview() && sync.project?.vcs === "git" + + return { + queryKey: [...vcsKey(), mode] as const, + enabled, + staleTime: Number.POSITIVE_INFINITY, + gcTime: 60 * 1000, + queryFn: mode + ? () => + sdk.client.vcs + .diff({ mode }) + .then((result) => list(result.data)) + .catch((error) => { + console.debug("[session-review] failed to load vcs diff", { mode, error }) + return [] + }) + : skipToken, + } + }) + const refreshVcs = () => void queryClient.invalidateQueries({ queryKey: vcsKey() }) + const reviewDiffs = () => { + if (store.changes === "git" || store.changes === "branch") + // avoids suspense + return vcsQuery.isFetched ? (vcsQuery.data ?? []) : [] return turnDiffs() - }) - const reviewCount = createMemo(() => reviewDiffs().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 + } + const reviewCount = () => reviewDiffs().length + const hasReview = () => reviewCount() > 0 + const reviewReady = () => { + if (store.changes === "git" || store.changes === "branch") return !vcsQuery.isPending return true - }) + } const newSessionWorktree = createMemo(() => { if (store.newSessionWorktree === "create") return "create" @@ -897,27 +847,6 @@ export default function Page() { ), ) - createEffect( - on( - () => sdk.directory, - () => { - resetVcs() - }, - { defer: true }, - ), - ) - - createEffect( - on( - () => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const, - (next, prev) => { - if (prev === undefined || same(next, prev)) return - refreshVcs() - }, - { defer: true }, - ), - ) - const stopVcs = sdk.event.listen((evt) => { if (evt.details.type !== "file.watcher.updated") return const props = @@ -1051,13 +980,6 @@ 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 @@ -1066,22 +988,12 @@ export default function Page() { 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) + refreshVcs() }, { defer: true }, ), diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 3e882761b5..77d30ba3a8 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -19,6 +19,9 @@ import { useCommand } from "@/context/command" import { useFile, type SelectedLineRange } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" +import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" +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" @@ -39,6 +42,9 @@ export function SessionSidePanel(props: { size: Sizing }) { const layout = useLayout() + const platform = usePlatform() + const settings = useSettings() + const sync = useSync() const file = useFile() const language = useLanguage() const command = useCommand() @@ -46,9 +52,15 @@ export function SessionSidePanel(props: { const { sessionKey, tabs, view } = useSessionLayout() const isDesktop = createMediaQuery("(min-width: 768px)") + const shown = createMemo( + () => + platform.platform !== "desktop" || + import.meta.env.VITE_KILO_CHANNEL !== "beta" || + settings.general.showFileTree(), + ) const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) - const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened()) + const fileOpen = createMemo(() => isDesktop() && shown() && layout.fileTree.opened()) const open = createMemo(() => reviewOpen() || fileOpen()) const reviewTab = createMemo(() => isDesktop()) const panelWidth = createMemo(() => { @@ -341,98 +353,99 @@ export function SessionSidePanel(props: {
-
+
- - - - {props.reviewCount()}{" "} - {language.t( - props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other", - )} - - - {language.t("session.files.all")} - - - - - - - {language.t("common.loading")} - {language.t("common.loading.ellipsis")} -
- } - > + + + + {props.reviewCount()}{" "} + {language.t( + props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other", + )} + + + {language.t("session.files.all")} + + + + + + + {language.t("common.loading")} + {language.t("common.loading.ellipsis")} +
+ } + > + props.focusReviewDiff(node.path)} + /> + + + + + + + {empty(language.t("session.files.empty"))} + props.focusReviewDiff(node.path)} + onFileClick={(node) => openTab(file.tab(node.path))} /> - - - {empty(props.empty())} - - - - - {empty(language.t("session.files.empty"))} - - openTab(file.tab(node.path))} - /> - - - - -
- -
props.size.start()}> - { - props.size.touch() - layout.fileTree.resize(width) - }} - /> + + + +
-
-
+ +
props.size.start()}> + { + props.size.touch() + layout.fileTree.resize(width) + }} + /> +
+
+ + diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 83d31369d4..ef0034c52a 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -7,8 +7,10 @@ import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useLocal } from "@/context/local" import { usePermission } from "@/context/permission" +import { usePlatform } from "@/context/platform" import { usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" +import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { showToast } from "@opencode-ai/ui/toast" @@ -39,8 +41,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const language = useLanguage() const local = useLocal() const permission = usePermission() + const platform = usePlatform() const prompt = usePrompt() const sdk = useSDK() + const settings = useSettings() const sync = useSync() const terminal = useTerminal() const layout = useLayout() @@ -66,6 +70,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => { }) const activeFileTab = tabState.activeFileTab const closableTab = tabState.closableTab + const shown = () => + platform.platform !== "desktop" || + import.meta.env.VITE_KILO_CHANNEL !== "beta" || + settings.general.showFileTree() const idle = { type: "idle" as const } const status = () => sync.data.session_status[params.id ?? ""] ?? idle @@ -457,12 +465,16 @@ export const useSessionCommands = (actions: SessionCommandContext) => { keybind: "mod+shift+r", onSelect: () => view().reviewPanel.toggle(), }), - viewCommand({ - id: "fileTree.toggle", - title: language.t("command.fileTree.toggle"), - keybind: "mod+\\", - onSelect: () => layout.fileTree.toggle(), - }), + ...(shown() + ? [ + viewCommand({ + id: "fileTree.toggle", + title: language.t("command.fileTree.toggle"), + keybind: "mod+\\", + onSelect: () => layout.fileTree.toggle(), + }), + ] + : []), viewCommand({ id: "input.focus", title: language.t("command.input.focus"), diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index dce0e94c3b..0cac30cb1e 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -469,7 +469,7 @@ export function persisted( state, setState, init, - Object.assign(() => ready() === true, { + Object.assign(() => (ready.loading ? false : ready.latest === true), { promise: init instanceof Promise ? init : undefined, }), ] diff --git a/packages/console/core/migrations/20260417071612_tidy_diamondback/snapshot.json b/packages/console/core/migrations/20260417071612_tidy_diamondback/snapshot.json new file mode 100644 index 0000000000..2152bfa76f --- /dev/null +++ b/packages/console/core/migrations/20260417071612_tidy_diamondback/snapshot.json @@ -0,0 +1,2567 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "93c492af-c95b-4213-9fc2-38c3dd10374d", + "prevIds": ["a09a925d-6cdd-4e7c-b8b1-11c259928b4c"], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "name": "benchmark", + "entityType": "tables" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "name": "lite", + "entityType": "tables" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "name": "subscription", + "entityType": "tables" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "name": "ip_rate_limit", + "entityType": "tables" + }, + { + "name": "ip", + "entityType": "tables" + }, + { + "name": "key_rate_limit", + "entityType": "tables" + }, + { + "name": "model_rate_limit", + "entityType": "tables" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "account" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "auth" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "mediumtext", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "result", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "enum('20','100','200')", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_plan", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_booked", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_selected", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite_subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "weekly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_weekly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "entityType": "columns", + "table": "payment" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "fixed_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_fixed_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "ip" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "usage", + "entityType": "columns", + "table": "ip" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(40)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "model_rate_limit" + }, + { + "type": "varchar(40)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "model_rate_limit" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "entityType": "columns", + "table": "user" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "user" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "workspace" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "benchmark", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "lite", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "subscription", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": ["ip", "interval"], + "name": "PRIMARY", + "table": "ip_rate_limit", + "entityType": "pks" + }, + { + "columns": ["ip"], + "name": "PRIMARY", + "table": "ip", + "entityType": "pks" + }, + { + "columns": ["key", "interval"], + "name": "PRIMARY", + "table": "key_rate_limit", + "entityType": "pks" + }, + { + "columns": ["key", "interval"], + "name": "PRIMARY", + "table": "model_rate_limit", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "time_created", + "entityType": "indexes", + "table": "benchmark" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_subscription_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "lite" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "subscription" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "usage_time_created", + "entityType": "indexes", + "table": "usage" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "entityType": "indexes", + "table": "key" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "entityType": "indexes", + "table": "model" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "entityType": "indexes", + "table": "provider" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "entityType": "indexes", + "table": "workspace" + } + ], + "renames": [] +} diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 7891d91ab4..312487e7eb 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "1.4.7" +version = "1.4.9" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.7/kilo-darwin-arm64.zip" -cmd = "./kilo" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.9/opencode-darwin-arm64.zip" +cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.7/kilo-darwin-x64.zip" -cmd = "./kilo" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.9/opencode-darwin-x64.zip" +cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.7/kilo-linux-arm64.tar.gz" -cmd = "./kilo" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.9/opencode-linux-arm64.tar.gz" +cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.7/kilo-linux-x64.tar.gz" -cmd = "./kilo" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.9/opencode-linux-x64.tar.gz" +cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.7/kilo-windows-x64.zip" -cmd = "./kilo.exe" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.9/opencode-windows-x64.zip" +cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 85cd37481b..ba037497f4 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -55,6 +55,11 @@ import { fetchMessagePage, MESSAGE_PAGE_LIMIT } from "./kilo-provider/message-pa import { childID } from "./kilo-provider/task-session" import { handleNetworkEvent, clearNetworkWaits } from "./kilo-provider/network" import { abortSession } from "./kilo-provider/abort" +import { + buildAutocompleteSettingsMessage, + routeAutocompleteMessage, + watchAutocompleteConfig, +} from "./services/autocomplete/settings" import * as ModelState from "./kilo-provider/model-state" import { handleForkSession } from "./kilo-provider/fork-session" import { retryable, backoff, MAX_RETRIES } from "./util/retry" @@ -201,6 +206,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private unsubscribeDirectoryProvider: (() => void) | null = null private initConnectionPromise: Promise | null = null private webviewMessageDisposable: vscode.Disposable | null = null + private autocompleteConfigDisposable: vscode.Disposable | null = null private viewStateDisposable: vscode.Disposable | null = null private visibilityDisposable: vscode.Disposable | null = null @@ -556,6 +562,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private setupWebviewMessageHandler(webview: vscode.Webview): void { this.webviewMessageDisposable?.dispose() + this.autocompleteConfigDisposable?.dispose() + this.autocompleteConfigDisposable = watchAutocompleteConfig((msg) => this.postMessage(msg)) this.webviewMessageDisposable = webview.onDidReceiveMessage(async (message) => { // Run interceptor if attached (e.g., AgentManagerProvider worktree logic) if (this.onBeforeMessage) { @@ -571,6 +579,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper await routeSuggestionWebviewMessage(this.questionCtx, message) if (await ModelState.handleMessage(message.type, message, this.client, (msg) => this.postMessage(msg))) return + if (await routeAutocompleteMessage(message, (msg) => this.postMessage(msg))) return switch (message.type) { case "webviewReady": console.log("[Kilo New] KiloProvider: ✅ webviewReady received") @@ -803,23 +812,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper .update("language", message.locale || undefined, vscode.ConfigurationTarget.Global) this.connectionService.notifyLanguageChanged(message.locale as string) break - case "requestAutocompleteSettings": - this.sendAutocompleteSettings() - break - case "updateAutocompleteSetting": { - const allowedKeys = new Set([ - "enableAutoTrigger", - "enableSmartInlineTaskKeybinding", - "enableChatAutocomplete", - ]) - if (allowedKeys.has(message.key)) { - await vscode.workspace - .getConfiguration("kilo-code.new.autocomplete") - .update(message.key, message.value, vscode.ConfigurationTarget.Global) - this.sendAutocompleteSettings() - } - break - } case "requestChatCompletion": { if (!this.chatAutocomplete) { this.chatAutocomplete = new ChatTextAreaAutocomplete(this.connectionService) @@ -2833,7 +2825,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper await this.extensionContext?.globalState.update("kilo.dismissedNotificationIds", undefined) // Re-send all settings to the webview so the UI reflects the reset - this.sendAutocompleteSettings() + this.postMessage(buildAutocompleteSettingsMessage()) this.sendBrowserSettings() this.sendNotificationSettings() this.sendTimelineSetting() @@ -3003,21 +2995,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.postMessage(msg) } - /** - * Read autocomplete settings from VS Code configuration and push to the webview. - */ - private sendAutocompleteSettings(): void { - const config = vscode.workspace.getConfiguration("kilo-code.new.autocomplete") - this.postMessage({ - type: "autocompleteSettingsLoaded", - settings: { - enableAutoTrigger: config.get("enableAutoTrigger", true), - enableSmartInlineTaskKeybinding: config.get("enableSmartInlineTaskKeybinding", false), - enableChatAutocomplete: config.get("enableChatAutocomplete", false), - }, - }) - } - /** Wait until the webview has sent "webviewReady". Resolves immediately when already ready. */ public waitForReady(): Promise { return this.isWebviewReady && this.webview ? Promise.resolve() : new Promise((r) => this.readyResolvers.push(r)) @@ -3334,6 +3311,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.viewStateDisposable?.dispose() this.visibilityDisposable?.dispose() this.webviewMessageDisposable?.dispose() + this.autocompleteConfigDisposable?.dispose() this.streams.dispose() this.isWebviewReady = false this.promptRecoveryQueued = false diff --git a/packages/kilo-vscode/src/services/autocomplete/settings.ts b/packages/kilo-vscode/src/services/autocomplete/settings.ts new file mode 100644 index 0000000000..fa2f52a06f --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/settings.ts @@ -0,0 +1,59 @@ +import * as vscode from "vscode" + +const keys = new Set(["enableAutoTrigger", "enableSmartInlineTaskKeybinding", "enableChatAutocomplete"]) + +type Message = { + type: string + key?: unknown + value?: unknown +} + +type Post = (msg: unknown) => void + +export async function routeAutocompleteMessage(message: Message, post: Post): Promise { + if (message.type === "requestAutocompleteSettings") { + post(buildAutocompleteSettingsMessage()) + return true + } + + if (message.type === "updateAutocompleteSetting") { + if (await update(message.key, message.value)) { + post(buildAutocompleteSettingsMessage()) + } + return true + } + + return false +} + +export function buildAutocompleteSettingsMessage() { + const config = vscode.workspace.getConfiguration("kilo-code.new.autocomplete") + return { + type: "autocompleteSettingsLoaded" as const, + settings: { + enableAutoTrigger: config.get("enableAutoTrigger", true), + enableSmartInlineTaskKeybinding: config.get("enableSmartInlineTaskKeybinding", false), + enableChatAutocomplete: config.get("enableChatAutocomplete", false), + }, + } +} + +/** Push autocomplete settings to the webview whenever VS Code config changes. */ +export function watchAutocompleteConfig(post: Post): vscode.Disposable { + return vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration("kilo-code.new.autocomplete")) { + post(buildAutocompleteSettingsMessage()) + } + }) +} + +async function update(key: unknown, value: unknown) { + if (typeof key !== "string") return false + if (!keys.has(key)) return false + + await vscode.workspace + .getConfiguration("kilo-code.new.autocomplete") + .update(key, value, vscode.ConfigurationTarget.Global) + + return true +} diff --git a/packages/kilo-vscode/tests/unit/model-price-format.test.ts b/packages/kilo-vscode/tests/unit/model-price-format.test.ts index 9203210f35..d5aab2d672 100644 --- a/packages/kilo-vscode/tests/unit/model-price-format.test.ts +++ b/packages/kilo-vscode/tests/unit/model-price-format.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test" -import { fmtPrice } from "../../webview-ui/src/components/shared/model-preview-utils" +import { avgPrice, fmtCachedPrice, fmtPrice } from "../../webview-ui/src/components/shared/model-preview-utils" // Prices arriving at fmtPrice are already in $/M tokens (converted by parseApiPrice). // This test suite guards against the double-multiplication bug where fmtPrice was @@ -34,3 +34,35 @@ describe("fmtPrice", () => { expect(fmtPrice(75)).toBe("$75.00/1M") }) }) + +describe("fmtCachedPrice", () => { + it("formats supported cached prices using the same $/1M formatter", () => { + expect(fmtCachedPrice({ input: 3, cache: { read: 0.3 } })).toBe("$0.30/1M") + }) + + it("shows free cached pricing when input is free and cache_read is zero", () => { + expect(fmtCachedPrice({ input: 0, cache: { read: 0 } })).toBe("Free") + }) + + it("returns null when cache_read is zero but input is paid", () => { + expect(fmtCachedPrice({ input: 3, cache: { read: 0 } })).toBeNull() + }) + + it("returns null when cache_read is missing but input is paid", () => { + expect(fmtCachedPrice({ input: 3 })).toBeNull() + }) +}) + +describe("avgPrice", () => { + it("uses the cache-heavy ratio when cache_read is supported", () => { + expect(avgPrice({ input: 3, output: 15, cache: { read: 0.3 } })).toBe(2.31) + }) + + it("uses the fallback ratio when cache_read is zero", () => { + expect(avgPrice({ input: 3, output: 15, cache: { read: 0 } })).toBe(4.2) + }) + + it("uses the fallback ratio when cache_read is missing", () => { + expect(avgPrice({ input: 3, output: 15 })).toBe(4.2) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx index 55f797b5a9..8b5dbb92e8 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx @@ -1,11 +1,12 @@ -import { Component, Show, For, useContext } from "solid-js" +import { Show, For, useContext, type Component } from "solid-js" import type { EnrichedModel } from "../../context/provider" import { SessionContext } from "../../context/session" import { Markdown } from "@kilocode/kilo-ui/markdown" import { Icon } from "@kilocode/kilo-ui/icon" +import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { useLanguage } from "../../context/language" import { sanitizeName } from "./model-selector-utils" -import { fmtPrice } from "./model-preview-utils" +import { avgPrice, fmtCachedPrice, fmtPrice } from "./model-preview-utils" interface Props { model: EnrichedModel | null @@ -22,12 +23,12 @@ function fmtDate(s: string): string { return d.toLocaleDateString(undefined, { year: "numeric", month: "short" }) } -const MODALITY_LABELS: Record = { - text: "Text", - image: "Images", - audio: "Audio", - video: "Video", - pdf: "PDF", +const MODALITY_KEYS: Record = { + text: "model.preview.modality.text", + image: "model.preview.modality.image", + audio: "model.preview.modality.audio", + video: "model.preview.modality.video", + pdf: "model.preview.modality.pdf", } export const ModelPreview: Component = (props) => { @@ -40,6 +41,11 @@ export const ModelPreview: Component = (props) => { {(model) => { const cost = () => model().cost + const cachedText = () => { + if (!cost()) return "" + return fmtCachedPrice(cost()!) ?? language.t("model.preview.value.notSupported") + } + const avg = () => (cost() ? avgPrice(cost()!) : undefined) const ctx = () => model().limit?.context ?? model().contextLength const caps = () => model().capabilities const inputs = () => caps()?.input @@ -47,13 +53,18 @@ export const ModelPreview: Component = (props) => { inputs() ? (Object.entries(inputs()!) as [string, boolean][]) .filter(([, v]) => v) - .map(([k]) => MODALITY_LABELS[k] ?? k) + .map(([k]) => { + const key = MODALITY_KEYS[k] + return key ? language.t(key) : k + }) : [] return ( <> - Free + + {language.t("model.tag.free")} + {/* Header — name + provider + star */}
@@ -88,21 +99,29 @@ export const ModelPreview: Component = (props) => {
{/* Release date */} - Released + {language.t("model.preview.label.released")} {fmtDate(model().releaseDate!)} {/* Pricing — hidden for free models */} - Input + {language.t("model.preview.label.input")} {fmtPrice(cost()!.input)} - Output + {language.t("model.preview.label.output")} {fmtPrice(cost()!.output)} + {language.t("model.preview.label.cached")} + {cachedText()} + + + {language.t("model.preview.label.average")} + + + {fmtPrice(avg()!)} {/* Context window */} - Context + {language.t("model.preview.label.context")} {fmtContext(ctx()!)}
@@ -111,7 +130,9 @@ export const ModelPreview: Component = (props) => { 0}>
- Reasoning + + {language.t("model.preview.badge.reasoning")} + {(label) => {label}}
diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/model-preview-utils.ts b/packages/kilo-vscode/webview-ui/src/components/shared/model-preview-utils.ts index 1d7cfcb73a..3c29c074fe 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/model-preview-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/shared/model-preview-utils.ts @@ -7,3 +7,18 @@ export function fmtPrice(n: number): string { if (n < 0.01) return `$${n.toFixed(4)}/1M` return `$${n.toFixed(2)}/1M` } + +export function fmtCachedPrice(cost: { input: number; cache?: { read: number } }): string | null { + const read = cost.cache?.read + if (read !== undefined && read > 0) return fmtPrice(read) + if (cost.input === 0) return fmtPrice(0) + return null +} + +export function avgPrice(cost: { input: number; output: number; cache?: { read: number } }): number { + const read = cost.cache?.read + if (read !== undefined && read > 0) { + return read * 0.7 + cost.input * 0.2 + cost.output * 0.1 + } + return cost.input * 0.9 + cost.output * 0.1 +} diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 40cfb7d9e6..960f4dcd25 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -181,6 +181,21 @@ export const dict = { "model.tooltip.reasoning.allowed": "يسمح بالاستنتاج", "model.tooltip.reasoning.none": "بدون استنتاج", "model.tooltip.context": "حد السياق {{limit}}", + "model.preview.label.released": "الإصدار", + "model.preview.label.input": "الإدخال", + "model.preview.label.output": "الإخراج", + "model.preview.label.cached": "مخزن مؤقتاً", + "model.preview.label.average": "متوسط التكلفة التقديرية", + "model.preview.label.context": "السياق", + "model.preview.value.notSupported": "غير مدعوم", + "model.preview.tooltip.average": + "يعتمد متوسط التكلفة التقديرية على نسبة نموذجية لرموز الإدخال والإخراج والقراءة المخزنة مؤقتاً.", + "model.preview.badge.reasoning": "التفكير", + "model.preview.modality.text": "نص", + "model.preview.modality.image": "صور", + "model.preview.modality.audio": "صوت", + "model.preview.modality.video": "فيديو", + "model.preview.modality.pdf": "PDF", "common.search.placeholder": "بحث", "common.goBack": "رجوع", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 5706e46cfb..a32c6a9644 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -182,6 +182,21 @@ export const dict = { "model.tooltip.reasoning.allowed": "Permite raciocínio", "model.tooltip.reasoning.none": "Sem raciocínio", "model.tooltip.context": "Limite de contexto {{limit}}", + "model.preview.label.released": "Lançado", + "model.preview.label.input": "Entrada", + "model.preview.label.output": "Saída", + "model.preview.label.cached": "Em cache", + "model.preview.label.average": "Custo Médio Est.", + "model.preview.label.context": "Contexto", + "model.preview.value.notSupported": "Não suportado", + "model.preview.tooltip.average": + "O custo médio estimado é baseado em uma proporção típica de tokens de entrada, saída e leitura de cache.", + "model.preview.badge.reasoning": "Raciocínio", + "model.preview.modality.text": "Texto", + "model.preview.modality.image": "Imagens", + "model.preview.modality.audio": "Áudio", + "model.preview.modality.video": "Vídeo", + "model.preview.modality.pdf": "PDF", "common.search.placeholder": "Buscar", "common.goBack": "Voltar", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 5980820781..d6e930dc3c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -183,6 +183,21 @@ export const dict = { "model.tooltip.reasoning.allowed": "Podržava rasuđivanje", "model.tooltip.reasoning.none": "Bez rasuđivanja", "model.tooltip.context": "Limit konteksta {{limit}}", + "model.preview.label.released": "Objavljeno", + "model.preview.label.input": "Ulaz", + "model.preview.label.output": "Izlaz", + "model.preview.label.cached": "Keširano", + "model.preview.label.average": "Procj. prosječni trošak", + "model.preview.label.context": "Kontekst", + "model.preview.value.notSupported": "Nije podržano", + "model.preview.tooltip.average": + "Procijenjeni prosječni trošak zasniva se na tipičnom omjeru ulaznih i izlaznih tokena, te tokena pročitanih iz keša.", + "model.preview.badge.reasoning": "Zaključivanje", + "model.preview.modality.text": "Tekst", + "model.preview.modality.image": "Slike", + "model.preview.modality.audio": "Audio", + "model.preview.modality.video": "Video", + "model.preview.modality.pdf": "PDF", "common.search.placeholder": "Pretraži", "common.goBack": "Nazad", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index a981d61a39..e250d36dd8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -183,6 +183,21 @@ export const dict = { "model.tooltip.reasoning.allowed": "Tillader tænkning", "model.tooltip.reasoning.none": "Ingen tænkning", "model.tooltip.context": "Kontekstgrænse {{limit}}", + "model.preview.label.released": "Udgivet", + "model.preview.label.input": "Input", + "model.preview.label.output": "Output", + "model.preview.label.cached": "Cached", + "model.preview.label.average": "Est. gennemsnitspris", + "model.preview.label.context": "Kontekst", + "model.preview.value.notSupported": "Ikke understøttet", + "model.preview.tooltip.average": + "Den estimerede gennemsnitspris er baseret på et typisk forhold mellem input-, output- og cache-læsetokens.", + "model.preview.badge.reasoning": "Ræsonnering", + "model.preview.modality.text": "Tekst", + "model.preview.modality.image": "Billeder", + "model.preview.modality.audio": "Lyd", + "model.preview.modality.video": "Video", + "model.preview.modality.pdf": "PDF", "common.search.placeholder": "Søg", "common.goBack": "Gå tilbage", "common.goForward": "Fremad", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 0117db37a3..6a9e9e59a7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -187,6 +187,21 @@ export const dict = { "model.tooltip.reasoning.allowed": "Erlaubt Reasoning", "model.tooltip.reasoning.none": "Kein Reasoning", "model.tooltip.context": "Kontextlimit {{limit}}", + "model.preview.label.released": "Veröffentlicht", + "model.preview.label.input": "Eingabe", + "model.preview.label.output": "Ausgabe", + "model.preview.label.cached": "Zwischengespeichert", + "model.preview.label.average": "Geschätzte Ø-Kosten", + "model.preview.label.context": "Kontext", + "model.preview.value.notSupported": "Nicht unterstützt", + "model.preview.tooltip.average": + "Die geschätzten Durchschnittskosten basieren auf einem typischen Verhältnis von Eingabe-, Ausgabe- und Cache-Lese-Token.", + "model.preview.badge.reasoning": "Reasoning", + "model.preview.modality.text": "Text", + "model.preview.modality.image": "Bilder", + "model.preview.modality.audio": "Audio", + "model.preview.modality.video": "Video", + "model.preview.modality.pdf": "PDF", "common.search.placeholder": "Suchen", "common.goBack": "Zurück", "common.goForward": "Vorwärts", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 1200171c65..0413b4ee0c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -183,6 +183,21 @@ export const dict = { "model.tooltip.reasoning.allowed": "Allows reasoning", "model.tooltip.reasoning.none": "No reasoning", "model.tooltip.context": "Context limit {{limit}}", + "model.preview.label.released": "Released", + "model.preview.label.input": "Input", + "model.preview.label.output": "Output", + "model.preview.label.cached": "Cached", + "model.preview.label.average": "Est. Average Cost", + "model.preview.label.context": "Context", + "model.preview.value.notSupported": "Not supported", + "model.preview.tooltip.average": + "The estimated average cost is based on a typical ratio of input, output, and cache read tokens.", + "model.preview.badge.reasoning": "Reasoning", + "model.preview.modality.text": "Text", + "model.preview.modality.image": "Images", + "model.preview.modality.audio": "Audio", + "model.preview.modality.video": "Video", + "model.preview.modality.pdf": "PDF", "common.search.placeholder": "Search", "common.goBack": "Back", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index cea3618a4e..782eb7436e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -183,6 +183,21 @@ export const dict = { "model.tooltip.reasoning.allowed": "Permite razonamiento", "model.tooltip.reasoning.none": "Sin razonamiento", "model.tooltip.context": "Límite de contexto {{limit}}", + "model.preview.label.released": "Lanzamiento", + "model.preview.label.input": "Entrada", + "model.preview.label.output": "Salida", + "model.preview.label.cached": "En caché", + "model.preview.label.average": "Costo prom. est.", + "model.preview.label.context": "Contexto", + "model.preview.value.notSupported": "No admitido", + "model.preview.tooltip.average": + "El costo promedio estimado se basa en una proporción típica de tokens de entrada, salida y lectura en caché.", + "model.preview.badge.reasoning": "Razonamiento", + "model.preview.modality.text": "Texto", + "model.preview.modality.image": "Imágenes", + "model.preview.modality.audio": "Audio", + "model.preview.modality.video": "Video", + "model.preview.modality.pdf": "PDF", "common.search.placeholder": "Buscar", "common.goBack": "Volver", "common.goForward": "Adelante", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 35eec7be19..9d7fe4562e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -184,6 +184,21 @@ export const dict = { "model.tooltip.reasoning.allowed": "Autorise le raisonnement", "model.tooltip.reasoning.none": "Sans raisonnement", "model.tooltip.context": "Limite de contexte {{limit}}", + "model.preview.label.released": "Sortie", + "model.preview.label.input": "Entrée", + "model.preview.label.output": "Sortie", + "model.preview.label.cached": "En cache", + "model.preview.label.average": "Coût moyen est.", + "model.preview.label.context": "Contexte", + "model.preview.value.notSupported": "Non pris en charge", + "model.preview.tooltip.average": + "Le coût moyen estimé est basé sur un ratio typique de jetons d'entrée, de sortie et de lecture en cache.", + "model.preview.badge.reasoning": "Raisonnement", + "model.preview.modality.text": "Texte", + "model.preview.modality.image": "Images", + "model.preview.modality.audio": "Audio", + "model.preview.modality.video": "Vidéo", + "model.preview.modality.pdf": "PDF", "common.search.placeholder": "Rechercher", "common.goBack": "Retour", "common.goForward": "Suivant", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index ef16e2231f..dc5c465c5f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -182,6 +182,21 @@ export const dict = { "model.tooltip.reasoning.allowed": "推論を許可", "model.tooltip.reasoning.none": "推論なし", "model.tooltip.context": "コンテキスト上限 {{limit}}", + "model.preview.label.released": "リリース", + "model.preview.label.input": "入力", + "model.preview.label.output": "出力", + "model.preview.label.cached": "キャッシュ", + "model.preview.label.average": "推定平均コスト", + "model.preview.label.context": "コンテキスト", + "model.preview.value.notSupported": "未対応", + "model.preview.tooltip.average": + "推定平均コストは、入力、出力、およびキャッシュ読み取りトークンの一般的な比率に基づいています。", + "model.preview.badge.reasoning": "推論", + "model.preview.modality.text": "テキスト", + "model.preview.modality.image": "画像", + "model.preview.modality.audio": "音声", + "model.preview.modality.video": "動画", + "model.preview.modality.pdf": "PDF", "common.search.placeholder": "検索", "common.goBack": "戻る", "common.goForward": "進む", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 9b75764427..b042919779 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -186,6 +186,20 @@ export const dict = { "model.tooltip.reasoning.allowed": "추론 허용", "model.tooltip.reasoning.none": "추론 없음", "model.tooltip.context": "컨텍스트 제한 {{limit}}", + "model.preview.label.released": "출시", + "model.preview.label.input": "입력", + "model.preview.label.output": "출력", + "model.preview.label.cached": "캐시됨", + "model.preview.label.average": "예상 평균 비용", + "model.preview.label.context": "컨텍스트", + "model.preview.value.notSupported": "미지원", + "model.preview.tooltip.average": "예상 평균 비용은 입력, 출력 및 캐시 읽기 토큰의 일반적인 비율을 기반으로 합니다.", + "model.preview.badge.reasoning": "추론", + "model.preview.modality.text": "텍스트", + "model.preview.modality.image": "이미지", + "model.preview.modality.audio": "오디오", + "model.preview.modality.video": "비디오", + "model.preview.modality.pdf": "PDF", "common.search.placeholder": "검색", "common.goBack": "뒤로 가기", "common.goForward": "앞으로 가기", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index de09f56cb5..4c44e82911 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -183,6 +183,21 @@ export const dict = { "model.tooltip.reasoning.allowed": "Staat redeneren toe", "model.tooltip.reasoning.none": "Geen redenering", "model.tooltip.context": "Contextlimiet {{limit}}", + "model.preview.label.released": "Uitgebracht", + "model.preview.label.input": "Input", + "model.preview.label.output": "Output", + "model.preview.label.cached": "Gecachet", + "model.preview.label.average": "Gesch. gem. kosten", + "model.preview.label.context": "Context", + "model.preview.value.notSupported": "Niet ondersteund", + "model.preview.tooltip.average": + "De geschatte gemiddelde kosten zijn gebaseerd op een typische verhouding van input-, output- en cache-leestokens.", + "model.preview.badge.reasoning": "Redeneren", + "model.preview.modality.text": "Tekst", + "model.preview.modality.image": "Afbeeldingen", + "model.preview.modality.audio": "Audio", + "model.preview.modality.video": "Video", + "model.preview.modality.pdf": "PDF", "common.search.placeholder": "Zoeken", "common.goBack": "Terug", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index fcc402b0b6..be8327ccab 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -185,6 +185,21 @@ export const dict = { "model.tooltip.reasoning.allowed": "Tillater resonnering", "model.tooltip.reasoning.none": "Ingen resonnering", "model.tooltip.context": "Kontekstgrense {{limit}}", + "model.preview.label.released": "Utgitt", + "model.preview.label.input": "Inndata", + "model.preview.label.output": "Utdata", + "model.preview.label.cached": "Bufret", + "model.preview.label.average": "Est. snittkostnad", + "model.preview.label.context": "Kontekst", + "model.preview.value.notSupported": "Ikke støttet", + "model.preview.tooltip.average": + "Den estimerte snittkostnaden er basert på et typisk forhold mellom inndata-, utdata- og leste buffer-tokens.", + "model.preview.badge.reasoning": "Resonnering", + "model.preview.modality.text": "Tekst", + "model.preview.modality.image": "Bilder", + "model.preview.modality.audio": "Lyd", + "model.preview.modality.video": "Video", + "model.preview.modality.pdf": "PDF", "common.search.placeholder": "Søk", "common.goBack": "Gå tilbake", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 387427a964..61f95eb596 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -182,6 +182,21 @@ export const dict = { "model.tooltip.reasoning.allowed": "Obsługuje wnioskowanie", "model.tooltip.reasoning.none": "Brak wnioskowania", "model.tooltip.context": "Limit kontekstu {{limit}}", + "model.preview.label.released": "Wydano", + "model.preview.label.input": "Wejście", + "model.preview.label.output": "Wyjście", + "model.preview.label.cached": "W pamięci podręcznej", + "model.preview.label.average": "Szac. średni koszt", + "model.preview.label.context": "Kontekst", + "model.preview.value.notSupported": "Nieobsługiwane", + "model.preview.tooltip.average": + "Szacowany średni koszt opiera się na typowym stosunku tokenów wejściowych, wyjściowych i pobranych z pamięci podręcznej.", + "model.preview.badge.reasoning": "Rozumowanie", + "model.preview.modality.text": "Tekst", + "model.preview.modality.image": "Obrazy", + "model.preview.modality.audio": "Audio", + "model.preview.modality.video": "Wideo", + "model.preview.modality.pdf": "PDF", "common.search.placeholder": "Szukaj", "common.goBack": "Wstecz", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index eeaa838574..63c152bab0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -182,7 +182,21 @@ export const dict = { "model.tooltip.reasoning.allowed": "Разрешает рассуждение", "model.tooltip.reasoning.none": "Без рассуждения", "model.tooltip.context": "Лимит контекста {{limit}}", - + "model.preview.label.released": "Выпущена", + "model.preview.label.input": "Ввод", + "model.preview.label.output": "Вывод", + "model.preview.label.cached": "В кэше", + "model.preview.label.average": "Средняя стоимость", + "model.preview.label.context": "Контекст", + "model.preview.value.notSupported": "Не поддерживается", + "model.preview.tooltip.average": + "Примерная средняя стоимость основана на типичном соотношении токенов ввода, вывода и чтения из кэша.", + "model.preview.badge.reasoning": "Рассуждение", + "model.preview.modality.text": "Текст", + "model.preview.modality.image": "Изображения", + "model.preview.modality.audio": "Аудио", + "model.preview.modality.video": "Видео", + "model.preview.modality.pdf": "PDF", "common.search.placeholder": "Поиск", "common.goBack": "Назад", "common.goForward": "Вперёд", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 1a9603c6e0..3d992623d3 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -182,6 +182,21 @@ export const dict = { "model.tooltip.reasoning.allowed": "อนุญาตการใช้เหตุผล", "model.tooltip.reasoning.none": "ไม่มีการใช้เหตุผล", "model.tooltip.context": "ขีดจำกัดบริบท {{limit}}", + "model.preview.label.released": "เปิดตัว", + "model.preview.label.input": "อินพุต", + "model.preview.label.output": "เอาต์พุต", + "model.preview.label.cached": "แคช", + "model.preview.label.average": "ต้นทุนเฉลี่ยโดยประมาณ", + "model.preview.label.context": "บริบท", + "model.preview.value.notSupported": "ไม่รองรับ", + "model.preview.tooltip.average": + "ต้นทุนเฉลี่ยโดยประมาณนี้คำนวณจากอัตราส่วนทั่วไปของโทเค็นอินพุต เอาต์พุต และการอ่านแคช", + "model.preview.badge.reasoning": "การให้เหตุผล", + "model.preview.modality.text": "ข้อความ", + "model.preview.modality.image": "รูปภาพ", + "model.preview.modality.audio": "เสียง", + "model.preview.modality.video": "วิดีโอ", + "model.preview.modality.pdf": "PDF", "common.search.placeholder": "ค้นหา", "common.goBack": "ย้อนกลับ", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 228790bca3..3470e2c36b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -182,6 +182,21 @@ export const dict = { "model.tooltip.reasoning.allowed": "Akıl yürütme destekler", "model.tooltip.reasoning.none": "Akıl yürütme yok", "model.tooltip.context": "Bağlam limiti {{limit}}", + "model.preview.label.released": "Yayınlanma", + "model.preview.label.input": "Girdi", + "model.preview.label.output": "Çıktı", + "model.preview.label.cached": "Önbellek", + "model.preview.label.average": "Tahmini Ort. Maliyet", + "model.preview.label.context": "Bağlam", + "model.preview.value.notSupported": "Desteklenmiyor", + "model.preview.tooltip.average": + "Tahmini ortalama maliyet; girdi, çıktı ve önbellek okuma tokenlerinin tipik bir oranına dayanmaktadır.", + "model.preview.badge.reasoning": "Akıl Yürütme", + "model.preview.modality.text": "Metin", + "model.preview.modality.image": "Görseller", + "model.preview.modality.audio": "Ses", + "model.preview.modality.video": "Video", + "model.preview.modality.pdf": "PDF", "common.search.placeholder": "Ara", "common.goBack": "Geri git", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 4476b724ba..b352336b62 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -183,6 +183,21 @@ export const dict = { "model.tooltip.reasoning.allowed": "Підтримує міркування", "model.tooltip.reasoning.none": "Без міркування", "model.tooltip.context": "Ліміт контексту {{limit}}", + "model.preview.label.released": "Випущено", + "model.preview.label.input": "Вхідні", + "model.preview.label.output": "Вихідні", + "model.preview.label.cached": "Кешовані", + "model.preview.label.average": "Орієнт. сер. вартість", + "model.preview.label.context": "Контекст", + "model.preview.value.notSupported": "Не підтримується", + "model.preview.tooltip.average": + "Орієнтовна середня вартість базується на типовому співвідношенні вхідних, вихідних токенів та читання кешу.", + "model.preview.badge.reasoning": "Міркування", + "model.preview.modality.text": "Текст", + "model.preview.modality.image": "Зображення", + "model.preview.modality.audio": "Аудіо", + "model.preview.modality.video": "Відео", + "model.preview.modality.pdf": "PDF", "common.search.placeholder": "Пошук", "common.goBack": "Назад", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index d659c75152..0e1e39fe75 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -183,6 +183,20 @@ export const dict = { "model.tooltip.reasoning.allowed": "支持推理", "model.tooltip.reasoning.none": "不支持推理", "model.tooltip.context": "上下文上限 {{limit}}", + "model.preview.label.released": "发布", + "model.preview.label.input": "输入", + "model.preview.label.output": "输出", + "model.preview.label.cached": "缓存", + "model.preview.label.average": "预估平均成本", + "model.preview.label.context": "上下文", + "model.preview.value.notSupported": "不支持", + "model.preview.tooltip.average": "预估平均成本基于输入、输出和缓存读取 token 的典型比例计算。", + "model.preview.badge.reasoning": "推理", + "model.preview.modality.text": "文本", + "model.preview.modality.image": "图像", + "model.preview.modality.audio": "音频", + "model.preview.modality.video": "视频", + "model.preview.modality.pdf": "PDF", "common.search.placeholder": "搜索", "common.goBack": "返回", "common.goForward": "前进", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index ea20276cb9..a281c20b0e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -183,6 +183,20 @@ export const dict = { "model.tooltip.reasoning.allowed": "支援推理", "model.tooltip.reasoning.none": "不支援推理", "model.tooltip.context": "上下文上限 {{limit}}", + "model.preview.label.released": "發布", + "model.preview.label.input": "輸入", + "model.preview.label.output": "輸出", + "model.preview.label.cached": "快取", + "model.preview.label.average": "預估平均成本", + "model.preview.label.context": "上下文", + "model.preview.value.notSupported": "不支援", + "model.preview.tooltip.average": "預估平均成本是基於輸入、輸出和快取讀取 token 的典型比例計算而得。", + "model.preview.badge.reasoning": "推理", + "model.preview.modality.text": "文字", + "model.preview.modality.image": "圖片", + "model.preview.modality.audio": "音訊", + "model.preview.modality.video": "影片", + "model.preview.modality.pdf": "PDF", "common.search.placeholder": "搜尋", "common.goBack": "上一頁", "common.goForward": "下一頁", diff --git a/packages/kilo-vscode/webview-ui/src/styles/model-selector.css b/packages/kilo-vscode/webview-ui/src/styles/model-selector.css index 86e330cce0..0422d7d9e4 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/model-selector.css +++ b/packages/kilo-vscode/webview-ui/src/styles/model-selector.css @@ -494,8 +494,13 @@ } .mode-switcher-item-desc { + display: block; font-size: 11px; color: var(--text-weak, var(--vscode-descriptionForeground)); + max-width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } .mode-switcher-item.selected .mode-switcher-item-name { diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index a631d4178b..fe803a43c1 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -355,7 +355,14 @@ export interface ProviderModel { options?: { description?: string } recommendedIndex?: number isFree?: boolean - cost?: { input: number; output: number } + cost?: { + input: number + output: number + cache?: { + read: number + write: number + } + } } export interface Provider { diff --git a/packages/opencode/package.json b/packages/opencode/package.json index bae08a7ae6..f79cf94014 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -15,7 +15,6 @@ "upgrade-opentui": "bun run script/upgrade-opentui.ts", "dev": "bun run --conditions=browser ./src/index.ts", "dev:temporary": "bun run --conditions=browser ./src/temporary.ts", - "dev:local": "XDG_DATA_HOME=/tmp/kilo-local/data XDG_CONFIG_HOME=/tmp/kilo-local/config XDG_STATE_HOME=/tmp/kilo-local/state XDG_CACHE_HOME=/tmp/kilo-local/cache KILO_API_URL=http://localhost:3000 KILO_SESSION_INGEST_URL=http://localhost:8800 bun run --conditions=browser ./src/index.ts", "db": "bun drizzle-kit" }, "bin": { @@ -128,8 +127,8 @@ "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", - "@opentui/core": "0.1.99", - "@opentui/solid": "0.1.99", + "@opentui/core": "catalog:", + "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", diff --git a/packages/opencode/script/collapse-barrel.ts b/packages/opencode/script/collapse-barrel.ts deleted file mode 100644 index 05bb11589c..0000000000 --- a/packages/opencode/script/collapse-barrel.ts +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env bun -/** - * Collapse a single-namespace barrel directory into a dir/index.ts module. - * - * Given a directory `src/foo/` that contains: - * - * - `index.ts` (exactly `export * as Foo from "./foo"`) - * - `foo.ts` (the real implementation) - * - zero or more sibling files - * - * this script: - * - * 1. Deletes the old `index.ts` barrel. - * 2. `git mv`s `foo.ts` → `index.ts` so the implementation IS the directory entry. - * 3. Appends `export * as Foo from "."` to the new `index.ts`. - * 4. Rewrites any same-directory sibling `*.ts` files that imported - * `./foo` (with or without the namespace name) to import `"."` instead. - * - * Consumer files outside the directory keep importing from the directory - * (`"@/foo"` / `"../foo"` / etc.) and continue to work, because - * `dir/index.ts` now provides the `Foo` named export directly. - * - * Usage: - * - * bun script/collapse-barrel.ts src/bus - * bun script/collapse-barrel.ts src/bus --dry-run - * - * Notes: - * - * - Only works on directories whose barrel is a single - * `export * as Name from "./file"` line. Refuses otherwise. - * - Refuses if the implementation file name already conflicts with - * `index.ts`. - * - Safe to run repeatedly: a second run on an already-collapsed dir - * will exit with a clear message. - */ - -import fs from "node:fs" -import path from "node:path" -import { spawnSync } from "node:child_process" - -const args = process.argv.slice(2) -const dryRun = args.includes("--dry-run") -const targetArg = args.find((a) => !a.startsWith("--")) - -if (!targetArg) { - console.error("Usage: bun script/collapse-barrel.ts [--dry-run]") - process.exit(1) -} - -const dir = path.resolve(targetArg) -const indexPath = path.join(dir, "index.ts") - -if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) { - console.error(`Not a directory: ${dir}`) - process.exit(1) -} -if (!fs.existsSync(indexPath)) { - console.error(`No index.ts in ${dir}`) - process.exit(1) -} - -// Validate barrel shape. -const indexContent = fs.readFileSync(indexPath, "utf-8").trim() -const match = indexContent.match(/^export\s+\*\s+as\s+(\w+)\s+from\s+["']\.\/([^"']+)["']\s*;?\s*$/) -if (!match) { - console.error(`Not a simple single-namespace barrel:\n${indexContent}`) - process.exit(1) -} -const namespaceName = match[1] -const implRel = match[2].replace(/\.ts$/, "") -const implPath = path.join(dir, `${implRel}.ts`) - -if (!fs.existsSync(implPath)) { - console.error(`Implementation file not found: ${implPath}`) - process.exit(1) -} - -if (implRel === "index") { - console.error(`Nothing to do — impl file is already index.ts`) - process.exit(0) -} - -console.log(`Collapsing ${path.relative(process.cwd(), dir)}`) -console.log(` namespace: ${namespaceName}`) -console.log(` impl file: ${implRel}.ts → index.ts`) - -// Figure out which sibling files need rewriting. -const siblings = fs - .readdirSync(dir) - .filter((f) => f.endsWith(".ts") || f.endsWith(".tsx")) - .filter((f) => f !== "index.ts" && f !== `${implRel}.ts`) - .map((f) => path.join(dir, f)) - -type SiblingEdit = { file: string; content: string } -const siblingEdits: SiblingEdit[] = [] - -for (const sibling of siblings) { - const content = fs.readFileSync(sibling, "utf-8") - // Match any import or re-export referring to "./" inside this directory. - const siblingRegex = new RegExp(`(from\\s*["'])\\.\\/${implRel.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&")}(["'])`, "g") - if (!siblingRegex.test(content)) continue - const updated = content.replace(siblingRegex, `$1.$2`) - siblingEdits.push({ file: sibling, content: updated }) -} - -if (siblingEdits.length > 0) { - console.log(` sibling rewrites: ${siblingEdits.length}`) - for (const edit of siblingEdits) { - console.log(` ${path.relative(process.cwd(), edit.file)}`) - } -} else { - console.log(` sibling rewrites: none`) -} - -if (dryRun) { - console.log(`\n(dry run) would:`) - console.log(` - delete ${path.relative(process.cwd(), indexPath)}`) - console.log(` - git mv ${path.relative(process.cwd(), implPath)} ${path.relative(process.cwd(), indexPath)}`) - console.log(` - append \`export * as ${namespaceName} from "."\` to the new index.ts`) - for (const edit of siblingEdits) { - console.log(` - rewrite sibling: ${path.relative(process.cwd(), edit.file)}`) - } - process.exit(0) -} - -// Apply: remove the old barrel, git-mv the impl onto it, then rewrite content. -// We can't git-mv on top of an existing tracked file, so we remove the barrel first. -function runGit(...cmd: string[]) { - const res = spawnSync("git", cmd, { stdio: "inherit" }) - if (res.status !== 0) { - console.error(`git ${cmd.join(" ")} failed`) - process.exit(res.status ?? 1) - } -} - -// Step 1: remove the barrel -runGit("rm", "-f", indexPath) - -// Step 2: rename the impl file into index.ts -runGit("mv", implPath, indexPath) - -// Step 3: append the self-reexport to the new index.ts -const newContent = fs.readFileSync(indexPath, "utf-8") -const trimmed = newContent.endsWith("\n") ? newContent : newContent + "\n" -fs.writeFileSync(indexPath, `${trimmed}\nexport * as ${namespaceName} from "."\n`) -console.log(` appended: export * as ${namespaceName} from "."`) - -// Step 4: rewrite siblings -for (const edit of siblingEdits) { - fs.writeFileSync(edit.file, edit.content) -} -if (siblingEdits.length > 0) { - console.log(` rewrote ${siblingEdits.length} sibling file(s)`) -} - -console.log(`\nDone. Verify with:`) -console.log(` cd packages/opencode`) -console.log(` bunx --bun tsgo --noEmit`) -console.log(` bun run --conditions=browser ./src/index.ts generate`) -console.log(` bun run test`) diff --git a/packages/opencode/script/unwrap-namespace.ts b/packages/opencode/script/unwrap-namespace.ts deleted file mode 100644 index 45c16f6c73..0000000000 --- a/packages/opencode/script/unwrap-namespace.ts +++ /dev/null @@ -1,305 +0,0 @@ -#!/usr/bin/env bun -/** - * Unwrap a TypeScript `export namespace` into flat exports + barrel. - * - * Usage: - * bun script/unwrap-namespace.ts src/bus/index.ts - * bun script/unwrap-namespace.ts src/bus/index.ts --dry-run - * bun script/unwrap-namespace.ts src/pty/index.ts --name service # avoid collision with pty.ts - * - * What it does: - * 1. Reads the file and finds the `export namespace Foo { ... }` block - * (uses ast-grep for accurate AST-based boundary detection) - * 2. Removes the namespace wrapper and dedents the body - * 3. Fixes self-references (e.g. Config.PermissionAction → PermissionAction) - * 4. If the file is index.ts, renames it to .ts - * 5. Creates/updates index.ts with `export * as Foo from "./"` - * 6. Rewrites import paths across src/, test/, and script/ - * 7. Fixes sibling imports within the same directory - * - * Requires: ast-grep (`brew install ast-grep` or `cargo install ast-grep`) - */ - -import path from "path" -import fs from "fs" - -const args = process.argv.slice(2) -const dryRun = args.includes("--dry-run") -const nameFlag = args.find((a, i) => args[i - 1] === "--name") -const filePath = args.find((a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--name") - -if (!filePath) { - console.error("Usage: bun script/unwrap-namespace.ts [--dry-run] [--name ]") - process.exit(1) -} - -const absPath = path.resolve(filePath) -if (!fs.existsSync(absPath)) { - console.error(`File not found: ${absPath}`) - process.exit(1) -} - -const src = fs.readFileSync(absPath, "utf-8") -const lines = src.split("\n") - -// Use ast-grep to find the namespace boundaries accurately. -// This avoids false matches from braces in strings, templates, comments, etc. -const astResult = Bun.spawnSync( - ["ast-grep", "run", "--pattern", "export namespace $NAME { $$$BODY }", "--lang", "typescript", "--json", absPath], - { stdout: "pipe", stderr: "pipe" }, -) - -if (astResult.exitCode !== 0) { - console.error("ast-grep failed:", astResult.stderr.toString()) - process.exit(1) -} - -const matches = JSON.parse(astResult.stdout.toString()) as Array<{ - text: string - range: { start: { line: number; column: number }; end: { line: number; column: number } } - metaVariables: { single: Record; multi: Record> } -}> - -if (matches.length === 0) { - console.error("No `export namespace Foo { ... }` found in file") - process.exit(1) -} - -if (matches.length > 1) { - console.error(`Found ${matches.length} namespaces — this script handles one at a time`) - console.error("Namespaces found:") - for (const m of matches) console.error(` ${m.metaVariables.single.NAME.text} (line ${m.range.start.line + 1})`) - process.exit(1) -} - -const match = matches[0] -const nsName = match.metaVariables.single.NAME.text -const nsLine = match.range.start.line // 0-indexed -const closeLine = match.range.end.line // 0-indexed, the line with closing `}` - -console.log(`Found: export namespace ${nsName} { ... }`) -console.log(` Lines ${nsLine + 1}–${closeLine + 1} (${closeLine - nsLine + 1} lines)`) - -// Build the new file content: -// 1. Everything before the namespace declaration (imports, etc.) -// 2. The namespace body, dedented by one level (2 spaces) -// 3. Everything after the closing brace (rare, but possible) -const before = lines.slice(0, nsLine) -const body = lines.slice(nsLine + 1, closeLine) -const after = lines.slice(closeLine + 1) - -// Dedent: remove exactly 2 leading spaces from each line -const dedented = body.map((line) => { - if (line === "") return "" - if (line.startsWith(" ")) return line.slice(2) - return line -}) - -let newContent = [...before, ...dedented, ...after].join("\n") - -// --- Fix self-references --- -// After unwrapping, references like `Config.PermissionAction` inside the same file -// need to become just `PermissionAction`. Only fix code positions, not strings. -const exportedNames = new Set() -const exportRegex = /export\s+(?:const|function|class|interface|type|enum|abstract\s+class)\s+(\w+)/g -for (const line of dedented) { - for (const m of line.matchAll(exportRegex)) exportedNames.add(m[1]) -} -const reExportRegex = /export\s*\{\s*([^}]+)\}/g -for (const line of dedented) { - for (const m of line.matchAll(reExportRegex)) { - for (const name of m[1].split(",")) { - const trimmed = name - .trim() - .split(/\s+as\s+/) - .pop()! - .trim() - if (trimmed) exportedNames.add(trimmed) - } - } -} - -let selfRefCount = 0 -if (exportedNames.size > 0) { - const fixedLines = newContent.split("\n").map((line) => { - // Split line into string-literal and code segments to avoid replacing inside strings - const segments: Array<{ text: string; isString: boolean }> = [] - let i = 0 - let current = "" - let inString: string | null = null - - while (i < line.length) { - const ch = line[i] - if (inString) { - current += ch - if (ch === "\\" && i + 1 < line.length) { - current += line[i + 1] - i += 2 - continue - } - if (ch === inString) { - segments.push({ text: current, isString: true }) - current = "" - inString = null - } - i++ - continue - } - if (ch === '"' || ch === "'" || ch === "`") { - if (current) segments.push({ text: current, isString: false }) - current = ch - inString = ch - i++ - continue - } - if (ch === "/" && i + 1 < line.length && line[i + 1] === "/") { - current += line.slice(i) - segments.push({ text: current, isString: true }) - current = "" - i = line.length - continue - } - current += ch - i++ - } - if (current) segments.push({ text: current, isString: !!inString }) - - return segments - .map((seg) => { - if (seg.isString) return seg.text - let result = seg.text - for (const name of exportedNames) { - const pattern = `${nsName}.${name}` - while (result.includes(pattern)) { - const idx = result.indexOf(pattern) - const charBefore = idx > 0 ? result[idx - 1] : " " - const charAfter = idx + pattern.length < result.length ? result[idx + pattern.length] : " " - if (/\w/.test(charBefore) || /\w/.test(charAfter)) break - result = result.slice(0, idx) + name + result.slice(idx + pattern.length) - selfRefCount++ - } - } - return result - }) - .join("") - }) - newContent = fixedLines.join("\n") -} - -// Figure out file naming -const dir = path.dirname(absPath) -const basename = path.basename(absPath, ".ts") -const isIndex = basename === "index" -const implName = nameFlag ?? (isIndex ? nsName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase() : basename) -const implFile = path.join(dir, `${implName}.ts`) -const indexFile = path.join(dir, "index.ts") -const barrelLine = `export * as ${nsName} from "./${implName}"\n` - -console.log("") -if (isIndex) { - console.log(`Plan: rename ${basename}.ts → ${implName}.ts, create new index.ts barrel`) -} else { - console.log(`Plan: rewrite ${basename}.ts in place, create index.ts barrel`) -} -if (selfRefCount > 0) console.log(`Fixed ${selfRefCount} self-reference(s) (${nsName}.X → X)`) -console.log("") - -if (dryRun) { - console.log("--- DRY RUN ---") - console.log("") - console.log(`=== ${implName}.ts (first 30 lines) ===`) - newContent - .split("\n") - .slice(0, 30) - .forEach((l, i) => console.log(` ${i + 1}: ${l}`)) - console.log(" ...") - console.log("") - console.log(`=== index.ts ===`) - console.log(` ${barrelLine.trim()}`) - console.log("") - if (!isIndex) { - const relDir = path.relative(path.resolve("src"), dir) - console.log(`=== Import rewrites (would apply) ===`) - console.log(` ${relDir}/${basename}" → ${relDir}" across src/, test/, script/`) - } else { - console.log("No import rewrites needed (was index.ts)") - } -} else { - if (isIndex) { - fs.writeFileSync(implFile, newContent) - fs.writeFileSync(indexFile, barrelLine) - console.log(`Wrote ${implName}.ts (${newContent.split("\n").length} lines)`) - console.log(`Wrote index.ts (barrel)`) - } else { - fs.writeFileSync(absPath, newContent) - if (fs.existsSync(indexFile)) { - const existing = fs.readFileSync(indexFile, "utf-8") - if (!existing.includes(`export * as ${nsName}`)) { - fs.appendFileSync(indexFile, barrelLine) - console.log(`Appended to existing index.ts`) - } else { - console.log(`index.ts already has ${nsName} export`) - } - } else { - fs.writeFileSync(indexFile, barrelLine) - console.log(`Wrote index.ts (barrel)`) - } - console.log(`Rewrote ${basename}.ts (${newContent.split("\n").length} lines)`) - } - - // --- Rewrite import paths across src/, test/, script/ --- - const relDir = path.relative(path.resolve("src"), dir) - if (!isIndex) { - const oldTail = `${relDir}/${basename}` - const searchDirs = ["src", "test", "script"].filter((d) => fs.existsSync(d)) - const rgResult = Bun.spawnSync(["rg", "-l", `from.*${oldTail}"`, ...searchDirs], { - stdout: "pipe", - stderr: "pipe", - }) - const filesToRewrite = rgResult.stdout - .toString() - .trim() - .split("\n") - .filter((f) => f.length > 0) - - if (filesToRewrite.length > 0) { - console.log(`\nRewriting imports in ${filesToRewrite.length} file(s)...`) - for (const file of filesToRewrite) { - const content = fs.readFileSync(file, "utf-8") - fs.writeFileSync(file, content.replaceAll(`${oldTail}"`, `${relDir}"`)) - } - console.log(` Done: ${oldTail}" → ${relDir}"`) - } else { - console.log("\nNo import rewrites needed") - } - } else { - console.log("\nNo import rewrites needed (was index.ts)") - } - - // --- Fix sibling imports within the same directory --- - const siblingFiles = fs.readdirSync(dir).filter((f) => { - if (!f.endsWith(".ts")) return false - if (f === "index.ts" || f === `${implName}.ts`) return false - return true - }) - - let siblingFixCount = 0 - for (const sibFile of siblingFiles) { - const sibPath = path.join(dir, sibFile) - const content = fs.readFileSync(sibPath, "utf-8") - const pattern = new RegExp(`from\\s+["']\\./${basename}["']`, "g") - if (pattern.test(content)) { - fs.writeFileSync(sibPath, content.replace(pattern, `from "."`)) - siblingFixCount++ - } - } - if (siblingFixCount > 0) { - console.log(`Fixed ${siblingFixCount} sibling import(s) in ${path.basename(dir)}/ (./${basename} → .)`) - } -} - -console.log("") -console.log("=== Verify ===") -console.log("") -console.log("bunx --bun tsgo --noEmit # typecheck") -console.log("bun run test # run tests") diff --git a/packages/opencode/specs/effect/migration.md b/packages/opencode/specs/effect/migration.md index 105a82290b..b8bf4e0494 100644 --- a/packages/opencode/specs/effect/migration.md +++ b/packages/opencode/specs/effect/migration.md @@ -9,7 +9,7 @@ Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Returns `{ runPromise, runFork, runCallback }`. - Global services (no per-directory state): Account, Auth, AppFileSystem, Installation, Truncate, Worktree -- Instance-scoped (per-directory state via InstanceState): Agent, Bus, Command, Config, File, FileTime, FileWatcher, Format, LSP, MCP, Permission, Plugin, ProviderAuth, Pty, Question, SessionStatus, Skill, Snapshot, ToolRegistry, Vcs +- Instance-scoped (per-directory state via InstanceState): Agent, Bus, Command, Config, File, FileWatcher, Format, LSP, MCP, Permission, Plugin, ProviderAuth, Pty, Question, SessionStatus, Skill, Snapshot, ToolRegistry, Vcs Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`. @@ -195,7 +195,6 @@ This checklist is only about the service shape migration. Many of these services - [x] `Config` — `config/config.ts` - [x] `Discovery` — `skill/discovery.ts` (dependency-only layer, no standalone runtime) - [x] `File` — `file/index.ts` -- [x] `FileTime` — `file/time.ts` - [x] `FileWatcher` — `file/watcher.ts` - [x] `Format` — `format/index.ts` - [x] `Installation` — `installation/index.ts` @@ -301,7 +300,6 @@ For each service, the migration is roughly: - `SessionRunState` — migrated 2026-04-11. Single caller in `server/instance/session.ts` converted; facade removed. - `Account` — migrated 2026-04-11. Callers in `server/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed. - `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed. -- `FileTime` — migrated 2026-04-11. Test-only callers converted; facade removed. - `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed. - `Question` — migrated 2026-04-11. Callers in `server/instance/question.ts` and test converted; facade removed. - `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed. diff --git a/packages/opencode/specs/effect/namespace-treeshake.md b/packages/opencode/specs/effect/namespace-treeshake.md deleted file mode 100644 index ef78c762bb..0000000000 --- a/packages/opencode/specs/effect/namespace-treeshake.md +++ /dev/null @@ -1,256 +0,0 @@ -# Namespace → self-reexport migration - -Migrate every `export namespace Foo { ... }` to flat top-level exports plus a -single self-reexport line at the bottom of the same file: - -```ts -export * as Foo from "./foo" -``` - -No barrel `index.ts` files. No cross-directory indirection. Consumers keep the -exact same `import { Foo } from "../foo/foo"` ergonomics. - -## Why this pattern - -We tested three options against Bun, esbuild, Rollup (what Vite uses under the -hood), Bun's runtime, and Node's native TypeScript runner. - -``` - heavy.ts loaded? - A. namespace B. barrel C. self-reexport -Bun bundler YES YES no -esbuild YES YES no -Rollup (Vite) YES YES no -Bun runtime YES YES no -Node --experimental-strip-types SYNTAX ERROR YES no -``` - -- **`export namespace`** compiles to an IIFE. Bundlers see one opaque function - call and can't analyze what's used. Node's native TS runner rejects the - syntax outright: `SyntaxError: TypeScript namespace declaration is not -supported in strip-only mode`. -- **Barrel `index.ts`** files (`export * as Foo from "./foo"` in a separate - file) force every re-exported sibling to evaluate when you import one name. - Siblings with side effects (top-level imports of SDKs, etc.) always load. -- **Self-reexport** keeps the file as plain ESM. Bundlers see static named - exports. The module is only pulled in when something actually imports from - it. There is no barrel hop, so no sibling contamination and no circular - import hazard. - -Bundle overhead for the self-reexport wrapper is roughly 240 bytes per module -(`Object.defineProperty` namespace proxy). At ~100 modules that's ~24KB — -negligible for a CLI binary. - -## The pattern - -### Before - -```ts -// src/permission/arity.ts -export namespace BashArity { - export function prefix(tokens: string[]) { ... } -} -``` - -### After - -```ts -// src/permission/arity.ts -export function prefix(tokens: string[]) { ... } - -export * as BashArity from "./arity" -``` - -Consumers don't change at all: - -```ts -import { BashArity } from "@/permission/arity" -BashArity.prefix(...) // still works -``` - -Editors still auto-import `BashArity` like any named export, because the file -does have a named `BashArity` export at the module top level. - -### Odd but harmless - -`BashArity.BashArity.BashArity.prefix(...)` compiles and runs because the -namespace contains a re-export of itself. Nobody would write that. Not a -problem. - -## Why this is different from what we tried first - -An earlier pass used sibling barrel files (`index.ts` with `export * as ...`). -That turned out to be wrong for our constraints: - -1. The barrel file always loads all its sibling modules when you import - through it, even if you only need one. For our CLI this is exactly the - cost we're trying to avoid. -2. Barrel + sibling imports made it very easy to accidentally create circular - imports that only surface as `ReferenceError` at runtime, not at - typecheck. - -The self-reexport has none of those issues. There is no indirection. The -file and the namespace are the same unit. - -## Why this matters for startup - -The worst import chain in the codebase looks like: - -``` -src/index.ts - └── FormatError from src/cli/error.ts - ├── { Provider } from provider/provider.ts (~1700 lines) - │ ├── 20+ @ai-sdk/* packages - │ ├── @aws-sdk/credential-providers - │ ├── google-auth-library - │ └── more - ├── { Config } from config/config.ts (~1600 lines) - └── { MCP } from mcp/mcp.ts (~900 lines) -``` - -All of that currently gets pulled in just to do `.isInstance()` on a handful -of error classes. The namespace IIFE shape is the main reason bundlers cannot -strip the unused parts. Self-reexport + flat ESM fixes it. - -## Automation - -From `packages/opencode`: - -```bash -bun script/unwrap-namespace.ts [--dry-run] -``` - -The script: - -1. Uses ast-grep to locate the `export namespace Foo { ... }` block accurately. -2. Removes the `export namespace Foo {` line and the matching closing `}`. -3. Dedents the body by one indent level (2 spaces). -4. Rewrites `Foo.Bar` self-references inside the file to just `Bar`. -5. Appends `export * as Foo from "./"` at the bottom of the file. -6. Never creates a barrel `index.ts`. - -### Typical flow for one file - -```bash -# 1. Preview -bun script/unwrap-namespace.ts src/permission/arity.ts --dry-run - -# 2. Apply -bun script/unwrap-namespace.ts src/permission/arity.ts - -# 3. Verify -cd packages/opencode -bunx --bun tsgo --noEmit -bun run --conditions=browser ./src/index.ts generate -bun run test -``` - -### Consumer imports usually don't need to change - -Most consumers already import straight from the file, e.g.: - -```ts -import { BashArity } from "@/permission/arity" -import { Config } from "@/config/config" -``` - -Because the file itself now does `export * as Foo from "./foo"`, those imports -keep working with zero edits. - -The only edits needed are when a consumer was importing through a previous -barrel (`"@/config"` or `"../config"` resolving to `config/index.ts`). In -that case, repoint it at the file: - -```ts -// before -import { Config } from "@/config" - -// after -import { Config } from "@/config/config" -``` - -### Dynamic imports in tests - -If a test did `const { Foo } = await import("../../src/x/y")`, the destructure -still works because of the self-reexport. No change required. - -## Verification checklist (per PR) - -Run all of these locally before pushing: - -```bash -cd packages/opencode -bunx --bun tsgo --noEmit -bun run --conditions=browser ./src/index.ts generate -bun run test -``` - -Also do a quick grep in `src/`, `test/`, and `script/` to make sure no -consumer is still importing the namespace from an old barrel path that no -longer exports it. - -The SDK build step (`bun run --conditions=browser ./src/index.ts generate`) -evaluates every module eagerly and is the most reliable way to catch circular -import regressions at runtime — the typechecker does not catch these. - -## Rules for new code - -- No new `export namespace`. -- Every module directory has a single canonical file — typically - `dir/index.ts` — with flat top-level exports and a self-reexport at the - bottom: - `export * as Foo from "."` -- Consumers import from the directory: - `import { Foo } from "@/dir"` or `import { Foo } from "../dir"`. -- No sibling barrel files. If a directory has multiple independent - namespaces, they each get their own file (e.g. `config/config.ts`, - `config/plugin.ts`) and their own self-reexport; the `index.ts` in that - directory stays minimal or does not exist. -- If a file needs a sibling, import the sibling file directly: - `import * as Sibling from "./sibling"`, not `from "."`. - -### Why `dir/index.ts` + `"."` is fine for us - -A single-file module (e.g. `pty/`) can live entirely in `dir/index.ts` -with `export * as Foo from "."` at the bottom. Consumers write the -short form: - -```ts -import { Pty } from "@/pty" -``` - -This works in Bun runtime, Bun build, esbuild, and Rollup. It does NOT -work under Node's `--experimental-strip-types` runner: - -``` -node --experimental-strip-types entry.ts - ERR_UNSUPPORTED_DIR_IMPORT: Directory import '/.../pty' is not supported -``` - -Node requires an explicit file or a `package.json#exports` map for ESM. -We don't care about that target right now because the opencode CLI is -built with Bun and the web apps are built with Vite/Rollup. If we ever -want to run raw `.ts` through Node, we'll need to either use explicit -`.ts` extensions everywhere or add per-directory `package.json` exports -maps. - -### When NOT to collapse to `index.ts` - -Some directories contain multiple independent namespaces where -`dir/index.ts` would be misleading. Examples: - -- `config/` has `Config`, `ConfigPaths`, `ConfigMarkdown`, `ConfigPlugin`, - `ConfigKeybinds`. Each lives in its own file with its own self-reexport - (`config/config.ts`, `config/plugin.ts`, etc.). Consumers import the - specific one: `import { ConfigPlugin } from "@/config/plugin"`. -- Same shape for `session/`, `server/`, etc. - -Collapsing one of those into `index.ts` would mean picking a single -"canonical" namespace for the directory, which breaks the symmetry and -hides the other files. - -## Scope - -There are still dozens of `export namespace` files left across the codebase. -Each one is its own small PR. Do them one at a time, verified locally, rather -than batching by directory. diff --git a/packages/opencode/src/account/account.ts b/packages/opencode/src/account/account.ts index 657c61b1e5..a0aed88cba 100644 --- a/packages/opencode/src/account/account.ts +++ b/packages/opencode/src/account/account.ts @@ -181,10 +181,10 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Account") {} -export const layer: Layer.Layer = Layer.effect( +export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { - const repo = yield* AccountRepo + const repo = yield* AccountRepo.Service const http = yield* HttpClient.HttpClient const httpRead = withTransientReadRetry(http) const httpOk = HttpClient.filterStatusOk(http) @@ -452,3 +452,5 @@ export const layer: Layer.Layer = Parameters>[0] const ACCOUNT_STATE_ID = 1 -export namespace AccountRepo { - export interface Service { - readonly active: () => Effect.Effect, AccountRepoError> - readonly list: () => Effect.Effect - readonly remove: (accountID: AccountID) => Effect.Effect - readonly use: (accountID: AccountID, orgID: Option.Option) => Effect.Effect - readonly getRow: (accountID: AccountID) => Effect.Effect, AccountRepoError> - readonly persistToken: (input: { - accountID: AccountID - accessToken: AccessToken - refreshToken: RefreshToken - expiry: Option.Option - }) => Effect.Effect - readonly persistAccount: (input: { - id: AccountID - email: string - url: string - accessToken: AccessToken - refreshToken: RefreshToken - expiry: number - orgID: Option.Option - }) => Effect.Effect - } +export interface Interface { + readonly active: () => Effect.Effect, AccountRepoError> + readonly list: () => Effect.Effect + readonly remove: (accountID: AccountID) => Effect.Effect + readonly use: (accountID: AccountID, orgID: Option.Option) => Effect.Effect + readonly getRow: (accountID: AccountID) => Effect.Effect, AccountRepoError> + readonly persistToken: (input: { + accountID: AccountID + accessToken: AccessToken + refreshToken: RefreshToken + expiry: Option.Option + }) => Effect.Effect + readonly persistAccount: (input: { + id: AccountID + email: string + url: string + accessToken: AccessToken + refreshToken: RefreshToken + expiry: number + orgID: Option.Option + }) => Effect.Effect } -export class AccountRepo extends Context.Service()("@opencode/AccountRepo") { - static readonly layer: Layer.Layer = Layer.effect( - AccountRepo, - Effect.gen(function* () { - const decode = Schema.decodeUnknownSync(Info) +export class Service extends Context.Service()("@opencode/AccountRepo") {} - const query = (f: DbTransactionCallback) => - Effect.try({ - try: () => Database.use(f), - catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }), +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const decode = Schema.decodeUnknownSync(Info) + + const query = (f: DbTransactionCallback) => + Effect.try({ + try: () => Database.use(f), + catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }), + }) + + const tx = (f: DbTransactionCallback) => + Effect.try({ + try: () => Database.transaction(f), + catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }), + }) + + const current = (db: DbClient) => { + const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get() + if (!state?.active_account_id) return + const account = db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get() + if (!account) return + return { ...account, active_org_id: state.active_org_id ?? null } + } + + const state = (db: DbClient, accountID: AccountID, orgID: Option.Option) => { + const id = Option.getOrNull(orgID) + return db + .insert(AccountStateTable) + .values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: id }) + .onConflictDoUpdate({ + target: AccountStateTable.id, + set: { active_account_id: accountID, active_org_id: id }, }) + .run() + } - const tx = (f: DbTransactionCallback) => - Effect.try({ - try: () => Database.transaction(f), - catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }), - }) + const active = Effect.fn("AccountRepo.active")(() => + query((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))), + ) - const current = (db: DbClient) => { - const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get() - if (!state?.active_account_id) return - const account = db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get() - if (!account) return - return { ...account, active_org_id: state.active_org_id ?? null } - } + const list = Effect.fn("AccountRepo.list")(() => + query((db) => + db + .select() + .from(AccountTable) + .all() + .map((row: AccountRow) => decode({ ...row, active_org_id: null })), + ), + ) - const state = (db: DbClient, accountID: AccountID, orgID: Option.Option) => { - const id = Option.getOrNull(orgID) - return db - .insert(AccountStateTable) - .values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: id }) - .onConflictDoUpdate({ - target: AccountStateTable.id, - set: { active_account_id: accountID, active_org_id: id }, - }) + const remove = Effect.fn("AccountRepo.remove")((accountID: AccountID) => + tx((db) => { + db.update(AccountStateTable) + .set({ active_account_id: null, active_org_id: null }) + .where(eq(AccountStateTable.active_account_id, accountID)) .run() - } + db.delete(AccountTable).where(eq(AccountTable.id, accountID)).run() + }).pipe(Effect.asVoid), + ) - const active = Effect.fn("AccountRepo.active")(() => - query((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))), - ) + const use = Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option) => + query((db) => state(db, accountID, orgID)).pipe(Effect.asVoid), + ) - const list = Effect.fn("AccountRepo.list")(() => - query((db) => - db - .select() - .from(AccountTable) - .all() - .map((row: AccountRow) => decode({ ...row, active_org_id: null })), - ), - ) + const getRow = Effect.fn("AccountRepo.getRow")((accountID: AccountID) => + query((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe( + Effect.map(Option.fromNullishOr), + ), + ) - const remove = Effect.fn("AccountRepo.remove")((accountID: AccountID) => - tx((db) => { - db.update(AccountStateTable) - .set({ active_account_id: null, active_org_id: null }) - .where(eq(AccountStateTable.active_account_id, accountID)) - .run() - db.delete(AccountTable).where(eq(AccountTable.id, accountID)).run() - }).pipe(Effect.asVoid), - ) + const persistToken = Effect.fn("AccountRepo.persistToken")((input) => + query((db) => + db + .update(AccountTable) + .set({ + access_token: input.accessToken, + refresh_token: input.refreshToken, + token_expiry: Option.getOrNull(input.expiry), + }) + .where(eq(AccountTable.id, input.accountID)) + .run(), + ).pipe(Effect.asVoid), + ) - const use = Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option) => - query((db) => state(db, accountID, orgID)).pipe(Effect.asVoid), - ) + const persistAccount = Effect.fn("AccountRepo.persistAccount")((input) => + tx((db) => { + const url = normalizeServerUrl(input.url) - const getRow = Effect.fn("AccountRepo.getRow")((accountID: AccountID) => - query((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe( - Effect.map(Option.fromNullishOr), - ), - ) - - const persistToken = Effect.fn("AccountRepo.persistToken")((input) => - query((db) => - db - .update(AccountTable) - .set({ - access_token: input.accessToken, - refresh_token: input.refreshToken, - token_expiry: Option.getOrNull(input.expiry), - }) - .where(eq(AccountTable.id, input.accountID)) - .run(), - ).pipe(Effect.asVoid), - ) - - const persistAccount = Effect.fn("AccountRepo.persistAccount")((input) => - tx((db) => { - const url = normalizeServerUrl(input.url) - - db.insert(AccountTable) - .values({ - id: input.id, + db.insert(AccountTable) + .values({ + id: input.id, + email: input.email, + url, + access_token: input.accessToken, + refresh_token: input.refreshToken, + token_expiry: input.expiry, + }) + .onConflictDoUpdate({ + target: AccountTable.id, + set: { email: input.email, url, access_token: input.accessToken, refresh_token: input.refreshToken, token_expiry: input.expiry, - }) - .onConflictDoUpdate({ - target: AccountTable.id, - set: { - email: input.email, - url, - access_token: input.accessToken, - refresh_token: input.refreshToken, - token_expiry: input.expiry, - }, - }) - .run() - void state(db, input.id, input.orgID) - }).pipe(Effect.asVoid), - ) + }, + }) + .run() + void state(db, input.id, input.orgID) + }).pipe(Effect.asVoid), + ) - return AccountRepo.of({ - active, - list, - remove, - use, - getRow, - persistToken, - persistAccount, - }) - }), - ) -} + return Service.of({ + active, + list, + remove, + use, + getRow, + persistToken, + persistAccount, + }) + }), +) + +export * as AccountRepo from "./repo" diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 69cc46a671..162e92c3a9 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -59,795 +59,262 @@ type ModelOption = { modelId: string; name: string } const DEFAULT_VARIANT_VALUE = "default" -export namespace ACP { - const log = Log.create({ service: "acp-agent" }) +const log = Log.create({ service: "acp-agent" }) - async function getContextLimit( - sdk: KiloClient, - providerID: ProviderID, - modelID: ModelID, - directory: string, - ): Promise { - const providers = await sdk.config - .providers({ directory }) - .then((x) => x.data?.providers ?? []) - .catch((error) => { - log.error("failed to get providers for context limit", { error }) - return [] - }) +async function getContextLimit( + sdk: KiloClient, + providerID: ProviderID, + modelID: ModelID, + directory: string, +): Promise { + const providers = await sdk.config + .providers({ directory }) + .then((x) => x.data?.providers ?? []) + .catch((error) => { + log.error("failed to get providers for context limit", { error }) + return [] + }) - const provider = providers.find((p) => p.id === providerID) - const model = provider?.models[modelID] - return model?.limit.context ?? null + const provider = providers.find((p) => p.id === providerID) + const model = provider?.models[modelID] + return model?.limit.context ?? null +} + +async function sendUsageUpdate( + connection: AgentSideConnection, + sdk: KiloClient, + sessionID: string, + directory: string, +): Promise { + const messages = await sdk.session + .messages({ sessionID, directory }, { throwOnError: true }) + .then((x) => x.data) + .catch((error) => { + log.error("failed to fetch messages for usage update", { error }) + return undefined + }) + + if (!messages) return + + const assistantMessages = messages.filter( + (m): m is { info: AssistantMessage; parts: SessionMessageResponse["parts"] } => m.info.role === "assistant", + ) + + const lastAssistant = assistantMessages[assistantMessages.length - 1] + if (!lastAssistant) return + + const msg = lastAssistant.info + if (!msg.providerID || !msg.modelID) return + const size = await getContextLimit(sdk, ProviderID.make(msg.providerID), ModelID.make(msg.modelID), directory) + + if (!size) { + // Cannot calculate usage without known context size + return } - async function sendUsageUpdate( - connection: AgentSideConnection, - sdk: KiloClient, - sessionID: string, - directory: string, - ): Promise { - const messages = await sdk.session - .messages({ sessionID, directory }, { throwOnError: true }) - .then((x) => x.data) - .catch((error) => { - log.error("failed to fetch messages for usage update", { error }) - return undefined - }) + const used = msg.tokens.input + (msg.tokens.cache?.read ?? 0) + const totalCost = assistantMessages.reduce((sum, m) => sum + m.info.cost, 0) - if (!messages) return - - const assistantMessages = messages.filter( - (m): m is { info: AssistantMessage; parts: SessionMessageResponse["parts"] } => m.info.role === "assistant", - ) - - const lastAssistant = assistantMessages[assistantMessages.length - 1] - if (!lastAssistant) return - - const msg = lastAssistant.info - if (!msg.providerID || !msg.modelID) return - const size = await getContextLimit(sdk, ProviderID.make(msg.providerID), ModelID.make(msg.modelID), directory) - - if (!size) { - // Cannot calculate usage without known context size - return - } - - const used = msg.tokens.input + (msg.tokens.cache?.read ?? 0) - const totalCost = assistantMessages.reduce((sum, m) => sum + m.info.cost, 0) - - await connection - .sessionUpdate({ - sessionId: sessionID, - update: { - sessionUpdate: "usage_update", - used, - size, - cost: { amount: totalCost, currency: "USD" }, - }, - }) - .catch((error) => { - log.error("failed to send usage update", { error }) - }) - } - - export async function init({ sdk: _sdk }: { sdk: KiloClient }) { - return { - create: (connection: AgentSideConnection, fullConfig: ACPConfig) => { - return new Agent(connection, fullConfig) + await connection + .sessionUpdate({ + sessionId: sessionID, + update: { + sessionUpdate: "usage_update", + used, + size, + cost: { amount: totalCost, currency: "USD" }, }, - } + }) + .catch((error) => { + log.error("failed to send usage update", { error }) + }) +} + +export async function init({ sdk: _sdk }: { sdk: KiloClient }) { + return { + create: (connection: AgentSideConnection, fullConfig: ACPConfig) => { + return new Agent(connection, fullConfig) + }, + } +} + +export class Agent implements ACPAgent { + private connection: AgentSideConnection + private config: ACPConfig + private sdk: KiloClient + private sessionManager: ACPSessionManager + private eventAbort = new AbortController() + private eventStarted = false + private bashSnapshots = new Map() + private toolStarts = new Set() + private permissionQueues = new Map>() + private permissionOptions: PermissionOption[] = [ + { optionId: "once", kind: "allow_once", name: "Allow once" }, + { optionId: "always", kind: "allow_always", name: "Always allow" }, + { optionId: "reject", kind: "reject_once", name: "Reject" }, + ] + + constructor(connection: AgentSideConnection, config: ACPConfig) { + this.connection = connection + this.config = config + this.sdk = config.sdk + this.sessionManager = new ACPSessionManager(this.sdk) + this.startEventSubscription() } - export class Agent implements ACPAgent { - private connection: AgentSideConnection - private config: ACPConfig - private sdk: KiloClient - private sessionManager: ACPSessionManager - private eventAbort = new AbortController() - private eventStarted = false - private bashSnapshots = new Map() - private toolStarts = new Set() - private permissionQueues = new Map>() - private permissionOptions: PermissionOption[] = [ - { optionId: "once", kind: "allow_once", name: "Allow once" }, - { optionId: "always", kind: "allow_always", name: "Always allow" }, - { optionId: "reject", kind: "reject_once", name: "Reject" }, - ] + private startEventSubscription() { + if (this.eventStarted) return + this.eventStarted = true + this.runEventSubscription().catch((error) => { + if (this.eventAbort.signal.aborted) return + log.error("event subscription failed", { error }) + }) + } - constructor(connection: AgentSideConnection, config: ACPConfig) { - this.connection = connection - this.config = config - this.sdk = config.sdk - this.sessionManager = new ACPSessionManager(this.sdk) - this.startEventSubscription() - } - - private startEventSubscription() { - if (this.eventStarted) return - this.eventStarted = true - this.runEventSubscription().catch((error) => { - if (this.eventAbort.signal.aborted) return - log.error("event subscription failed", { error }) + private async runEventSubscription() { + while (true) { + if (this.eventAbort.signal.aborted) return + const events = await this.sdk.global.event({ + signal: this.eventAbort.signal, }) - } - - private async runEventSubscription() { - while (true) { + for await (const event of events.stream) { if (this.eventAbort.signal.aborted) return - const events = await this.sdk.global.event({ - signal: this.eventAbort.signal, + const payload = event?.payload + if (!payload) continue + await this.handleEvent(payload as Event).catch((error) => { + log.error("failed to handle event", { error, type: payload.type }) }) - for await (const event of events.stream) { - if (this.eventAbort.signal.aborted) return - const payload = event?.payload - if (!payload) continue - await this.handleEvent(payload as Event).catch((error) => { - log.error("failed to handle event", { error, type: payload.type }) - }) - } } } + } - private async handleEvent(event: Event) { - switch (event.type) { - case "permission.asked": { - const permission = event.properties - const session = this.sessionManager.tryGet(permission.sessionID) - if (!session) return + private async handleEvent(event: Event) { + switch (event.type) { + case "permission.asked": { + const permission = event.properties + const session = this.sessionManager.tryGet(permission.sessionID) + if (!session) return - const prev = this.permissionQueues.get(permission.sessionID) ?? Promise.resolve() - const next = prev - .then(async () => { - const directory = session.cwd + const prev = this.permissionQueues.get(permission.sessionID) ?? Promise.resolve() + const next = prev + .then(async () => { + const directory = session.cwd - const res = await this.connection - .requestPermission({ - sessionId: permission.sessionID, - toolCall: { - toolCallId: permission.tool?.callID ?? permission.id, - status: "pending", - title: permission.permission, - rawInput: permission.metadata, - kind: toToolKind(permission.permission), - locations: toLocations(permission.permission, permission.metadata), - }, - options: this.permissionOptions, + const res = await this.connection + .requestPermission({ + sessionId: permission.sessionID, + toolCall: { + toolCallId: permission.tool?.callID ?? permission.id, + status: "pending", + title: permission.permission, + rawInput: permission.metadata, + kind: toToolKind(permission.permission), + locations: toLocations(permission.permission, permission.metadata), + }, + options: this.permissionOptions, + }) + .catch(async (error) => { + log.error("failed to request permission from ACP", { + error, + permissionID: permission.id, + sessionID: permission.sessionID, }) - .catch(async (error) => { - log.error("failed to request permission from ACP", { - error, - permissionID: permission.id, - sessionID: permission.sessionID, - }) - await this.sdk.permission.reply({ - requestID: permission.id, - reply: "reject", - directory, - }) - return undefined - }) - - if (!res) return - if (res.outcome.outcome !== "selected") { await this.sdk.permission.reply({ requestID: permission.id, reply: "reject", directory, }) - return - } - - if (res.outcome.optionId !== "reject" && permission.permission == "edit") { - const metadata = permission.metadata || {} - const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : "" - const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : "" - const content = (await Filesystem.exists(filepath)) ? await Filesystem.readText(filepath) : "" - const newContent = getNewContent(content, diff) - - if (newContent) { - void this.connection.writeTextFile({ - sessionId: session.id, - path: filepath, - content: newContent, - }) - } - } + return undefined + }) + if (!res) return + if (res.outcome.outcome !== "selected") { await this.sdk.permission.reply({ requestID: permission.id, - reply: res.outcome.optionId as "once" | "always" | "reject", + reply: "reject", directory, }) - }) - .catch((error) => { - log.error("failed to handle permission", { error, permissionID: permission.id }) - }) - .finally(() => { - if (this.permissionQueues.get(permission.sessionID) === next) { - this.permissionQueues.delete(permission.sessionID) + return + } + + if (res.outcome.optionId !== "reject" && permission.permission == "edit") { + const metadata = permission.metadata || {} + const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : "" + const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : "" + const content = (await Filesystem.exists(filepath)) ? await Filesystem.readText(filepath) : "" + const newContent = getNewContent(content, diff) + + if (newContent) { + void this.connection.writeTextFile({ + sessionId: session.id, + path: filepath, + content: newContent, + }) } + } + + await this.sdk.permission.reply({ + requestID: permission.id, + reply: res.outcome.optionId as "once" | "always" | "reject", + directory, }) - this.permissionQueues.set(permission.sessionID, next) - return - } + }) + .catch((error) => { + log.error("failed to handle permission", { error, permissionID: permission.id }) + }) + .finally(() => { + if (this.permissionQueues.get(permission.sessionID) === next) { + this.permissionQueues.delete(permission.sessionID) + } + }) + this.permissionQueues.set(permission.sessionID, next) + return + } - case "message.part.updated": { - log.info("message part updated", { event: event.properties }) - const props = event.properties - const part = props.part - const session = this.sessionManager.tryGet(part.sessionID) - if (!session) return - const sessionId = session.id + case "message.part.updated": { + log.info("message part updated", { event: event.properties }) + const props = event.properties + const part = props.part + const session = this.sessionManager.tryGet(part.sessionID) + if (!session) return + const sessionId = session.id - if (part.type === "tool") { - await this.toolStart(sessionId, part) + if (part.type === "tool") { + await this.toolStart(sessionId, part) - switch (part.state.status) { - case "pending": - this.bashSnapshots.delete(part.callID) - return + switch (part.state.status) { + case "pending": + this.bashSnapshots.delete(part.callID) + return - case "running": - const output = this.bashOutput(part) - const content: ToolCallContent[] = [] - if (output) { - const hash = Hash.fast(output) - if (part.tool === "bash") { - if (this.bashSnapshots.get(part.callID) === hash) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "in_progress", - kind: toToolKind(part.tool), - title: part.tool, - locations: toLocations(part.tool, part.state.input), - rawInput: part.state.input, - }, - }) - .catch((error) => { - log.error("failed to send tool in_progress to ACP", { error }) - }) - return - } - this.bashSnapshots.set(part.callID, hash) - } - content.push({ - type: "content", - content: { - type: "text", - text: output, - }, - }) - } - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "in_progress", - kind: toToolKind(part.tool), - title: part.tool, - locations: toLocations(part.tool, part.state.input), - rawInput: part.state.input, - ...(content.length > 0 && { content }), - }, - }) - .catch((error) => { - log.error("failed to send tool in_progress to ACP", { error }) - }) - return - - case "completed": { - this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) - const kind = toToolKind(part.tool) - const content: ToolCallContent[] = [ - { - type: "content", - content: { - type: "text", - text: part.state.output, - }, - }, - ] - - if (kind === "edit") { - const input = part.state.input - const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" - const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" - const newText = - typeof input["newString"] === "string" - ? input["newString"] - : typeof input["content"] === "string" - ? input["content"] - : "" - content.push({ - type: "diff", - path: filePath, - oldText, - newText, - }) - } - - if (part.tool === "todowrite") { - const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output)) - if (parsedTodos.success) { + case "running": + const output = this.bashOutput(part) + const content: ToolCallContent[] = [] + if (output) { + const hash = Hash.fast(output) + if (part.tool === "bash") { + if (this.bashSnapshots.get(part.callID) === hash) { await this.connection .sessionUpdate({ sessionId, update: { - sessionUpdate: "plan", - entries: parsedTodos.data.map((todo) => { - const status: PlanEntry["status"] = - todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"]) - return { - priority: "medium", - status, - content: todo.content, - } - }), + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "in_progress", + kind: toToolKind(part.tool), + title: part.tool, + locations: toLocations(part.tool, part.state.input), + rawInput: part.state.input, }, }) .catch((error) => { - log.error("failed to send session update for todo", { error }) + log.error("failed to send tool in_progress to ACP", { error }) }) - } else { - log.error("failed to parse todo output", { error: parsedTodos.error }) + return } + this.bashSnapshots.set(part.callID, hash) } - - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "completed", - kind, - content, - title: part.state.title, - rawInput: part.state.input, - rawOutput: { - output: part.state.output, - metadata: part.state.metadata, - }, - }, - }) - .catch((error) => { - log.error("failed to send tool completed to ACP", { error }) - }) - return - } - case "error": - this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "failed", - kind: toToolKind(part.tool), - title: part.tool, - rawInput: part.state.input, - content: [ - { - type: "content", - content: { - type: "text", - text: part.state.error, - }, - }, - ], - rawOutput: { - error: part.state.error, - metadata: part.state.metadata, - }, - }, - }) - .catch((error) => { - log.error("failed to send tool error to ACP", { error }) - }) - return - } - } - - // ACP clients already know the prompt they just submitted, so replaying - // live user parts duplicates the message. We still replay user history in - // loadSession() and forkSession() via processMessage(). - if (part.type !== "text" && part.type !== "file") return - - return - } - - case "message.part.delta": { - const props = event.properties - const session = this.sessionManager.tryGet(props.sessionID) - if (!session) return - const sessionId = session.id - - const message = await this.sdk.session - .message( - { - sessionID: props.sessionID, - messageID: props.messageID, - directory: session.cwd, - }, - { throwOnError: true }, - ) - .then((x) => x.data) - .catch((error) => { - log.error("unexpected error when fetching message", { error }) - return undefined - }) - - if (!message || message.info.role !== "assistant") return - - const part = message.parts.find((p) => p.id === props.partID) - if (!part) return - - if (part.type === "text" && props.field === "text" && part.ignored !== true) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "agent_message_chunk", - messageId: props.messageID, - content: { - type: "text", - text: props.delta, - }, - }, - }) - .catch((error) => { - log.error("failed to send text delta to ACP", { error }) - }) - return - } - - if (part.type === "reasoning" && props.field === "text") { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "agent_thought_chunk", - messageId: props.messageID, - content: { - type: "text", - text: props.delta, - }, - }, - }) - .catch((error) => { - log.error("failed to send reasoning delta to ACP", { error }) - }) - } - return - } - } - } - - async initialize(params: InitializeRequest): Promise { - log.info("initialize", { protocolVersion: params.protocolVersion }) - - // kilocode_change start - const authMethod: AuthMethod = { - description: "Run `kilo auth login` in the terminal", - name: "Login with Kilo", - id: "kilo-login", - } - // kilocode_change end - - // If client supports terminal-auth capability, use that instead. - if (params.clientCapabilities?._meta?.["terminal-auth"] === true) { - authMethod._meta = { - "terminal-auth": { - command: "kilo", - args: ["auth", "login"], - label: "Kilo Login", // kilocode_change - }, - } - } - - return { - protocolVersion: 1, - agentCapabilities: { - loadSession: true, - mcpCapabilities: { - http: true, - sse: true, - }, - promptCapabilities: { - embeddedContext: true, - image: true, - }, - sessionCapabilities: { - fork: {}, - list: {}, - resume: {}, - }, - }, - authMethods: [authMethod], - agentInfo: { - name: "Kilo", // kilocode_change - version: InstallationVersion, - }, - } - } - - async authenticate(_params: AuthenticateRequest) { - throw new Error("Authentication not implemented") - } - - async newSession(params: NewSessionRequest) { - const directory = params.cwd - try { - const model = await defaultModel(this.config, directory) - - // Store ACP session state - const state = await this.sessionManager.create(params.cwd, params.mcpServers, model) - const sessionId = state.id - - log.info("creating_session", { sessionId, mcpServers: params.mcpServers.length }) - - const load = await this.loadSessionMode({ - cwd: directory, - mcpServers: params.mcpServers, - sessionId, - }) - - return { - sessionId, - configOptions: load.configOptions, - models: load.models, - modes: load.modes, - _meta: load._meta, - } - } catch (e) { - const error = MessageV2.fromError(e, { - providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), - }) - if (LoadAPIKeyError.isInstance(error)) { - throw RequestError.authRequired() - } - throw e - } - } - - async loadSession(params: LoadSessionRequest) { - const directory = params.cwd - const sessionId = params.sessionId - - try { - const model = await defaultModel(this.config, directory) - - // Store ACP session state - await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model) - - log.info("load_session", { sessionId, mcpServers: params.mcpServers.length }) - - const result = await this.loadSessionMode({ - cwd: directory, - mcpServers: params.mcpServers, - sessionId, - }) - - // Replay session history - const messages = await this.sdk.session - .messages( - { - sessionID: sessionId, - directory, - }, - { throwOnError: true }, - ) - .then((x) => x.data) - .catch((err) => { - log.error("unexpected error when fetching message", { error: err }) - return undefined - }) - - const lastUser = messages?.findLast((m) => m.info.role === "user")?.info - if (lastUser?.role === "user") { - result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}` - this.sessionManager.setModel(sessionId, { - providerID: ProviderID.make(lastUser.model.providerID), - modelID: ModelID.make(lastUser.model.modelID), - }) - if (result.modes?.availableModes.some((m) => m.id === lastUser.agent)) { - result.modes.currentModeId = lastUser.agent - this.sessionManager.setMode(sessionId, lastUser.agent) - } - result.configOptions = buildConfigOptions({ - currentModelId: result.models.currentModelId, - availableModels: result.models.availableModels, - modes: result.modes, - }) - } - - for (const msg of messages ?? []) { - log.debug("replay message", msg) - await this.processMessage(msg) - } - - await sendUsageUpdate(this.connection, this.sdk, sessionId, directory) - - return result - } catch (e) { - const error = MessageV2.fromError(e, { - providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), - }) - if (LoadAPIKeyError.isInstance(error)) { - throw RequestError.authRequired() - } - throw e - } - } - - async listSessions(params: ListSessionsRequest): Promise { - try { - const cursor = params.cursor ? Number(params.cursor) : undefined - const limit = 100 - - const sessions = await this.sdk.session - .list( - { - directory: params.cwd ?? undefined, - roots: true, - }, - { throwOnError: true }, - ) - .then((x) => x.data ?? []) - - const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated) - const filtered = cursor ? sorted.filter((s) => s.time.updated < cursor) : sorted - const page = filtered.slice(0, limit) - - const entries: SessionInfo[] = page.map((session) => ({ - sessionId: session.id, - cwd: session.directory, - title: session.title, - updatedAt: new Date(session.time.updated).toISOString(), - })) - - const last = page[page.length - 1] - const next = filtered.length > limit && last ? String(last.time.updated) : undefined - - const response: ListSessionsResponse = { - sessions: entries, - } - if (next) response.nextCursor = next - return response - } catch (e) { - const error = MessageV2.fromError(e, { - providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), - }) - if (LoadAPIKeyError.isInstance(error)) { - throw RequestError.authRequired() - } - throw e - } - } - - async unstable_forkSession(params: ForkSessionRequest): Promise { - const directory = params.cwd - const mcpServers = params.mcpServers ?? [] - - try { - const model = await defaultModel(this.config, directory) - - const forked = await this.sdk.session - .fork( - { - sessionID: params.sessionId, - directory, - }, - { throwOnError: true }, - ) - .then((x) => x.data) - - if (!forked) { - throw new Error("Fork session returned no data") - } - - const sessionId = forked.id - await this.sessionManager.load(sessionId, directory, mcpServers, model) - - log.info("fork_session", { sessionId, mcpServers: mcpServers.length }) - - const mode = await this.loadSessionMode({ - cwd: directory, - mcpServers, - sessionId, - }) - - const messages = await this.sdk.session - .messages( - { - sessionID: sessionId, - directory, - }, - { throwOnError: true }, - ) - .then((x) => x.data) - .catch((err) => { - log.error("unexpected error when fetching message", { error: err }) - return undefined - }) - - for (const msg of messages ?? []) { - log.debug("replay message", msg) - await this.processMessage(msg) - } - - await sendUsageUpdate(this.connection, this.sdk, sessionId, directory) - - return mode - } catch (e) { - const error = MessageV2.fromError(e, { - providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), - }) - if (LoadAPIKeyError.isInstance(error)) { - throw RequestError.authRequired() - } - throw e - } - } - - async unstable_resumeSession(params: ResumeSessionRequest): Promise { - const directory = params.cwd - const sessionId = params.sessionId - const mcpServers = params.mcpServers ?? [] - - try { - const model = await defaultModel(this.config, directory) - await this.sessionManager.load(sessionId, directory, mcpServers, model) - - log.info("resume_session", { sessionId, mcpServers: mcpServers.length }) - - const result = await this.loadSessionMode({ - cwd: directory, - mcpServers, - sessionId, - }) - - await sendUsageUpdate(this.connection, this.sdk, sessionId, directory) - - return result - } catch (e) { - const error = MessageV2.fromError(e, { - providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), - }) - if (LoadAPIKeyError.isInstance(error)) { - throw RequestError.authRequired() - } - throw e - } - } - - private async processMessage(message: SessionMessageResponse) { - log.debug("process message", message) - if (message.info.role !== "assistant" && message.info.role !== "user") return - const sessionId = message.info.sessionID - - for (const part of message.parts) { - if (part.type === "tool") { - await this.toolStart(sessionId, part) - switch (part.state.status) { - case "pending": - this.bashSnapshots.delete(part.callID) - break - case "running": - const output = this.bashOutput(part) - const runningContent: ToolCallContent[] = [] - if (output) { - runningContent.push({ + content.push({ type: "content", content: { type: "text", @@ -866,14 +333,15 @@ export namespace ACP { title: part.tool, locations: toLocations(part.tool, part.state.input), rawInput: part.state.input, - ...(runningContent.length > 0 && { content: runningContent }), + ...(content.length > 0 && { content }), }, }) - .catch((err) => { - log.error("failed to send tool in_progress to ACP", { error: err }) + .catch((error) => { + log.error("failed to send tool in_progress to ACP", { error }) }) - break - case "completed": + return + + case "completed": { this.toolStarts.delete(part.callID) this.bashSnapshots.delete(part.callID) const kind = toToolKind(part.tool) @@ -924,8 +392,8 @@ export namespace ACP { }), }, }) - .catch((err) => { - log.error("failed to send session update for todo", { error: err }) + .catch((error) => { + log.error("failed to send session update for todo", { error }) }) } else { log.error("failed to parse todo output", { error: parsedTodos.error }) @@ -949,10 +417,11 @@ export namespace ACP { }, }, }) - .catch((err) => { - log.error("failed to send tool completed to ACP", { error: err }) + .catch((error) => { + log.error("failed to send tool completed to ACP", { error }) }) - break + return + } case "error": this.toolStarts.delete(part.callID) this.bashSnapshots.delete(part.callID) @@ -981,875 +450,1402 @@ export namespace ACP { }, }, }) - .catch((err) => { - log.error("failed to send tool error to ACP", { error: err }) + .catch((error) => { + log.error("failed to send tool error to ACP", { error }) }) - break + return } - } else if (part.type === "text") { - if (part.text) { - const audience: Role[] | undefined = part.synthetic ? ["assistant"] : part.ignored ? ["user"] : undefined + } + + // ACP clients already know the prompt they just submitted, so replaying + // live user parts duplicates the message. We still replay user history in + // loadSession() and forkSession() via processMessage(). + if (part.type !== "text" && part.type !== "file") return + + return + } + + case "message.part.delta": { + const props = event.properties + const session = this.sessionManager.tryGet(props.sessionID) + if (!session) return + const sessionId = session.id + + const message = await this.sdk.session + .message( + { + sessionID: props.sessionID, + messageID: props.messageID, + directory: session.cwd, + }, + { throwOnError: true }, + ) + .then((x) => x.data) + .catch((error) => { + log.error("unexpected error when fetching message", { error }) + return undefined + }) + + if (!message || message.info.role !== "assistant") return + + const part = message.parts.find((p) => p.id === props.partID) + if (!part) return + + if (part.type === "text" && props.field === "text" && part.ignored !== true) { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + messageId: props.messageID, + content: { + type: "text", + text: props.delta, + }, + }, + }) + .catch((error) => { + log.error("failed to send text delta to ACP", { error }) + }) + return + } + + if (part.type === "reasoning" && props.field === "text") { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_thought_chunk", + messageId: props.messageID, + content: { + type: "text", + text: props.delta, + }, + }, + }) + .catch((error) => { + log.error("failed to send reasoning delta to ACP", { error }) + }) + } + return + } + } + } + + async initialize(params: InitializeRequest): Promise { + log.info("initialize", { protocolVersion: params.protocolVersion }) + + // kilocode_change start + const authMethod: AuthMethod = { + description: "Run `kilo auth login` in the terminal", + name: "Login with Kilo", + id: "kilo-login", + } + // kilocode_change end + + // If client supports terminal-auth capability, use that instead. + if (params.clientCapabilities?._meta?.["terminal-auth"] === true) { + authMethod._meta = { + "terminal-auth": { + command: "opencode", + args: ["auth", "login"], + label: "Kilo Login", // kilocode_change + }, + } + } + + return { + protocolVersion: 1, + agentCapabilities: { + loadSession: true, + mcpCapabilities: { + http: true, + sse: true, + }, + promptCapabilities: { + embeddedContext: true, + image: true, + }, + sessionCapabilities: { + fork: {}, + list: {}, + resume: {}, + }, + }, + authMethods: [authMethod], + agentInfo: { + name: "Kilo", // kilocode_change + version: InstallationVersion, + }, + } + } + + async authenticate(_params: AuthenticateRequest) { + throw new Error("Authentication not implemented") + } + + async newSession(params: NewSessionRequest) { + const directory = params.cwd + try { + const model = await defaultModel(this.config, directory) + + // Store ACP session state + const state = await this.sessionManager.create(params.cwd, params.mcpServers, model) + const sessionId = state.id + + log.info("creating_session", { sessionId, mcpServers: params.mcpServers.length }) + + const load = await this.loadSessionMode({ + cwd: directory, + mcpServers: params.mcpServers, + sessionId, + }) + + return { + sessionId, + configOptions: load.configOptions, + models: load.models, + modes: load.modes, + _meta: load._meta, + } + } catch (e) { + const error = MessageV2.fromError(e, { + providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), + }) + if (LoadAPIKeyError.isInstance(error)) { + throw RequestError.authRequired() + } + throw e + } + } + + async loadSession(params: LoadSessionRequest) { + const directory = params.cwd + const sessionId = params.sessionId + + try { + const model = await defaultModel(this.config, directory) + + // Store ACP session state + await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model) + + log.info("load_session", { sessionId, mcpServers: params.mcpServers.length }) + + const result = await this.loadSessionMode({ + cwd: directory, + mcpServers: params.mcpServers, + sessionId, + }) + + // Replay session history + const messages = await this.sdk.session + .messages( + { + sessionID: sessionId, + directory, + }, + { throwOnError: true }, + ) + .then((x) => x.data) + .catch((err) => { + log.error("unexpected error when fetching message", { error: err }) + return undefined + }) + + const lastUser = messages?.findLast((m) => m.info.role === "user")?.info + if (lastUser?.role === "user") { + result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}` + this.sessionManager.setModel(sessionId, { + providerID: ProviderID.make(lastUser.model.providerID), + modelID: ModelID.make(lastUser.model.modelID), + }) + if (result.modes?.availableModes.some((m) => m.id === lastUser.agent)) { + result.modes.currentModeId = lastUser.agent + this.sessionManager.setMode(sessionId, lastUser.agent) + } + result.configOptions = buildConfigOptions({ + currentModelId: result.models.currentModelId, + availableModels: result.models.availableModels, + modes: result.modes, + }) + } + + for (const msg of messages ?? []) { + log.debug("replay message", msg) + await this.processMessage(msg) + } + + await sendUsageUpdate(this.connection, this.sdk, sessionId, directory) + + return result + } catch (e) { + const error = MessageV2.fromError(e, { + providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), + }) + if (LoadAPIKeyError.isInstance(error)) { + throw RequestError.authRequired() + } + throw e + } + } + + async listSessions(params: ListSessionsRequest): Promise { + try { + const cursor = params.cursor ? Number(params.cursor) : undefined + const limit = 100 + + const sessions = await this.sdk.session + .list( + { + directory: params.cwd ?? undefined, + roots: true, + }, + { throwOnError: true }, + ) + .then((x) => x.data ?? []) + + const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated) + const filtered = cursor ? sorted.filter((s) => s.time.updated < cursor) : sorted + const page = filtered.slice(0, limit) + + const entries: SessionInfo[] = page.map((session) => ({ + sessionId: session.id, + cwd: session.directory, + title: session.title, + updatedAt: new Date(session.time.updated).toISOString(), + })) + + const last = page[page.length - 1] + const next = filtered.length > limit && last ? String(last.time.updated) : undefined + + const response: ListSessionsResponse = { + sessions: entries, + } + if (next) response.nextCursor = next + return response + } catch (e) { + const error = MessageV2.fromError(e, { + providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), + }) + if (LoadAPIKeyError.isInstance(error)) { + throw RequestError.authRequired() + } + throw e + } + } + + async unstable_forkSession(params: ForkSessionRequest): Promise { + const directory = params.cwd + const mcpServers = params.mcpServers ?? [] + + try { + const model = await defaultModel(this.config, directory) + + const forked = await this.sdk.session + .fork( + { + sessionID: params.sessionId, + directory, + }, + { throwOnError: true }, + ) + .then((x) => x.data) + + if (!forked) { + throw new Error("Fork session returned no data") + } + + const sessionId = forked.id + await this.sessionManager.load(sessionId, directory, mcpServers, model) + + log.info("fork_session", { sessionId, mcpServers: mcpServers.length }) + + const mode = await this.loadSessionMode({ + cwd: directory, + mcpServers, + sessionId, + }) + + const messages = await this.sdk.session + .messages( + { + sessionID: sessionId, + directory, + }, + { throwOnError: true }, + ) + .then((x) => x.data) + .catch((err) => { + log.error("unexpected error when fetching message", { error: err }) + return undefined + }) + + for (const msg of messages ?? []) { + log.debug("replay message", msg) + await this.processMessage(msg) + } + + await sendUsageUpdate(this.connection, this.sdk, sessionId, directory) + + return mode + } catch (e) { + const error = MessageV2.fromError(e, { + providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), + }) + if (LoadAPIKeyError.isInstance(error)) { + throw RequestError.authRequired() + } + throw e + } + } + + async unstable_resumeSession(params: ResumeSessionRequest): Promise { + const directory = params.cwd + const sessionId = params.sessionId + const mcpServers = params.mcpServers ?? [] + + try { + const model = await defaultModel(this.config, directory) + await this.sessionManager.load(sessionId, directory, mcpServers, model) + + log.info("resume_session", { sessionId, mcpServers: mcpServers.length }) + + const result = await this.loadSessionMode({ + cwd: directory, + mcpServers, + sessionId, + }) + + await sendUsageUpdate(this.connection, this.sdk, sessionId, directory) + + return result + } catch (e) { + const error = MessageV2.fromError(e, { + providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), + }) + if (LoadAPIKeyError.isInstance(error)) { + throw RequestError.authRequired() + } + throw e + } + } + + private async processMessage(message: SessionMessageResponse) { + log.debug("process message", message) + if (message.info.role !== "assistant" && message.info.role !== "user") return + const sessionId = message.info.sessionID + + for (const part of message.parts) { + if (part.type === "tool") { + await this.toolStart(sessionId, part) + switch (part.state.status) { + case "pending": + this.bashSnapshots.delete(part.callID) + break + case "running": + const output = this.bashOutput(part) + const runningContent: ToolCallContent[] = [] + if (output) { + runningContent.push({ + type: "content", + content: { + type: "text", + text: output, + }, + }) + } await this.connection .sessionUpdate({ sessionId, update: { - sessionUpdate: message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk", - messageId: message.info.id, - content: { - type: "text", - text: part.text, - ...(audience && { annotations: { audience } }), + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "in_progress", + kind: toToolKind(part.tool), + title: part.tool, + locations: toLocations(part.tool, part.state.input), + rawInput: part.state.input, + ...(runningContent.length > 0 && { content: runningContent }), + }, + }) + .catch((err) => { + log.error("failed to send tool in_progress to ACP", { error: err }) + }) + break + case "completed": + this.toolStarts.delete(part.callID) + this.bashSnapshots.delete(part.callID) + const kind = toToolKind(part.tool) + const content: ToolCallContent[] = [ + { + type: "content", + content: { + type: "text", + text: part.state.output, + }, + }, + ] + + if (kind === "edit") { + const input = part.state.input + const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" + const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" + const newText = + typeof input["newString"] === "string" + ? input["newString"] + : typeof input["content"] === "string" + ? input["content"] + : "" + content.push({ + type: "diff", + path: filePath, + oldText, + newText, + }) + } + + if (part.tool === "todowrite") { + const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output)) + if (parsedTodos.success) { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "plan", + entries: parsedTodos.data.map((todo) => { + const status: PlanEntry["status"] = + todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"]) + return { + priority: "medium", + status, + content: todo.content, + } + }), + }, + }) + .catch((err) => { + log.error("failed to send session update for todo", { error: err }) + }) + } else { + log.error("failed to parse todo output", { error: parsedTodos.error }) + } + } + + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "completed", + kind, + content, + title: part.state.title, + rawInput: part.state.input, + rawOutput: { + output: part.state.output, + metadata: part.state.metadata, }, }, }) .catch((err) => { - log.error("failed to send text to ACP", { error: err }) + log.error("failed to send tool completed to ACP", { error: err }) }) - } - } else if (part.type === "file") { - // Replay file attachments as appropriate ACP content blocks. - // OpenCode stores files internally as { type: "file", url, filename, mime }. - // We convert these back to ACP blocks based on the URL scheme and MIME type: - // - file:// URLs → resource_link - // - data: URLs with image/* → image block - // - data: URLs with text/* or application/json → resource with text - // - data: URLs with other types → resource with blob - const url = part.url - const filename = part.filename ?? "file" - const mime = part.mime || "application/octet-stream" - const messageChunk = message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk" + break + case "error": + this.toolStarts.delete(part.callID) + this.bashSnapshots.delete(part.callID) + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "failed", + kind: toToolKind(part.tool), + title: part.tool, + rawInput: part.state.input, + content: [ + { + type: "content", + content: { + type: "text", + text: part.state.error, + }, + }, + ], + rawOutput: { + error: part.state.error, + metadata: part.state.metadata, + }, + }, + }) + .catch((err) => { + log.error("failed to send tool error to ACP", { error: err }) + }) + break + } + } else if (part.type === "text") { + if (part.text) { + const audience: Role[] | undefined = part.synthetic ? ["assistant"] : part.ignored ? ["user"] : undefined + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk", + messageId: message.info.id, + content: { + type: "text", + text: part.text, + ...(audience && { annotations: { audience } }), + }, + }, + }) + .catch((err) => { + log.error("failed to send text to ACP", { error: err }) + }) + } + } else if (part.type === "file") { + // Replay file attachments as appropriate ACP content blocks. + // OpenCode stores files internally as { type: "file", url, filename, mime }. + // We convert these back to ACP blocks based on the URL scheme and MIME type: + // - file:// URLs → resource_link + // - data: URLs with image/* → image block + // - data: URLs with text/* or application/json → resource with text + // - data: URLs with other types → resource with blob + const url = part.url + const filename = part.filename ?? "file" + const mime = part.mime || "application/octet-stream" + const messageChunk = message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk" - if (url.startsWith("file://")) { - // Local file reference - send as resource_link + if (url.startsWith("file://")) { + // Local file reference - send as resource_link + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: messageChunk, + messageId: message.info.id, + content: { type: "resource_link", uri: url, name: filename, mimeType: mime }, + }, + }) + .catch((err) => { + log.error("failed to send resource_link to ACP", { error: err }) + }) + } else if (url.startsWith("data:")) { + // Embedded content - parse data URL and send as appropriate block type + const base64Match = url.match(/^data:([^;]+);base64,(.*)$/) + const dataMime = base64Match?.[1] + const base64Data = base64Match?.[2] ?? "" + + const effectiveMime = dataMime || mime + + if (effectiveMime.startsWith("image/")) { + // Image - send as image block await this.connection .sessionUpdate({ sessionId, update: { sessionUpdate: messageChunk, messageId: message.info.id, - content: { type: "resource_link", uri: url, name: filename, mimeType: mime }, + content: { + type: "image", + mimeType: effectiveMime, + data: base64Data, + uri: pathToFileURL(filename).href, + }, }, }) .catch((err) => { - log.error("failed to send resource_link to ACP", { error: err }) + log.error("failed to send image to ACP", { error: err }) }) - } else if (url.startsWith("data:")) { - // Embedded content - parse data URL and send as appropriate block type - const base64Match = url.match(/^data:([^;]+);base64,(.*)$/) - const dataMime = base64Match?.[1] - const base64Data = base64Match?.[2] ?? "" + } else { + // Non-image: text types get decoded, binary types stay as blob + const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json" + const fileUri = pathToFileURL(filename).href + const resource = isText + ? { + uri: fileUri, + mimeType: effectiveMime, + text: Buffer.from(base64Data, "base64").toString("utf-8"), + } + : { uri: fileUri, mimeType: effectiveMime, blob: base64Data } - const effectiveMime = dataMime || mime - - if (effectiveMime.startsWith("image/")) { - // Image - send as image block - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: messageChunk, - messageId: message.info.id, - content: { - type: "image", - mimeType: effectiveMime, - data: base64Data, - uri: pathToFileURL(filename).href, - }, - }, - }) - .catch((err) => { - log.error("failed to send image to ACP", { error: err }) - }) - } else { - // Non-image: text types get decoded, binary types stay as blob - const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json" - const fileUri = pathToFileURL(filename).href - const resource = isText - ? { - uri: fileUri, - mimeType: effectiveMime, - text: Buffer.from(base64Data, "base64").toString("utf-8"), - } - : { uri: fileUri, mimeType: effectiveMime, blob: base64Data } - - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: messageChunk, - messageId: message.info.id, - content: { type: "resource", resource }, - }, - }) - .catch((err) => { - log.error("failed to send resource to ACP", { error: err }) - }) - } - } - // URLs that don't match file:// or data: are skipped (unsupported) - } else if (part.type === "reasoning") { - if (part.text) { await this.connection .sessionUpdate({ sessionId, update: { - sessionUpdate: "agent_thought_chunk", + sessionUpdate: messageChunk, messageId: message.info.id, - content: { - type: "text", - text: part.text, - }, + content: { type: "resource", resource }, }, }) .catch((err) => { - log.error("failed to send reasoning to ACP", { error: err }) + log.error("failed to send resource to ACP", { error: err }) }) } } - } - } - - private bashOutput(part: ToolPart) { - if (part.tool !== "bash") return - if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return - const output = part.state.metadata["output"] - if (typeof output !== "string") return - return output - } - - private async toolStart(sessionId: string, part: ToolPart) { - if (this.toolStarts.has(part.callID)) return - this.toolStarts.add(part.callID) - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call", - toolCallId: part.callID, - title: part.tool, - kind: toToolKind(part.tool), - status: "pending", - locations: [], - rawInput: {}, - }, - }) - .catch((error) => { - log.error("failed to send tool pending to ACP", { error }) - }) - } - - private async loadAvailableModes(directory: string): Promise { - const agents = await this.config.sdk.app - .agents( - { - directory, - }, - { throwOnError: true }, - ) - .then((resp) => resp.data!) - - return agents - .filter((agent) => agent.mode !== "subagent" && !agent.hidden) - .map((agent) => ({ - id: agent.name, - name: agent.name, - description: agent.description, - })) - } - - private async resolveModeState( - directory: string, - sessionId: string, - ): Promise<{ availableModes: ModeOption[]; currentModeId?: string }> { - const availableModes = await this.loadAvailableModes(directory) - const currentModeId = - this.sessionManager.get(sessionId).modeId || - (await (async () => { - if (!availableModes.length) return undefined - const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent())) - const resolvedModeId = - availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id - this.sessionManager.setMode(sessionId, resolvedModeId) - return resolvedModeId - })()) - - return { availableModes, currentModeId } - } - - private async loadSessionMode(params: LoadSessionRequest) { - const directory = params.cwd - const model = await defaultModel(this.config, directory) - const sessionId = params.sessionId - - const providers = await this.sdk.config.providers({ directory }).then((x) => x.data!.providers) - const entries = sortProvidersByName(providers) - const availableVariants = modelVariantsFromProviders(entries, model) - const currentVariant = this.sessionManager.getVariant(sessionId) - if (currentVariant && !availableVariants.includes(currentVariant)) { - this.sessionManager.setVariant(sessionId, undefined) - } - const availableModels = buildAvailableModels(entries, { includeVariants: true }) - const modeState = await this.resolveModeState(directory, sessionId) - const currentModeId = modeState.currentModeId - const modes = currentModeId - ? { - availableModes: modeState.availableModes, - currentModeId, - } - : undefined - - const commands = await this.config.sdk.command - .list( - { - directory, - }, - { throwOnError: true }, - ) - .then((resp) => resp.data!) - - const availableCommands = commands.map((command) => ({ - name: command.name, - description: command.description ?? "", - })) - const names = new Set(availableCommands.map((c) => c.name)) - if (!names.has("compact")) - availableCommands.push({ - name: "compact", - description: "compact the session", - }) - - const mcpServers: Record = {} - for (const server of params.mcpServers) { - if ("type" in server) { - mcpServers[server.name] = { - url: server.url, - headers: server.headers.reduce>((acc, { name, value }) => { - acc[name] = value - return acc - }, {}), - type: "remote", - } - } else { - mcpServers[server.name] = { - type: "local", - command: [server.command, ...server.args], - environment: server.env.reduce>((acc, { name, value }) => { - acc[name] = value - return acc - }, {}), - } - } - } - - await Promise.all( - Object.entries(mcpServers).map(async ([key, mcp]) => { - await this.sdk.mcp - .add( - { - directory, - name: key, - config: mcp, + // URLs that don't match file:// or data: are skipped (unsupported) + } else if (part.type === "reasoning") { + if (part.text) { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_thought_chunk", + messageId: message.info.id, + content: { + type: "text", + text: part.text, + }, }, - { throwOnError: true }, - ) - .catch((error) => { - log.error("failed to add mcp server", { name: key, error }) }) - }), - ) + .catch((err) => { + log.error("failed to send reasoning to ACP", { error: err }) + }) + } + } + } + } - setTimeout(() => { - void this.connection.sessionUpdate({ - sessionId, - update: { - sessionUpdate: "available_commands_update", - availableCommands, - }, - }) - }, 0) + private bashOutput(part: ToolPart) { + if (part.tool !== "bash") return + if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return + const output = part.state.metadata["output"] + if (typeof output !== "string") return + return output + } - return { + private async toolStart(sessionId: string, part: ToolPart) { + if (this.toolStarts.has(part.callID)) return + this.toolStarts.add(part.callID) + await this.connection + .sessionUpdate({ sessionId, - models: { - currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true), - availableModels, + update: { + sessionUpdate: "tool_call", + toolCallId: part.callID, + title: part.tool, + kind: toToolKind(part.tool), + status: "pending", + locations: [], + rawInput: {}, }, - modes, - configOptions: buildConfigOptions({ - currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true), - availableModels, - modes, - }), - _meta: buildVariantMeta({ - model, - variant: this.sessionManager.getVariant(sessionId), - availableVariants, - }), - } + }) + .catch((error) => { + log.error("failed to send tool pending to ACP", { error }) + }) + } + + private async loadAvailableModes(directory: string): Promise { + const agents = await this.config.sdk.app + .agents( + { + directory, + }, + { throwOnError: true }, + ) + .then((resp) => resp.data!) + + return agents + .filter((agent) => agent.mode !== "subagent" && !agent.hidden) + .map((agent) => ({ + id: agent.name, + name: agent.name, + description: agent.description, + })) + } + + private async resolveModeState( + directory: string, + sessionId: string, + ): Promise<{ availableModes: ModeOption[]; currentModeId?: string }> { + const availableModes = await this.loadAvailableModes(directory) + const currentModeId = + this.sessionManager.get(sessionId).modeId || + (await (async () => { + if (!availableModes.length) return undefined + const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent())) + const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id + this.sessionManager.setMode(sessionId, resolvedModeId) + return resolvedModeId + })()) + + return { availableModes, currentModeId } + } + + private async loadSessionMode(params: LoadSessionRequest) { + const directory = params.cwd + const model = await defaultModel(this.config, directory) + const sessionId = params.sessionId + + const providers = await this.sdk.config.providers({ directory }).then((x) => x.data!.providers) + const entries = sortProvidersByName(providers) + const availableVariants = modelVariantsFromProviders(entries, model) + const currentVariant = this.sessionManager.getVariant(sessionId) + if (currentVariant && !availableVariants.includes(currentVariant)) { + this.sessionManager.setVariant(sessionId, undefined) } - - async unstable_setSessionModel(params: SetSessionModelRequest) { - const session = this.sessionManager.get(params.sessionId) - const providers = await this.sdk.config - .providers({ directory: session.cwd }, { throwOnError: true }) - .then((x) => x.data!.providers) - - const selection = parseModelSelection(params.modelId, providers) - this.sessionManager.setModel(session.id, selection.model) - this.sessionManager.setVariant(session.id, selection.variant) - - const entries = sortProvidersByName(providers) - const availableVariants = modelVariantsFromProviders(entries, selection.model) - - return { - _meta: buildVariantMeta({ - model: selection.model, - variant: selection.variant, - availableVariants, - }), - } - } - - async setSessionMode(params: SetSessionModeRequest): Promise { - const session = this.sessionManager.get(params.sessionId) - const availableModes = await this.loadAvailableModes(session.cwd) - if (!availableModes.some((mode) => mode.id === params.modeId)) { - throw new Error(`Agent not found: ${params.modeId}`) - } - this.sessionManager.setMode(params.sessionId, params.modeId) - } - - async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise { - const session = this.sessionManager.get(params.sessionId) - const providers = await this.sdk.config - .providers({ directory: session.cwd }, { throwOnError: true }) - .then((x) => x.data!.providers) - const entries = sortProvidersByName(providers) - - if (params.configId === "model") { - if (typeof params.value !== "string") throw RequestError.invalidParams("model value must be a string") - const selection = parseModelSelection(params.value, providers) - this.sessionManager.setModel(session.id, selection.model) - this.sessionManager.setVariant(session.id, selection.variant) - } else if (params.configId === "mode") { - if (typeof params.value !== "string") throw RequestError.invalidParams("mode value must be a string") - const availableModes = await this.loadAvailableModes(session.cwd) - if (!availableModes.some((mode) => mode.id === params.value)) { - throw RequestError.invalidParams(JSON.stringify({ error: `Mode not found: ${params.value}` })) + const availableModels = buildAvailableModels(entries, { includeVariants: true }) + const modeState = await this.resolveModeState(directory, sessionId) + const currentModeId = modeState.currentModeId + const modes = currentModeId + ? { + availableModes: modeState.availableModes, + currentModeId, } - this.sessionManager.setMode(session.id, params.value) - } else { - throw RequestError.invalidParams(JSON.stringify({ error: `Unknown config option: ${params.configId}` })) - } + : undefined - const updatedSession = this.sessionManager.get(session.id) - const model = updatedSession.model ?? (await defaultModel(this.config, session.cwd)) - const availableVariants = modelVariantsFromProviders(entries, model) - const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, true) - const availableModels = buildAvailableModels(entries, { includeVariants: true }) - const modeState = await this.resolveModeState(session.cwd, session.id) - const modes = modeState.currentModeId - ? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId } - : undefined + const commands = await this.config.sdk.command + .list( + { + directory, + }, + { throwOnError: true }, + ) + .then((resp) => resp.data!) - return { - configOptions: buildConfigOptions({ currentModelId, availableModels, modes }), - } - } - - async prompt(params: PromptRequest) { - const sessionID = params.sessionId - const session = this.sessionManager.get(sessionID) - const directory = session.cwd - - const current = session.model - const model = current ?? (await defaultModel(this.config, directory)) - if (!current) { - this.sessionManager.setModel(session.id, model) - } - const agent = - session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))) - - const parts: Array< - | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean } - | { type: "file"; url: string; filename: string; mime: string } - > = [] - for (const part of params.prompt) { - switch (part.type) { - case "text": - const audience = part.annotations?.audience - const forAssistant = audience?.length === 1 && audience[0] === "assistant" - const forUser = audience?.length === 1 && audience[0] === "user" - parts.push({ - type: "text" as const, - text: part.text, - ...(forAssistant && { synthetic: true }), - ...(forUser && { ignored: true }), - }) - break - case "image": { - const parsed = parseUri(part.uri ?? "") - const filename = parsed.type === "file" ? parsed.filename : "image" - if (part.data) { - parts.push({ - type: "file", - url: `data:${part.mimeType};base64,${part.data}`, - filename, - mime: part.mimeType, - }) - } else if (part.uri && part.uri.startsWith("http:")) { - parts.push({ - type: "file", - url: part.uri, - filename, - mime: part.mimeType, - }) - } - break - } - - case "resource_link": - const parsed = parseUri(part.uri) - // Use the name from resource_link if available - if (part.name && parsed.type === "file") { - parsed.filename = part.name - } - parts.push(parsed) - - break - - case "resource": { - const resource = part.resource - if ("text" in resource && resource.text) { - parts.push({ - type: "text", - text: resource.text, - }) - } else if ("blob" in resource && resource.blob && resource.mimeType) { - // Binary resource (PDFs, etc.): store as file part with data URL - const parsed = parseUri(resource.uri ?? "") - const filename = parsed.type === "file" ? parsed.filename : "file" - parts.push({ - type: "file", - url: `data:${resource.mimeType};base64,${resource.blob}`, - filename, - mime: resource.mimeType, - }) - } - break - } - - default: - break - } - } - - log.info("parts", { parts }) - - const cmd = (() => { - const text = parts - .filter((p): p is { type: "text"; text: string } => p.type === "text") - .map((p) => p.text) - .join("") - .trim() - - if (!text.startsWith("/")) return - - const [name, ...rest] = text.slice(1).split(/\s+/) - return { name, args: rest.join(" ").trim() } - })() - - const buildUsage = (msg: AssistantMessage): Usage => ({ - totalTokens: - msg.tokens.input + - msg.tokens.output + - msg.tokens.reasoning + - (msg.tokens.cache?.read ?? 0) + - (msg.tokens.cache?.write ?? 0), - inputTokens: msg.tokens.input, - outputTokens: msg.tokens.output, - thoughtTokens: msg.tokens.reasoning || undefined, - cachedReadTokens: msg.tokens.cache?.read || undefined, - cachedWriteTokens: msg.tokens.cache?.write || undefined, + const availableCommands = commands.map((command) => ({ + name: command.name, + description: command.description ?? "", + })) + const names = new Set(availableCommands.map((c) => c.name)) + if (!names.has("compact")) + availableCommands.push({ + name: "compact", + description: "compact the session", }) - if (!cmd) { - const response = await this.sdk.session.prompt({ - sessionID, - model: { - providerID: model.providerID, - modelID: model.modelID, - }, - variant: this.sessionManager.getVariant(sessionID), - parts, - agent, - directory, - }) - const msg = response.data?.info - - await sendUsageUpdate(this.connection, this.sdk, sessionID, directory) - - return { - stopReason: "end_turn" as const, - usage: msg ? buildUsage(msg) : undefined, - _meta: {}, + const mcpServers: Record = {} + for (const server of params.mcpServers) { + if ("type" in server) { + mcpServers[server.name] = { + url: server.url, + headers: server.headers.reduce>((acc, { name, value }) => { + acc[name] = value + return acc + }, {}), + type: "remote", + } + } else { + mcpServers[server.name] = { + type: "local", + command: [server.command, ...server.args], + environment: server.env.reduce>((acc, { name, value }) => { + acc[name] = value + return acc + }, {}), } } + } - const command = await this.config.sdk.command - .list({ directory }, { throwOnError: true }) - .then((x) => x.data!.find((c) => c.name === cmd.name)) - if (command) { - const response = await this.sdk.session.command({ - sessionID, - command: command.name, - arguments: cmd.args, - model: model.providerID + "/" + model.modelID, - agent, - directory, - }) - const msg = response.data?.info - - await sendUsageUpdate(this.connection, this.sdk, sessionID, directory) - - return { - stopReason: "end_turn" as const, - usage: msg ? buildUsage(msg) : undefined, - _meta: {}, - } - } - - switch (cmd.name) { - case "compact": - await this.config.sdk.session.summarize( + await Promise.all( + Object.entries(mcpServers).map(async ([key, mcp]) => { + await this.sdk.mcp + .add( { - sessionID, directory, - providerID: model.providerID, - modelID: model.modelID, + name: key, + config: mcp, }, { throwOnError: true }, ) + .catch((error) => { + log.error("failed to add mcp server", { name: key, error }) + }) + }), + ) + + setTimeout(() => { + void this.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "available_commands_update", + availableCommands, + }, + }) + }, 0) + + return { + sessionId, + models: { + currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true), + availableModels, + }, + modes, + configOptions: buildConfigOptions({ + currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true), + availableModels, + modes, + }), + _meta: buildVariantMeta({ + model, + variant: this.sessionManager.getVariant(sessionId), + availableVariants, + }), + } + } + + async unstable_setSessionModel(params: SetSessionModelRequest) { + const session = this.sessionManager.get(params.sessionId) + const providers = await this.sdk.config + .providers({ directory: session.cwd }, { throwOnError: true }) + .then((x) => x.data!.providers) + + const selection = parseModelSelection(params.modelId, providers) + this.sessionManager.setModel(session.id, selection.model) + this.sessionManager.setVariant(session.id, selection.variant) + + const entries = sortProvidersByName(providers) + const availableVariants = modelVariantsFromProviders(entries, selection.model) + + return { + _meta: buildVariantMeta({ + model: selection.model, + variant: selection.variant, + availableVariants, + }), + } + } + + async setSessionMode(params: SetSessionModeRequest): Promise { + const session = this.sessionManager.get(params.sessionId) + const availableModes = await this.loadAvailableModes(session.cwd) + if (!availableModes.some((mode) => mode.id === params.modeId)) { + throw new Error(`Agent not found: ${params.modeId}`) + } + this.sessionManager.setMode(params.sessionId, params.modeId) + } + + async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise { + const session = this.sessionManager.get(params.sessionId) + const providers = await this.sdk.config + .providers({ directory: session.cwd }, { throwOnError: true }) + .then((x) => x.data!.providers) + const entries = sortProvidersByName(providers) + + if (params.configId === "model") { + if (typeof params.value !== "string") throw RequestError.invalidParams("model value must be a string") + const selection = parseModelSelection(params.value, providers) + this.sessionManager.setModel(session.id, selection.model) + this.sessionManager.setVariant(session.id, selection.variant) + } else if (params.configId === "mode") { + if (typeof params.value !== "string") throw RequestError.invalidParams("mode value must be a string") + const availableModes = await this.loadAvailableModes(session.cwd) + if (!availableModes.some((mode) => mode.id === params.value)) { + throw RequestError.invalidParams(JSON.stringify({ error: `Mode not found: ${params.value}` })) + } + this.sessionManager.setMode(session.id, params.value) + } else { + throw RequestError.invalidParams(JSON.stringify({ error: `Unknown config option: ${params.configId}` })) + } + + const updatedSession = this.sessionManager.get(session.id) + const model = updatedSession.model ?? (await defaultModel(this.config, session.cwd)) + const availableVariants = modelVariantsFromProviders(entries, model) + const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, true) + const availableModels = buildAvailableModels(entries, { includeVariants: true }) + const modeState = await this.resolveModeState(session.cwd, session.id) + const modes = modeState.currentModeId + ? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId } + : undefined + + return { + configOptions: buildConfigOptions({ currentModelId, availableModels, modes }), + } + } + + async prompt(params: PromptRequest) { + const sessionID = params.sessionId + const session = this.sessionManager.get(sessionID) + const directory = session.cwd + + const current = session.model + const model = current ?? (await defaultModel(this.config, directory)) + if (!current) { + this.sessionManager.setModel(session.id, model) + } + const agent = session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))) + + const parts: Array< + | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean } + | { type: "file"; url: string; filename: string; mime: string } + > = [] + for (const part of params.prompt) { + switch (part.type) { + case "text": + const audience = part.annotations?.audience + const forAssistant = audience?.length === 1 && audience[0] === "assistant" + const forUser = audience?.length === 1 && audience[0] === "user" + parts.push({ + type: "text" as const, + text: part.text, + ...(forAssistant && { synthetic: true }), + ...(forUser && { ignored: true }), + }) + break + case "image": { + const parsed = parseUri(part.uri ?? "") + const filename = parsed.type === "file" ? parsed.filename : "image" + if (part.data) { + parts.push({ + type: "file", + url: `data:${part.mimeType};base64,${part.data}`, + filename, + mime: part.mimeType, + }) + } else if (part.uri && part.uri.startsWith("http:")) { + parts.push({ + type: "file", + url: part.uri, + filename, + mime: part.mimeType, + }) + } + break + } + + case "resource_link": + const parsed = parseUri(part.uri) + // Use the name from resource_link if available + if (part.name && parsed.type === "file") { + parsed.filename = part.name + } + parts.push(parsed) + + break + + case "resource": { + const resource = part.resource + if ("text" in resource && resource.text) { + parts.push({ + type: "text", + text: resource.text, + }) + } else if ("blob" in resource && resource.blob && resource.mimeType) { + // Binary resource (PDFs, etc.): store as file part with data URL + const parsed = parseUri(resource.uri ?? "") + const filename = parsed.type === "file" ? parsed.filename : "file" + parts.push({ + type: "file", + url: `data:${resource.mimeType};base64,${resource.blob}`, + filename, + mime: resource.mimeType, + }) + } + break + } + + default: break } + } + + log.info("parts", { parts }) + + const cmd = (() => { + const text = parts + .filter((p): p is { type: "text"; text: string } => p.type === "text") + .map((p) => p.text) + .join("") + .trim() + + if (!text.startsWith("/")) return + + const [name, ...rest] = text.slice(1).split(/\s+/) + return { name, args: rest.join(" ").trim() } + })() + + const buildUsage = (msg: AssistantMessage): Usage => ({ + totalTokens: + msg.tokens.input + + msg.tokens.output + + msg.tokens.reasoning + + (msg.tokens.cache?.read ?? 0) + + (msg.tokens.cache?.write ?? 0), + inputTokens: msg.tokens.input, + outputTokens: msg.tokens.output, + thoughtTokens: msg.tokens.reasoning || undefined, + cachedReadTokens: msg.tokens.cache?.read || undefined, + cachedWriteTokens: msg.tokens.cache?.write || undefined, + }) + + if (!cmd) { + const response = await this.sdk.session.prompt({ + sessionID, + model: { + providerID: model.providerID, + modelID: model.modelID, + }, + variant: this.sessionManager.getVariant(sessionID), + parts, + agent, + directory, + }) + const msg = response.data?.info await sendUsageUpdate(this.connection, this.sdk, sessionID, directory) return { stopReason: "end_turn" as const, + usage: msg ? buildUsage(msg) : undefined, _meta: {}, } } - async cancel(params: CancelNotification) { - const session = this.sessionManager.get(params.sessionId) - await this.config.sdk.session.abort( - { - sessionID: params.sessionId, - directory: session.cwd, - }, - { throwOnError: true }, - ) - } - } - - function toToolKind(toolName: string): ToolKind { - const tool = toolName.toLocaleLowerCase() - switch (tool) { - case "bash": - return "execute" - case "webfetch": - return "fetch" - - case "edit": - case "patch": - case "write": - return "edit" - - case "grep": - case "glob": - case "context7_resolve_library_id": - case "context7_get_library_docs": - return "search" - - case "read": - return "read" - - default: - return "other" - } - } - - function toLocations(toolName: string, input: Record): { path: string }[] { - const tool = toolName.toLocaleLowerCase() - switch (tool) { - case "read": - case "edit": - case "write": - return input["filePath"] ? [{ path: input["filePath"] }] : [] - case "glob": - case "grep": - return input["path"] ? [{ path: input["path"] }] : [] - case "bash": - return [] - default: - return [] - } - } - - async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> { - const sdk = config.sdk - const configured = config.defaultModel - if (configured) return configured - - const directory = cwd ?? process.cwd() - - const specified = await sdk.config - .get({ directory }, { throwOnError: true }) - .then((resp) => { - const cfg = resp.data - if (!cfg || !cfg.model) return undefined - return Provider.parseModel(cfg.model) - }) - .catch((error) => { - log.error("failed to load user config for default model", { error }) - return undefined + const command = await this.config.sdk.command + .list({ directory }, { throwOnError: true }) + .then((x) => x.data!.find((c) => c.name === cmd.name)) + if (command) { + const response = await this.sdk.session.command({ + sessionID, + command: command.name, + arguments: cmd.args, + model: model.providerID + "/" + model.modelID, + agent, + directory, }) + const msg = response.data?.info - const providers = await sdk.config - .providers({ directory }, { throwOnError: true }) - .then((x) => x.data?.providers ?? []) - .catch((error) => { - log.error("failed to list providers for default model", { error }) - return [] - }) + await sendUsageUpdate(this.connection, this.sdk, sessionID, directory) - if (specified && providers.length) { - const provider = providers.find((p) => p.id === specified.providerID) - if (provider && provider.models[specified.modelID]) return specified - } - - if (specified && !providers.length) return specified - - // kilocode_change start - const kiloProvider = providers.find((p) => p.id === "kilo") - if (kiloProvider) { - const [best] = Provider.sort(Object.values(kiloProvider.models)) - if (best) { - return { - providerID: ProviderID.make(best.providerID), - modelID: ModelID.make(best.id), - } + return { + stopReason: "end_turn" as const, + usage: msg ? buildUsage(msg) : undefined, + _meta: {}, } } - // kilocode_change end - const models = providers.flatMap((p) => Object.values(p.models)) - const [best] = Provider.sort(models) + switch (cmd.name) { + case "compact": + await this.config.sdk.session.summarize( + { + sessionID, + directory, + providerID: model.providerID, + modelID: model.modelID, + }, + { throwOnError: true }, + ) + break + } + + await sendUsageUpdate(this.connection, this.sdk, sessionID, directory) + + return { + stopReason: "end_turn" as const, + _meta: {}, + } + } + + async cancel(params: CancelNotification) { + const session = this.sessionManager.get(params.sessionId) + await this.config.sdk.session.abort( + { + sessionID: params.sessionId, + directory: session.cwd, + }, + { throwOnError: true }, + ) + } +} + +function toToolKind(toolName: string): ToolKind { + const tool = toolName.toLocaleLowerCase() + switch (tool) { + case "bash": + return "execute" + case "webfetch": + return "fetch" + + case "edit": + case "patch": + case "write": + return "edit" + + case "grep": + case "glob": + case "context7_resolve_library_id": + case "context7_get_library_docs": + return "search" + + case "read": + return "read" + + default: + return "other" + } +} + +function toLocations(toolName: string, input: Record): { path: string }[] { + const tool = toolName.toLocaleLowerCase() + switch (tool) { + case "read": + case "edit": + case "write": + return input["filePath"] ? [{ path: input["filePath"] }] : [] + case "glob": + case "grep": + return input["path"] ? [{ path: input["path"] }] : [] + case "bash": + return [] + default: + return [] + } +} + +async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> { + const sdk = config.sdk + const configured = config.defaultModel + if (configured) return configured + + const directory = cwd ?? process.cwd() + + const specified = await sdk.config + .get({ directory }, { throwOnError: true }) + .then((resp) => { + const cfg = resp.data + if (!cfg || !cfg.model) return undefined + return Provider.parseModel(cfg.model) + }) + .catch((error) => { + log.error("failed to load user config for default model", { error }) + return undefined + }) + + const providers = await sdk.config + .providers({ directory }, { throwOnError: true }) + .then((x) => x.data?.providers ?? []) + .catch((error) => { + log.error("failed to list providers for default model", { error }) + return [] + }) + + if (specified && providers.length) { + const provider = providers.find((p) => p.id === specified.providerID) + if (provider && provider.models[specified.modelID]) return specified + } + + if (specified && !providers.length) return specified + + // kilocode_change start + const kiloProvider = providers.find((p) => p.id === "kilo") + if (kiloProvider) { + const [best] = Provider.sort(Object.values(kiloProvider.models)) if (best) { return { providerID: ProviderID.make(best.providerID), modelID: ModelID.make(best.id), } } + } + // kilocode_change end - if (specified) return specified - - // kilocode_change start - // Only fall back to the Kilo provider if it was present in the available - // providers list. When teams configure enabled_providers to use only their - // own models, this prevents silently routing requests to an external API. - // Note: LiteLLM / custom provider users won't reach here — the function - // returns earlier via `specified` (config.model) or the sorted providers list. - if (providers.some((p) => p.id === "kilo")) { - const freeModel = await fetchDefaultModel() - return { providerID: ProviderID.kilo, modelID: ModelID.make(freeModel) } + const models = providers.flatMap((p) => Object.values(p.models)) + const [best] = Provider.sort(models) + if (best) { + return { + providerID: ProviderID.make(best.providerID), + modelID: ModelID.make(best.id), } - throw new Error("no model available: no providers are configured and no default model is set") - // kilocode_change end } - function parseUri( - uri: string, - ): { type: "file"; url: string; filename: string; mime: string } | { type: "text"; text: string } { - try { - if (uri.startsWith("file://")) { - const path = uri.slice(7) + if (specified) return specified + + // kilocode_change start + // Only fall back to the Kilo provider if it was present in the available + // providers list. When teams configure enabled_providers to use only their + // own models, this prevents silently routing requests to an external API. + // Note: LiteLLM / custom provider users won't reach here — the function + // returns earlier via `specified` (config.model) or the sorted providers list. + if (providers.some((p) => p.id === "kilo")) { + const freeModel = await fetchDefaultModel() + return { providerID: ProviderID.kilo, modelID: ModelID.make(freeModel) } + } + throw new Error("no model available: no providers are configured and no default model is set") + // kilocode_change end +} + +function parseUri( + uri: string, +): { type: "file"; url: string; filename: string; mime: string } | { type: "text"; text: string } { + try { + if (uri.startsWith("file://")) { + const path = uri.slice(7) + const name = path.split("/").pop() || path + return { + type: "file", + url: uri, + filename: name, + mime: "text/plain", + } + } + if (uri.startsWith("zed://")) { + const url = new URL(uri) + const path = url.searchParams.get("path") + if (path) { const name = path.split("/").pop() || path return { type: "file", - url: uri, + url: pathToFileURL(path).href, filename: name, mime: "text/plain", } } - if (uri.startsWith("zed://")) { - const url = new URL(uri) - const path = url.searchParams.get("path") - if (path) { - const name = path.split("/").pop() || path - return { - type: "file", - url: pathToFileURL(path).href, - filename: name, - mime: "text/plain", - } - } - } - return { - type: "text", - text: uri, - } - } catch { - return { - type: "text", - text: uri, - } } - } - - function getNewContent(fileOriginal: string, unifiedDiff: string): string | undefined { - const result = applyPatch(fileOriginal, unifiedDiff) - if (result === false) { - log.error("Failed to apply unified diff (context mismatch)") - return undefined - } - return result - } - - function sortProvidersByName(providers: T[]): T[] { - return [...providers].sort((a, b) => { - const nameA = a.name.toLowerCase() - const nameB = b.name.toLowerCase() - if (nameA < nameB) return -1 - if (nameA > nameB) return 1 - return 0 - }) - } - - function modelVariantsFromProviders( - providers: Array<{ id: string; models: Record }> }>, - model: { providerID: ProviderID; modelID: ModelID }, - ): string[] { - const provider = providers.find((entry) => entry.id === model.providerID) - if (!provider) return [] - const modelInfo = provider.models[model.modelID] - if (!modelInfo?.variants) return [] - return Object.keys(modelInfo.variants) - } - - function buildAvailableModels( - providers: Array<{ id: string; name: string; models: Record }>, - options: { includeVariants?: boolean } = {}, - ): ModelOption[] { - const includeVariants = options.includeVariants ?? false - return providers.flatMap((provider) => { - const unsorted: Array<{ id: string; name: string; variants?: Record }> = Object.values( - provider.models, - ) - const models = Provider.sort(unsorted) - return models.flatMap((model) => { - const base: ModelOption = { - modelId: `${provider.id}/${model.id}`, - name: `${provider.name}/${model.name}`, - } - if (!includeVariants || !model.variants) return [base] - const variants = Object.keys(model.variants).filter((variant) => variant !== DEFAULT_VARIANT_VALUE) - const variantOptions = variants.map((variant) => ({ - modelId: `${provider.id}/${model.id}/${variant}`, - name: `${provider.name}/${model.name} (${variant})`, - })) - return [base, ...variantOptions] - }) - }) - } - - function formatModelIdWithVariant( - model: { providerID: ProviderID; modelID: ModelID }, - variant: string | undefined, - availableVariants: string[], - includeVariant: boolean, - ) { - const base = `${model.providerID}/${model.modelID}` - if (!includeVariant || !variant || !availableVariants.includes(variant)) return base - return `${base}/${variant}` - } - - function buildVariantMeta(input: { - model: { providerID: ProviderID; modelID: ModelID } - variant?: string - availableVariants: string[] - }) { return { - opencode: { - modelId: `${input.model.providerID}/${input.model.modelID}`, - variant: input.variant ?? null, - availableVariants: input.availableVariants, - }, + type: "text", + text: uri, + } + } catch { + return { + type: "text", + text: uri, } } +} - function parseModelSelection( - modelId: string, - providers: Array<{ id: string; models: Record }> }>, - ): { model: { providerID: ProviderID; modelID: ModelID }; variant?: string } { - const parsed = Provider.parseModel(modelId) - const provider = providers.find((p) => p.id === parsed.providerID) - if (!provider) { - return { model: parsed, variant: undefined } - } +function getNewContent(fileOriginal: string, unifiedDiff: string): string | undefined { + const result = applyPatch(fileOriginal, unifiedDiff) + if (result === false) { + log.error("Failed to apply unified diff (context mismatch)") + return undefined + } + return result +} - // Check if modelID exists directly - if (provider.models[parsed.modelID]) { - return { model: parsed, variant: undefined } - } +function sortProvidersByName(providers: T[]): T[] { + return [...providers].sort((a, b) => { + const nameA = a.name.toLowerCase() + const nameB = b.name.toLowerCase() + if (nameA < nameB) return -1 + if (nameA > nameB) return 1 + return 0 + }) +} - // Try to extract variant from end of modelID (e.g., "claude-sonnet-4/high" -> model: "claude-sonnet-4", variant: "high") - const segments = parsed.modelID.split("/") - if (segments.length > 1) { - const candidateVariant = segments[segments.length - 1] - const baseModelId = segments.slice(0, -1).join("/") - const baseModelInfo = provider.models[baseModelId] - if (baseModelInfo?.variants && candidateVariant in baseModelInfo.variants) { - return { - model: { providerID: parsed.providerID, modelID: ModelID.make(baseModelId) }, - variant: candidateVariant, - } +function modelVariantsFromProviders( + providers: Array<{ id: string; models: Record }> }>, + model: { providerID: ProviderID; modelID: ModelID }, +): string[] { + const provider = providers.find((entry) => entry.id === model.providerID) + if (!provider) return [] + const modelInfo = provider.models[model.modelID] + if (!modelInfo?.variants) return [] + return Object.keys(modelInfo.variants) +} + +function buildAvailableModels( + providers: Array<{ id: string; name: string; models: Record }>, + options: { includeVariants?: boolean } = {}, +): ModelOption[] { + const includeVariants = options.includeVariants ?? false + return providers.flatMap((provider) => { + const unsorted: Array<{ id: string; name: string; variants?: Record }> = Object.values(provider.models) + const models = Provider.sort(unsorted) + return models.flatMap((model) => { + const base: ModelOption = { + modelId: `${provider.id}/${model.id}`, + name: `${provider.name}/${model.name}`, } - } + if (!includeVariants || !model.variants) return [base] + const variants = Object.keys(model.variants).filter((variant) => variant !== DEFAULT_VARIANT_VALUE) + const variantOptions = variants.map((variant) => ({ + modelId: `${provider.id}/${model.id}/${variant}`, + name: `${provider.name}/${model.name} (${variant})`, + })) + return [base, ...variantOptions] + }) + }) +} +function formatModelIdWithVariant( + model: { providerID: ProviderID; modelID: ModelID }, + variant: string | undefined, + availableVariants: string[], + includeVariant: boolean, +) { + const base = `${model.providerID}/${model.modelID}` + if (!includeVariant || !variant || !availableVariants.includes(variant)) return base + return `${base}/${variant}` +} + +function buildVariantMeta(input: { + model: { providerID: ProviderID; modelID: ModelID } + variant?: string + availableVariants: string[] +}) { + return { + opencode: { + modelId: `${input.model.providerID}/${input.model.modelID}`, + variant: input.variant ?? null, + availableVariants: input.availableVariants, + }, + } +} + +function parseModelSelection( + modelId: string, + providers: Array<{ id: string; models: Record }> }>, +): { model: { providerID: ProviderID; modelID: ModelID }; variant?: string } { + const parsed = Provider.parseModel(modelId) + const provider = providers.find((p) => p.id === parsed.providerID) + if (!provider) { return { model: parsed, variant: undefined } } - function buildConfigOptions(input: { - currentModelId: string - availableModels: ModelOption[] - modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined - }): SessionConfigOption[] { - const options: SessionConfigOption[] = [ - { - id: "model", - name: "Model", - category: "model", - type: "select", - currentValue: input.currentModelId, - options: input.availableModels.map((m) => ({ value: m.modelId, name: m.name })), - }, - ] - if (input.modes) { - options.push({ - id: "mode", - name: "Session Mode", - category: "mode", - type: "select", - currentValue: input.modes.currentModeId, - options: input.modes.availableModes.map((m) => ({ - value: m.id, - name: m.name, - ...(m.description ? { description: m.description } : {}), - })), - }) - } - return options + // Check if modelID exists directly + if (provider.models[parsed.modelID]) { + return { model: parsed, variant: undefined } } + + // Try to extract variant from end of modelID (e.g., "claude-sonnet-4/high" -> model: "claude-sonnet-4", variant: "high") + const segments = parsed.modelID.split("/") + if (segments.length > 1) { + const candidateVariant = segments[segments.length - 1] + const baseModelId = segments.slice(0, -1).join("/") + const baseModelInfo = provider.models[baseModelId] + if (baseModelInfo?.variants && candidateVariant in baseModelInfo.variants) { + return { + model: { providerID: parsed.providerID, modelID: ModelID.make(baseModelId) }, + variant: candidateVariant, + } + } + } + + return { model: parsed, variant: undefined } } + +function buildConfigOptions(input: { + currentModelId: string + availableModels: ModelOption[] + modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined +}): SessionConfigOption[] { + const options: SessionConfigOption[] = [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: input.currentModelId, + options: input.availableModels.map((m) => ({ value: m.modelId, name: m.name })), + }, + ] + if (input.modes) { + options.push({ + id: "mode", + name: "Session Mode", + category: "mode", + type: "select", + currentValue: input.modes.currentModeId, + options: input.modes.availableModes.map((m) => ({ + value: m.id, + name: m.name, + ...(m.description ? { description: m.description } : {}), + })), + }) + } + return options +} + +export * as ACP from "./agent" diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index d906b1853e..d6da9bf6c4 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -25,426 +25,425 @@ import { Effect, Context, Layer } from "effect" import { InstanceState } from "@/effect" import * as KiloAgent from "@/kilocode/agent" // kilocode_change -export namespace Agent { - export const Info = z - .object({ - name: z.string(), - displayName: z.string().optional(), // kilocode_change - human-readable name for org modes - description: z.string().optional(), - deprecated: z.boolean().optional(), // kilocode_change - mode: z.enum(["subagent", "primary", "all"]), - native: z.boolean().optional(), - hidden: z.boolean().optional(), - topP: z.number().optional(), - temperature: z.number().optional(), - color: z.string().optional(), - permission: Permission.Ruleset.zod, - model: z - .object({ - modelID: ModelID.zod, - providerID: ProviderID.zod, - }) - .optional(), - variant: z.string().optional(), - prompt: z.string().optional(), - options: z.record(z.string(), z.any()), - steps: z.number().int().positive().optional(), - }) - .meta({ - ref: "Agent", - }) - export type Info = z.infer - - export interface Interface { - readonly get: (agent: string) => Effect.Effect - readonly list: () => Effect.Effect - readonly defaultAgent: () => Effect.Effect - readonly generate: (input: { - description: string - model?: { providerID: ProviderID; modelID: ModelID } - }) => Effect.Effect<{ - identifier: string - whenToUse: string - systemPrompt: string - }> - } - - type State = Omit - - export class Service extends Context.Service()("@opencode/Agent") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const config = yield* Config.Service - const auth = yield* Auth.Service - const plugin = yield* Plugin.Service - const skill = yield* Skill.Service - const provider = yield* Provider.Service - - const state = yield* InstanceState.make( - Effect.fn("Agent.state")(function* (_ctx) { - const cfg = yield* config.get() - const skillDirs = yield* skill.dirs() - // kilocode_change start - include global config dirs so agents can read them without prompting - const whitelistedDirs = [ - Truncate.GLOB, - ...skillDirs.map((dir) => path.join(dir, "*")), - path.join(Global.Path.config, "*"), - ...KilocodePaths.globalDirs().map((dir) => path.join(dir, "*")), - ] - // kilocode_change end - - const baseDefaults = Permission.fromConfig({ - // kilocode_change: renamed from defaults - "*": "allow", - doom_loop: "ask", - external_directory: { - "*": "ask", - ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), - }, - suggest: "deny", // kilocode_change - question: "deny", - plan_enter: "deny", - plan_exit: "deny", - // mirrors github.com/github/gitignore Node.gitignore pattern for .env files - read: { - "*": "allow", - "*.env": "ask", - "*.env.*": "ask", - "*.env.example": "allow", - }, - }) - - // kilocode_change start - patch defaults with bash allowlist and recall permission - const kilo = KiloAgent.prepare(cfg) - const defaults = Permission.merge(baseDefaults, kilo.defaultsPatch) - // kilocode_change end - - const user = Permission.fromConfig(cfg.permission ?? {}) - - const agents: Record = { - build: { - name: "build", - description: "The default agent. Executes tools based on configured permissions.", - options: {}, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - question: "allow", - suggest: "allow", // kilocode_change - plan_enter: "allow", - }), - user, - ), - mode: "primary", - native: true, - }, - plan: { - name: "plan", - description: "Plan mode. Disallows all edit tools.", - options: {}, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - question: "allow", - plan_exit: "allow", - external_directory: { - [path.join(Global.Path.data, "plans", "*")]: "allow", - }, - edit: { - "*": "deny", - [path.join(".opencode", "plans", "*.md")]: "allow", - [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: - "allow", - }, - }), - user, - ), - mode: "primary", - native: true, - }, - general: { - name: "general", - description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - todowrite: "deny", - }), - user, - ), - options: {}, - mode: "subagent", - native: true, - }, - explore: { - name: "explore", - permission: Permission.merge( - defaults, - Permission.fromConfig({ - "*": "deny", - grep: "allow", - glob: "allow", - list: "allow", - bash: "allow", - webfetch: "allow", - websearch: "allow", - codesearch: "allow", - read: "allow", - external_directory: { - "*": "ask", - ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), - }, - }), - user, - ), - description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`, - prompt: PROMPT_EXPLORE, - options: {}, - mode: "subagent", - native: true, - }, - compaction: { - name: "compaction", - mode: "primary", - native: true, - hidden: true, - prompt: PROMPT_COMPACTION, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - "*": "deny", - }), - user, - ), - options: {}, - }, - title: { - name: "title", - mode: "primary", - options: {}, - native: true, - hidden: true, - temperature: 0.5, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - "*": "deny", - }), - user, - ), - prompt: PROMPT_TITLE, - }, - summary: { - name: "summary", - mode: "primary", - options: {}, - native: true, - hidden: true, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - "*": "deny", - }), - user, - ), - prompt: PROMPT_SUMMARY, - }, - } - - // kilocode_change start - rename build→code, add debug/orchestrator/ask, patch plan/explore - KiloAgent.patchAgents(agents, defaults, user, cfg, kilo) - // kilocode_change end - - // kilocode_change start - preprocess config to remap "build" key → "code" - const agentConfigs = KiloAgent.preprocessConfig(cfg.agent ?? {}) - for (const [key, value] of Object.entries(agentConfigs)) { - // kilocode_change end - if (value.disable) { - delete agents[key] - continue - } - let item = agents[key] - if (!item) - item = agents[key] = { - name: key, - mode: "all", - permission: Permission.merge(defaults, user), - options: {}, - native: false, - } - if (value.model) item.model = Provider.parseModel(value.model) - item.variant = value.variant ?? item.variant - item.prompt = value.prompt ?? item.prompt - item.description = value.description ?? item.description - item.temperature = value.temperature ?? item.temperature - item.topP = value.top_p ?? item.topP - item.mode = value.mode ?? item.mode - item.color = value.color ?? item.color - item.hidden = value.hidden ?? item.hidden - item.name = value.name ?? item.name - item.steps = value.steps ?? item.steps - item.options = mergeDeep(item.options, value.options ?? {}) - item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) - KiloAgent.processConfigItem(item) // kilocode_change - populate displayName from options - } - - // Ensure Truncate.GLOB is allowed unless explicitly configured - for (const name in agents) { - const agent = agents[name] - const explicit = agent.permission.some((r) => { - if (r.permission !== "external_directory") return false - if (r.action !== "deny") return false - return r.pattern === Truncate.GLOB - }) - if (explicit) continue - - agents[name].permission = Permission.merge( - agents[name].permission, - Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }), - ) - } - - const get = Effect.fnUntraced(function* (agent: string) { - return agents[KiloAgent.resolveKey(agent)] // kilocode_change - treat "build" as "code" - }) - - const list = Effect.fnUntraced(function* () { - const cfg = yield* config.get() - return pipe( - agents, - values(), - sortBy( - [(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "code"), "desc"], // kilocode_change - renamed from "build" to "code" - [(x) => x.name, "asc"], - ), - ) - }) - - const defaultAgent = Effect.fnUntraced(function* () { - const c = yield* config.get() - if (c.default_agent) { - const effective = KiloAgent.resolveKey(c.default_agent) // kilocode_change - treat "build" as "code" - const agent = agents[effective] // kilocode_change - if (!agent) throw new Error(`default agent "${c.default_agent}" not found`) - if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`) - if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`) - return agent.name - } - // kilocode_change start - prefer "code" as default agent (key order changes after rename from "build") - const code = agents.code - if (code && code.mode !== "subagent" && code.hidden !== true) return code.name - // kilocode_change end - const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true) - if (!visible) throw new Error("no primary visible agent found") - return visible.name - }) - - return { - get, - list, - defaultAgent, - } satisfies State - }), - ) - - return Service.of({ - get: Effect.fn("Agent.get")(function* (agent: string) { - return yield* InstanceState.useEffect(state, (s) => s.get(agent)) - }), - list: Effect.fn("Agent.list")(function* () { - return yield* InstanceState.useEffect(state, (s) => s.list()) - }), - defaultAgent: Effect.fn("Agent.defaultAgent")(function* () { - return yield* InstanceState.useEffect(state, (s) => s.defaultAgent()) - }), - generate: Effect.fn("Agent.generate")(function* (input: { - description: string - model?: { providerID: ProviderID; modelID: ModelID } - }) { - const cfg = yield* config.get() - const model = input.model ?? (yield* provider.defaultModel()) - const resolved = yield* provider.getModel(model.providerID, model.modelID) - const language = yield* provider.getLanguage(resolved) - - const system = [PROMPT_GENERATE] - yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system }) - const existing = yield* InstanceState.useEffect(state, (s) => s.list()) - - // TODO: clean this up so provider specific logic doesnt bleed over - const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie) - const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth" - - const params = { - // kilocode_change start - enable telemetry with custom PostHog tracer - experimental_telemetry: KiloAgent.telemetryOptions(cfg), - // kilocode_change end - temperature: 0.3, - messages: [ - ...(isOpenaiOauth - ? [] - : system.map( - (item): ModelMessage => ({ - role: "system", - content: item, - }), - )), - { - role: "user", - content: `Create an agent configuration based on this request: "${input.description}".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`, - }, - ], - model: language, - schema: z.object({ - identifier: z.string(), - whenToUse: z.string(), - systemPrompt: z.string(), - }), - } satisfies Parameters[0] - - if (isOpenaiOauth) { - return yield* Effect.promise(async () => { - const result = streamObject({ - ...params, - providerOptions: ProviderTransform.providerOptions(resolved, { - instructions: system.join("\n"), - store: false, - }), - onError: () => {}, - }) - for await (const part of result.fullStream) { - if (part.type === "error") throw part.error - } - return result.object - }) - } - - return yield* Effect.promise(() => generateObject(params).then((r) => r.object)) - }), +export const Info = z + .object({ + name: z.string(), + displayName: z.string().optional(), // kilocode_change - human-readable name for org modes + description: z.string().optional(), + deprecated: z.boolean().optional(), // kilocode_change + mode: z.enum(["subagent", "primary", "all"]), + native: z.boolean().optional(), + hidden: z.boolean().optional(), + topP: z.number().optional(), + temperature: z.number().optional(), + color: z.string().optional(), + permission: Permission.Ruleset.zod, + model: z + .object({ + modelID: ModelID.zod, + providerID: ProviderID.zod, }) - }), - ) + .optional(), + variant: z.string().optional(), + prompt: z.string().optional(), + options: z.record(z.string(), z.any()), + steps: z.number().int().positive().optional(), + }) + .meta({ + ref: "Agent", + }) +export type Info = z.infer - export const defaultLayer = layer.pipe( - Layer.provide(Plugin.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Skill.defaultLayer), - ) - - // kilocode_change start - agent removal (delegated to kilocode module) - export const RemoveError = KiloAgent.RemoveError - export async function remove(name: string) { - return KiloAgent.remove(name) - } - // kilocode_change end - - // kilocode_change start - legacy promise helpers for Kilo callsites - const { runPromise } = makeRuntime(Service, defaultLayer) - export const get = (agent: string) => runPromise((svc) => svc.get(agent)) - export const list = () => runPromise((svc) => svc.list()) - export const defaultAgent = () => runPromise((svc) => svc.defaultAgent()) - // kilocode_change end +export interface Interface { + readonly get: (agent: string) => Effect.Effect + readonly list: () => Effect.Effect + readonly defaultAgent: () => Effect.Effect + readonly generate: (input: { + description: string + model?: { providerID: ProviderID; modelID: ModelID } + }) => Effect.Effect<{ + identifier: string + whenToUse: string + systemPrompt: string + }> } + +type State = Omit + +export class Service extends Context.Service()("@opencode/Agent") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const auth = yield* Auth.Service + const plugin = yield* Plugin.Service + const skill = yield* Skill.Service + const provider = yield* Provider.Service + + const state = yield* InstanceState.make( + Effect.fn("Agent.state")(function* (_ctx) { + const cfg = yield* config.get() + const skillDirs = yield* skill.dirs() + // kilocode_change start - include global config dirs so agents can read them without prompting + const whitelistedDirs = [ + Truncate.GLOB, + ...skillDirs.map((dir) => path.join(dir, "*")), + path.join(Global.Path.config, "*"), + ...KilocodePaths.globalDirs().map((dir) => path.join(dir, "*")), + ] + // kilocode_change end + + const baseDefaults = Permission.fromConfig({ + // kilocode_change: renamed from defaults + "*": "allow", + doom_loop: "ask", + external_directory: { + "*": "ask", + ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), + }, + suggest: "deny", // kilocode_change + question: "deny", + plan_enter: "deny", + plan_exit: "deny", + // mirrors github.com/github/gitignore Node.gitignore pattern for .env files + read: { + "*": "allow", + "*.env": "ask", + "*.env.*": "ask", + "*.env.example": "allow", + }, + }) + + // kilocode_change start - patch defaults with bash allowlist and recall permission + const kilo = KiloAgent.prepare(cfg) + const defaults = Permission.merge(baseDefaults, kilo.defaultsPatch) + // kilocode_change end + + const user = Permission.fromConfig(cfg.permission ?? {}) + + const agents: Record = { + build: { + name: "build", + description: "The default agent. Executes tools based on configured permissions.", + options: {}, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + question: "allow", + suggest: "allow", // kilocode_change + plan_enter: "allow", + }), + user, + ), + mode: "primary", + native: true, + }, + plan: { + name: "plan", + description: "Plan mode. Disallows all edit tools.", + options: {}, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + question: "allow", + plan_exit: "allow", + external_directory: { + [path.join(Global.Path.data, "plans", "*")]: "allow", + }, + edit: { + "*": "deny", + [path.join(".opencode", "plans", "*.md")]: "allow", + [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow", + }, + }), + user, + ), + mode: "primary", + native: true, + }, + general: { + name: "general", + description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + todowrite: "deny", + }), + user, + ), + options: {}, + mode: "subagent", + native: true, + }, + explore: { + name: "explore", + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + grep: "allow", + glob: "allow", + list: "allow", + bash: "allow", + webfetch: "allow", + websearch: "allow", + codesearch: "allow", + read: "allow", + external_directory: { + "*": "ask", + ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), + }, + }), + user, + ), + description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`, + prompt: PROMPT_EXPLORE, + options: {}, + mode: "subagent", + native: true, + }, + compaction: { + name: "compaction", + mode: "primary", + native: true, + hidden: true, + prompt: PROMPT_COMPACTION, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + }), + user, + ), + options: {}, + }, + title: { + name: "title", + mode: "primary", + options: {}, + native: true, + hidden: true, + temperature: 0.5, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + }), + user, + ), + prompt: PROMPT_TITLE, + }, + summary: { + name: "summary", + mode: "primary", + options: {}, + native: true, + hidden: true, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + }), + user, + ), + prompt: PROMPT_SUMMARY, + }, + } + + // kilocode_change start - rename build→code, add debug/orchestrator/ask, patch plan/explore + KiloAgent.patchAgents(agents, defaults, user, cfg, kilo) + // kilocode_change end + + // kilocode_change start - preprocess config to remap "build" key → "code" + const agentConfigs = KiloAgent.preprocessConfig(cfg.agent ?? {}) + for (const [key, value] of Object.entries(agentConfigs)) { + // kilocode_change end + if (value.disable) { + delete agents[key] + continue + } + let item = agents[key] + if (!item) + item = agents[key] = { + name: key, + mode: "all", + permission: Permission.merge(defaults, user), + options: {}, + native: false, + } + if (value.model) item.model = Provider.parseModel(value.model) + item.variant = value.variant ?? item.variant + item.prompt = value.prompt ?? item.prompt + item.description = value.description ?? item.description + item.temperature = value.temperature ?? item.temperature + item.topP = value.top_p ?? item.topP + item.mode = value.mode ?? item.mode + item.color = value.color ?? item.color + item.hidden = value.hidden ?? item.hidden + item.name = value.name ?? item.name + item.steps = value.steps ?? item.steps + item.options = mergeDeep(item.options, value.options ?? {}) + item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) + KiloAgent.processConfigItem(item) // kilocode_change - populate displayName from options + } + + // Ensure Truncate.GLOB is allowed unless explicitly configured + for (const name in agents) { + const agent = agents[name] + const explicit = agent.permission.some((r) => { + if (r.permission !== "external_directory") return false + if (r.action !== "deny") return false + return r.pattern === Truncate.GLOB + }) + if (explicit) continue + + agents[name].permission = Permission.merge( + agents[name].permission, + Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }), + ) + } + + const get = Effect.fnUntraced(function* (agent: string) { + return agents[KiloAgent.resolveKey(agent)] // kilocode_change - treat "build" as "code" + }) + + const list = Effect.fnUntraced(function* () { + const cfg = yield* config.get() + return pipe( + agents, + values(), + sortBy( + [(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "code"), "desc"], // kilocode_change - renamed from "build" to "code" + [(x) => x.name, "asc"], + ), + ) + }) + + const defaultAgent = Effect.fnUntraced(function* () { + const c = yield* config.get() + if (c.default_agent) { + const effective = KiloAgent.resolveKey(c.default_agent) // kilocode_change - treat "build" as "code" + const agent = agents[effective] // kilocode_change + if (!agent) throw new Error(`default agent "${c.default_agent}" not found`) + if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`) + if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`) + return agent.name + } + // kilocode_change start - prefer "code" as default agent (key order changes after rename from "build") + const code = agents.code + if (code && code.mode !== "subagent" && code.hidden !== true) return code.name + // kilocode_change end + const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true) + if (!visible) throw new Error("no primary visible agent found") + return visible.name + }) + + return { + get, + list, + defaultAgent, + } satisfies State + }), + ) + + return Service.of({ + get: Effect.fn("Agent.get")(function* (agent: string) { + return yield* InstanceState.useEffect(state, (s) => s.get(agent)) + }), + list: Effect.fn("Agent.list")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.list()) + }), + defaultAgent: Effect.fn("Agent.defaultAgent")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.defaultAgent()) + }), + generate: Effect.fn("Agent.generate")(function* (input: { + description: string + model?: { providerID: ProviderID; modelID: ModelID } + }) { + const cfg = yield* config.get() + const model = input.model ?? (yield* provider.defaultModel()) + const resolved = yield* provider.getModel(model.providerID, model.modelID) + const language = yield* provider.getLanguage(resolved) + + const system = [PROMPT_GENERATE] + yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system }) + const existing = yield* InstanceState.useEffect(state, (s) => s.list()) + + // TODO: clean this up so provider specific logic doesnt bleed over + const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie) + const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth" + + const params = { + // kilocode_change start - enable telemetry with custom PostHog tracer + experimental_telemetry: KiloAgent.telemetryOptions(cfg), + // kilocode_change end + temperature: 0.3, + messages: [ + ...(isOpenaiOauth + ? [] + : system.map( + (item): ModelMessage => ({ + role: "system", + content: item, + }), + )), + { + role: "user", + content: `Create an agent configuration based on this request: "${input.description}".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`, + }, + ], + model: language, + schema: z.object({ + identifier: z.string(), + whenToUse: z.string(), + systemPrompt: z.string(), + }), + } satisfies Parameters[0] + + if (isOpenaiOauth) { + return yield* Effect.promise(async () => { + const result = streamObject({ + ...params, + providerOptions: ProviderTransform.providerOptions(resolved, { + instructions: system.join("\n"), + store: false, + }), + onError: () => {}, + }) + for await (const part of result.fullStream) { + if (part.type === "error") throw part.error + } + return result.object + }) + } + + return yield* Effect.promise(() => generateObject(params).then((r) => r.object)) + }), + }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Plugin.defaultLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(Auth.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(Skill.defaultLayer), +) + +// kilocode_change start - agent removal (delegated to kilocode module) +export const RemoveError = KiloAgent.RemoveError +export async function remove(name: string) { + return KiloAgent.remove(name) +} +// kilocode_change end + +// kilocode_change start - legacy promise helpers for Kilo callsites +const { runPromise } = makeRuntime(Service, defaultLayer) +export const get = (agent: string) => runPromise((svc) => svc.get(agent)) +export const list = () => runPromise((svc) => svc.list()) +export const defaultAgent = () => runPromise((svc) => svc.defaultAgent()) +// kilocode_change end + +export * as Agent from "./agent" diff --git a/packages/opencode/src/auth/auth.ts b/packages/opencode/src/auth/auth.ts deleted file mode 100644 index 330aa1cd92..0000000000 --- a/packages/opencode/src/auth/auth.ts +++ /dev/null @@ -1,112 +0,0 @@ -import path from "path" -import { Effect, Layer, Record, Result, Schema, Context } from "effect" -import { zod } from "@/util/effect-zod" -import { Global } from "../global" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change -import { makeRuntime } from "@/effect/run-service" // kilocode_change - -export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" - -const file = path.join(Global.Path.data, "auth.json") - -const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause }) - -export class Oauth extends Schema.Class("OAuth")({ - type: Schema.Literal("oauth"), - refresh: Schema.String, - access: Schema.String, - expires: Schema.Number, - accountId: Schema.optional(Schema.String), - enterpriseUrl: Schema.optional(Schema.String), -}) {} - -export class Api extends Schema.Class("ApiAuth")({ - type: Schema.Literal("api"), - key: Schema.String, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), -}) {} - -export class WellKnown extends Schema.Class("WellKnownAuth")({ - type: Schema.Literal("wellknown"), - key: Schema.String, - token: Schema.String, -}) {} - -const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" }) -export const Info = Object.assign(_Info, { zod: zod(_Info) }) -export type Info = Schema.Schema.Type - -export class AuthError extends Schema.TaggedErrorClass()("AuthError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect), -}) {} - -export interface Interface { - readonly get: (providerID: string) => Effect.Effect - readonly all: () => Effect.Effect, AuthError> - readonly set: (key: string, info: Info) => Effect.Effect - readonly remove: (key: string) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/Auth") {} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const fsys = yield* AppFileSystem.Service - const decode = Schema.decodeUnknownOption(Info) - - const all = Effect.fn("Auth.all")(function* () { - if (process.env.KILO_AUTH_CONTENT) { - try { - return JSON.parse(process.env.KILO_AUTH_CONTENT) - } catch (err) {} - } - - const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record - return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined)) - }) - - const get = Effect.fn("Auth.get")(function* (providerID: string) { - return (yield* all())[providerID] - }) - - const set = Effect.fn("Auth.set")(function* (key: string, info: Info) { - const norm = key.replace(/\/+$/, "") - const data = yield* all() - if (norm !== key) delete data[key] - delete data[norm + "/"] - yield* fsys - .writeJson(file, { ...data, [norm]: info }, 0o600) - .pipe(Effect.mapError(fail("Failed to write auth data"))) - }) - - const remove = Effect.fn("Auth.remove")(function* (key: string) { - const norm = key.replace(/\/+$/, "") - const data = yield* all() - delete data[key] - delete data[norm] - yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data"))) - - // kilocode_change start - Track logout and reset telemetry identity for Kilo - if (key === "kilo") { - yield* Effect.promise(() => Telemetry.updateIdentity(null)) - } - Telemetry.trackAuthLogout(key) - // kilocode_change end - }) - - return Service.of({ get, all, set, remove }) - }), -) - -export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) - -// kilocode_change start - legacy promise helpers for Kilo callsites -const { runPromise } = makeRuntime(Service, defaultLayer) -export const get = (providerID: string) => runPromise((svc) => svc.get(providerID)) -export const all = () => runPromise((svc) => svc.all()) -export const set = (key: string, info: Info) => runPromise((svc) => svc.set(key, info)) -export const remove = (key: string) => runPromise((svc) => svc.remove(key)) -// kilocode_change end \ No newline at end of file diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 9174745fd8..fb30660d58 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,2 +1,114 @@ -export * as Auth from "./auth" -export { OAUTH_DUMMY_KEY } from "./auth" +import path from "path" +import { Effect, Layer, Record, Result, Schema, Context } from "effect" +import { zod } from "@/util/effect-zod" +import { Global } from "../global" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change +import { makeRuntime } from "@/effect/run-service" // kilocode_change + +export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" + +const file = path.join(Global.Path.data, "auth.json") + +const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause }) + +export class Oauth extends Schema.Class("OAuth")({ + type: Schema.Literal("oauth"), + refresh: Schema.String, + access: Schema.String, + expires: Schema.Number, + accountId: Schema.optional(Schema.String), + enterpriseUrl: Schema.optional(Schema.String), +}) {} + +export class Api extends Schema.Class("ApiAuth")({ + type: Schema.Literal("api"), + key: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) {} + +export class WellKnown extends Schema.Class("WellKnownAuth")({ + type: Schema.Literal("wellknown"), + key: Schema.String, + token: Schema.String, +}) {} + +const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" }) +export const Info = Object.assign(_Info, { zod: zod(_Info) }) +export type Info = Schema.Schema.Type + +export class AuthError extends Schema.TaggedErrorClass()("AuthError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +export interface Interface { + readonly get: (providerID: string) => Effect.Effect + readonly all: () => Effect.Effect, AuthError> + readonly set: (key: string, info: Info) => Effect.Effect + readonly remove: (key: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Auth") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fsys = yield* AppFileSystem.Service + const decode = Schema.decodeUnknownOption(Info) + + const all = Effect.fn("Auth.all")(function* () { + if (process.env.KILO_AUTH_CONTENT) { + try { + return JSON.parse(process.env.KILO_AUTH_CONTENT) + } catch (err) {} + } + + const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record + return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined)) + }) + + const get = Effect.fn("Auth.get")(function* (providerID: string) { + return (yield* all())[providerID] + }) + + const set = Effect.fn("Auth.set")(function* (key: string, info: Info) { + const norm = key.replace(/\/+$/, "") + const data = yield* all() + if (norm !== key) delete data[key] + delete data[norm + "/"] + yield* fsys + .writeJson(file, { ...data, [norm]: info }, 0o600) + .pipe(Effect.mapError(fail("Failed to write auth data"))) + }) + + const remove = Effect.fn("Auth.remove")(function* (key: string) { + const norm = key.replace(/\/+$/, "") + const data = yield* all() + delete data[key] + delete data[norm] + yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data"))) + + // kilocode_change start - Track logout and reset telemetry identity for Kilo + if (key === "kilo") { + yield* Effect.promise(() => Telemetry.updateIdentity(null)) + } + Telemetry.trackAuthLogout(key) + // kilocode_change end + }) + + return Service.of({ get, all, set, remove }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) + +// kilocode_change start - legacy promise helpers for Kilo callsites +const { runPromise } = makeRuntime(Service, defaultLayer) +export const get = (providerID: string) => runPromise((svc) => svc.get(providerID)) +export const all = () => runPromise((svc) => svc.all()) +export const set = (key: string, info: Info) => runPromise((svc) => svc.set(key, info)) +export const remove = (key: string) => runPromise((svc) => svc.remove(key)) +// kilocode_change end + +export * as Auth from "." diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index 369a40ed88..efaed94406 100644 --- a/packages/opencode/src/bus/bus-event.ts +++ b/packages/opencode/src/bus/bus-event.ts @@ -1,33 +1,33 @@ import z from "zod" import type { ZodType } from "zod" -export namespace BusEvent { - export type Definition = ReturnType +export type Definition = ReturnType - const registry = new Map() +const registry = new Map() - export function define(type: Type, properties: Properties) { - const result = { - type, - properties, - } - registry.set(type, result) - return result - } - - export function payloads() { - return registry - .entries() - .map(([type, def]) => { - return z - .object({ - type: z.literal(type), - properties: def.properties, - }) - .meta({ - ref: `Event.${def.type}`, - }) - }) - .toArray() +export function define(type: Type, properties: Properties) { + const result = { + type, + properties, } + registry.set(type, result) + return result } + +export function payloads() { + return registry + .entries() + .map(([type, def]) => { + return z + .object({ + type: z.literal(type), + properties: def.properties, + }) + .meta({ + ref: `Event.${def.type}`, + }) + }) + .toArray() +} + +export * as BusEvent from "./bus-event" diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts index 89680ebe0a..38c28032cd 100644 --- a/packages/opencode/src/cli/cmd/account.ts +++ b/packages/opencode/src/cli/cmd/account.ts @@ -1,8 +1,8 @@ import { cmd } from "./cmd" import { Duration, Effect, Match, Option } from "effect" import { UI } from "../ui" -import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account" -import { type AccountError } from "@/account/schema" +import { Account } from "@/account/account" +import { AccountID, OrgID, PollExpired, type PollResult, type AccountError } from "@/account/schema" import { AppRuntime } from "@/effect/app-runtime" import * as Prompt from "../effect/prompt" import open from "open" diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 9ef6f5a7ee..0953049b9f 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -157,7 +157,16 @@ export function tui(input: { - + Promise }) { }) local.model.set({ providerID, modelID }, { recent: true }) } - // Handle --session without --fork immediately (fork is handled in createEffect below) if (args.sessionID && !args.fork) { route.navigate({ type: "session", @@ -438,12 +446,8 @@ function App(props: { onSnapshot?: () => Promise }) { aliases: ["clear"], }, onSelect: () => { - const current = promptRef.current - // Don't require focus - if there's any text, preserve it - const currentPrompt = current?.current?.input ? current.current : undefined route.navigate({ type: "home", - initialPrompt: currentPrompt, }) dialog.clear() }, diff --git a/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx b/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx new file mode 100644 index 0000000000..541ecea4e1 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx @@ -0,0 +1,130 @@ +import { BoxRenderable, RGBA } from "@opentui/core" +import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js" +import { tint, useTheme } from "@tui/context/theme" + +const PERIOD = 4600 +const RINGS = 3 +const WIDTH = 3.8 +const TAIL = 9.5 +const AMP = 0.55 +const TAIL_AMP = 0.16 +const BREATH_AMP = 0.05 +const BREATH_SPEED = 0.0008 +// Offset so bg ring emits from GO center at the moment the logo pulse peaks. +const PHASE_OFFSET = 0.29 + +export type BgPulseMask = { + x: number + y: number + width: number + height: number + pad?: number + strength?: number +} + +export function BgPulse(props: { centerX?: number; centerY?: number; masks?: BgPulseMask[] }) { + const { theme } = useTheme() + const [now, setNow] = createSignal(performance.now()) + const [size, setSize] = createSignal<{ width: number; height: number }>({ width: 0, height: 0 }) + let box: BoxRenderable | undefined + + const timer = setInterval(() => setNow(performance.now()), 50) + onCleanup(() => clearInterval(timer)) + + const sync = () => { + if (!box) return + setSize({ width: box.width, height: box.height }) + } + + onMount(() => { + sync() + box?.on("resize", sync) + }) + + onCleanup(() => { + box?.off("resize", sync) + }) + + const grid = createMemo(() => { + const t = now() + const w = size().width + const h = size().height + if (w === 0 || h === 0) return [] as RGBA[][] + const cxv = props.centerX ?? w / 2 + const cyv = props.centerY ?? h / 2 + const reach = Math.hypot(Math.max(cxv, w - cxv), Math.max(cyv, h - cyv) * 2) + TAIL + const ringStates = Array.from({ length: RINGS }, (_, i) => { + const offset = i / RINGS + const phase = (t / PERIOD + offset - PHASE_OFFSET + 1) % 1 + const envelope = Math.sin(phase * Math.PI) + const eased = envelope * envelope * (3 - 2 * envelope) + return { + head: phase * reach, + eased, + } + }) + const normalizedMasks = props.masks?.map((m) => { + const pad = m.pad ?? 2 + return { + left: m.x - pad, + right: m.x + m.width + pad, + top: m.y - pad, + bottom: m.y + m.height + pad, + pad, + strength: m.strength ?? 0.85, + } + }) + const rows = [] as RGBA[][] + for (let y = 0; y < h; y++) { + const row = [] as RGBA[] + for (let x = 0; x < w; x++) { + const dx = x + 0.5 - cxv + const dy = (y + 0.5 - cyv) * 2 + const dist = Math.hypot(dx, dy) + let level = 0 + for (const ring of ringStates) { + const delta = dist - ring.head + const crest = Math.abs(delta) < WIDTH ? 0.5 + 0.5 * Math.cos((delta / WIDTH) * Math.PI) : 0 + const tail = delta < 0 && delta > -TAIL ? (1 + delta / TAIL) ** 2.3 : 0 + level += (crest * AMP + tail * TAIL_AMP) * ring.eased + } + const edgeFalloff = Math.max(0, 1 - (dist / (reach * 0.85)) ** 2) + const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP + let maskAtten = 1 + if (normalizedMasks) { + for (const m of normalizedMasks) { + if (x < m.left || x > m.right || y < m.top || y > m.bottom) continue + const inX = Math.min(x - m.left, m.right - x) + const inY = Math.min(y - m.top, m.bottom - y) + const edge = Math.min(inX / m.pad, inY / m.pad, 1) + const eased = edge * edge * (3 - 2 * edge) + const reduce = 1 - m.strength * eased + if (reduce < maskAtten) maskAtten = reduce + } + } + const strength = Math.min(1, ((level / RINGS) * edgeFalloff + breath * edgeFalloff) * maskAtten) + row.push(tint(theme.backgroundPanel, theme.primary, strength * 0.7)) + } + rows.push(row) + } + return rows + }) + + return ( + (box = item)} width="100%" height="100%"> + + {(row) => ( + + + {(color) => ( + + {" "} + + )} + + + )} + + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx index 2d200ca3b8..ace4b090bc 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx @@ -1,12 +1,16 @@ -import { RGBA, TextAttributes } from "@opentui/core" +import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core" import { useKeyboard } from "@opentui/solid" import open from "open" -import { createSignal } from "solid-js" +import { createSignal, onCleanup, onMount } from "solid-js" import { selectedForeground, useTheme } from "@tui/context/theme" import { useDialog, type DialogContext } from "@tui/ui/dialog" import { Link } from "@tui/ui/link" +import { GoLogo } from "./logo" +import { BgPulse, type BgPulseMask } from "./bg-pulse" const GO_URL = "https://opencode.ai/go" +const PAD_X = 3 +const PAD_TOP_OUTER = 1 export type DialogGoUpsellProps = { onClose?: (dontShowAgain?: boolean) => void @@ -27,62 +31,116 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) { const dialog = useDialog() const { theme } = useTheme() const fg = selectedForeground(theme) - const [selected, setSelected] = createSignal(0) + const [selected, setSelected] = createSignal<"dismiss" | "subscribe">("subscribe") + const [center, setCenter] = createSignal<{ x: number; y: number } | undefined>() + const [masks, setMasks] = createSignal([]) + let content: BoxRenderable | undefined + let logoBox: BoxRenderable | undefined + let headingBox: BoxRenderable | undefined + let descBox: BoxRenderable | undefined + let buttonsBox: BoxRenderable | undefined + + const sync = () => { + if (!content || !logoBox) return + setCenter({ + x: logoBox.x - content.x + logoBox.width / 2, + y: logoBox.y - content.y + logoBox.height / 2 + PAD_TOP_OUTER, + }) + const next: BgPulseMask[] = [] + const baseY = PAD_TOP_OUTER + for (const b of [headingBox, descBox, buttonsBox]) { + if (!b) continue + next.push({ + x: b.x - content.x, + y: b.y - content.y + baseY, + width: b.width, + height: b.height, + pad: 2, + strength: 0.78, + }) + } + setMasks(next) + } + + onMount(() => { + sync() + for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.on("resize", sync) + }) + + onCleanup(() => { + for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.off("resize", sync) + }) useKeyboard((evt) => { if (evt.name === "left" || evt.name === "right" || evt.name === "tab") { - setSelected((s) => (s === 0 ? 1 : 0)) + setSelected((s) => (s === "subscribe" ? "dismiss" : "subscribe")) return } - if (evt.name !== "return") return - if (selected() === 0) subscribe(props, dialog) - else dismiss(props, dialog) + if (evt.name === "return") { + if (selected() === "subscribe") subscribe(props, dialog) + else dismiss(props, dialog) + } }) return ( - - - - Free limit reached - - dialog.clear()}> - esc - + (content = item)}> + + - - - Subscribe to OpenCode Go to keep going with reliable access to the best open-source models, starting at - $5/month. - - + + (headingBox = item)} flexDirection="row" justifyContent="space-between"> + + Free limit reached + + dialog.clear()}> + esc + + + (descBox = item)} gap={0}> + + Subscribe to + + OpenCode Go + + for reliable access to the + + best open-source models, starting at $5/month. + + + (logoBox = item)}> + + - - - setSelected(0)} - onMouseUp={() => subscribe(props, dialog)} - > - - subscribe - - - setSelected(1)} - onMouseUp={() => dismiss(props, dialog)} - > - (buttonsBox = item)} flexDirection="row" justifyContent="space-between"> + setSelected("dismiss")} + onMouseUp={() => dismiss(props, dialog)} > - don't show again - + + don't show again + + + setSelected("subscribe")} + onMouseUp={() => subscribe(props, dialog)} + > + + subscribe + + diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index b4c25dca16..31602ef725 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -137,7 +137,11 @@ export function DialogSessionList() { const all = global() // kilocode_change return sessions() .filter((x) => x.parentID === undefined) - .toSorted((a, b) => b.time.updated - a.time.updated) + .toSorted((a, b) => { + const updatedDay = new Date(b.time.updated).setHours(0, 0, 0, 0) - new Date(a.time.updated).setHours(0, 0, 0, 0) + if (updatedDay !== 0) return updatedDay + return b.time.created - a.time.created + }) .map((x) => { const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index a48cc6e05f..c98fe9d899 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -229,6 +229,10 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) = }) const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => { + toast.show({ + message: "Creating workspace failed", + variant: "error", + }) log.error("workspace create request failed", { type, error: errorData(err), diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx index 1892391abf..5814b31387 100644 --- a/packages/opencode/src/cli/cmd/tui/component/logo.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx @@ -1,9 +1,62 @@ import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core" -import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js" +import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js" import { useTheme, tint } from "@tui/context/theme" import { KiloLogo } from "./kilo-logo" // kilocode_change import * as Sound from "@tui/util/sound" -import { logo } from "@/cli/logo" +import { go, logo } from "@/cli/logo" + +export type LogoShape = { + left: string[] + right: string[] +} + +type ShimmerConfig = { + period: number + rings: number + sweepFraction: number + coreWidth: number + coreAmp: number + softWidth: number + softAmp: number + tail: number + tailAmp: number + haloWidth: number + haloOffset: number + haloAmp: number + breathBase: number + noise: number + ambientAmp: number + ambientCenter: number + ambientWidth: number + shadowMix: number + primaryMix: number + originX: number + originY: number +} + +const shimmerConfig: ShimmerConfig = { + period: 4600, + rings: 2, + sweepFraction: 1, + coreWidth: 1.2, + coreAmp: 1.9, + softWidth: 10, + softAmp: 1.6, + tail: 5, + tailAmp: 0.64, + haloWidth: 4.3, + haloOffset: 0.6, + haloAmp: 0.16, + breathBase: 0.04, + noise: 0.1, + ambientAmp: 0.36, + ambientCenter: 0.5, + ambientWidth: 0.34, + shadowMix: 0.1, + primaryMix: 0.3, + originX: 4.5, + originY: 13.5, +} // Shadow markers (rendered chars in parens): // _ = full shadow cell (space with bg=shadow) @@ -75,9 +128,6 @@ type Frame = { spark: number } -const LEFT = logo.left[0]?.length ?? 0 -const FULL = logo.left.map((line, i) => line + " ".repeat(GAP) + logo.right[i]) -const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94 const NEAR = [ [1, 0], [1, 1], @@ -141,7 +191,7 @@ function noise(x: number, y: number, t: number) { } function lit(char: string) { - return char !== " " && char !== "_" && char !== "~" + return char !== " " && char !== "_" && char !== "~" && char !== "," } function key(x: number, y: number) { @@ -189,12 +239,12 @@ function route(list: Array<{ x: number; y: number }>) { return path } -function mapGlyphs() { +function mapGlyphs(full: string[]) { const cells = [] as Array<{ x: number; y: number }> - for (let y = 0; y < FULL.length; y++) { - for (let x = 0; x < (FULL[y]?.length ?? 0); x++) { - if (lit(FULL[y]?.[x] ?? " ")) cells.push({ x, y }) + for (let y = 0; y < full.length; y++) { + for (let x = 0; x < (full[y]?.length ?? 0); x++) { + if (lit(full[y]?.[x] ?? " ")) cells.push({ x, y }) } } @@ -238,9 +288,25 @@ function mapGlyphs() { return { glyph, trace, center } } -const MAP = mapGlyphs() +type LogoContext = { + LEFT: number + FULL: string[] + SPAN: number + MAP: ReturnType + shape: LogoShape +} -function shimmer(x: number, y: number, frame: Frame) { +function build(shape: LogoShape): LogoContext { + const LEFT = shape.left[0]?.length ?? 0 + const FULL = shape.left.map((line, i) => line + " ".repeat(GAP) + shape.right[i]) + const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94 + return { LEFT, FULL, SPAN, MAP: mapGlyphs(FULL), shape } +} + +const DEFAULT = build(logo) +const GO = build(go) + +function shimmer(x: number, y: number, frame: Frame, ctx: LogoContext) { return frame.list.reduce((best, item) => { const age = frame.t - item.at if (age < SHIMMER_IN || age > LIFE) return best @@ -248,7 +314,7 @@ function shimmer(x: number, y: number, frame: Frame) { const dy = y * 2 + 1 - item.y const dist = Math.hypot(dx, dy) const p = age / LIFE - const r = SPAN * (1 - (1 - p) ** EXPAND) + const r = ctx.SPAN * (1 - (1 - p) ** EXPAND) const lag = r - dist if (lag < 0.18 || lag > SHIMMER_OUT) return best const band = Math.exp(-(((lag - 1.05) / 0.68) ** 2)) @@ -259,19 +325,19 @@ function shimmer(x: number, y: number, frame: Frame) { }, 0) } -function remain(x: number, y: number, item: Release, t: number) { +function remain(x: number, y: number, item: Release, t: number, ctx: LogoContext) { const age = t - item.at if (age < 0 || age > LIFE) return 0 const p = age / LIFE const dx = x + 0.5 - item.x - 0.5 const dy = y * 2 + 1 - item.y * 2 - 1 const dist = Math.hypot(dx, dy) - const r = SPAN * (1 - (1 - p) ** EXPAND) + const r = ctx.SPAN * (1 - (1 - p) ** EXPAND) if (dist > r) return 1 return clamp((r - dist) / 1.35 < 1 ? 1 - (r - dist) / 1.35 : 0) } -function wave(x: number, y: number, frame: Frame, live: boolean) { +function wave(x: number, y: number, frame: Frame, live: boolean, ctx: LogoContext) { return frame.list.reduce((sum, item) => { const age = frame.t - item.at if (age < 0 || age > LIFE) return sum @@ -279,7 +345,7 @@ function wave(x: number, y: number, frame: Frame, live: boolean) { const dx = x + 0.5 - item.x const dy = y * 2 + 1 - item.y const dist = Math.hypot(dx, dy) - const r = SPAN * (1 - (1 - p) ** EXPAND) + const r = ctx.SPAN * (1 - (1 - p) ** EXPAND) const fade = (1 - p) ** 1.32 const j = 1.02 + noise(x + item.x * 0.7, y + item.y * 0.7, item.at * 0.002 + age * 0.06) * 0.52 const edge = Math.exp(-(((dist - r) / WIDTH) ** 2)) * GAIN * fade * item.force * j @@ -293,7 +359,7 @@ function wave(x: number, y: number, frame: Frame, live: boolean) { }, 0) } -function field(x: number, y: number, frame: Frame) { +function field(x: number, y: number, frame: Frame, ctx: LogoContext) { const held = frame.hold const rest = frame.release const item = held ?? rest @@ -327,11 +393,11 @@ function field(x: number, y: number, frame: Frame) { Math.max(0, noise(item.x * 3.1, item.y * 2.7, frame.t * 1.7) - 0.72) * Math.exp(-(dist * dist) / 0.15) * lerp(0.08, 0.42, body) - const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1 + const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1 return (core + shell + ember + ring + fork + glitch + lash + flicker - dim) * fade } -function pick(x: number, y: number, frame: Frame) { +function pick(x: number, y: number, frame: Frame, ctx: LogoContext) { const held = frame.hold const rest = frame.release const item = held ?? rest @@ -340,26 +406,26 @@ function pick(x: number, y: number, frame: Frame) { const dx = x + 0.5 - item.x - 0.5 const dy = y * 2 + 1 - item.y * 2 - 1 const dist = Math.hypot(dx, dy) - const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1 + const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1 return Math.exp(-(dist * dist) / 1.7) * lerp(0.2, 0.96, rise) * fade } -function select(x: number, y: number) { - const direct = MAP.glyph.get(key(x, y)) +function select(x: number, y: number, ctx: LogoContext) { + const direct = ctx.MAP.glyph.get(key(x, y)) if (direct !== undefined) return direct - const near = NEAR.map(([dx, dy]) => MAP.glyph.get(key(x + dx, y + dy))).find( + const near = NEAR.map(([dx, dy]) => ctx.MAP.glyph.get(key(x + dx, y + dy))).find( (item): item is number => item !== undefined, ) return near } -function trace(x: number, y: number, frame: Frame) { +function trace(x: number, y: number, frame: Frame, ctx: LogoContext) { const held = frame.hold const rest = frame.release const item = held ?? rest if (!item || item.glyph === undefined) return 0 - const step = MAP.trace.get(key(x, y)) + const step = ctx.MAP.trace.get(key(x, y)) if (!step || step.glyph !== item.glyph || step.l < 2) return 0 const age = frame.t - item.at const rise = held ? ramp(age, HOLD, CHARGE) : rest!.rise @@ -369,269 +435,131 @@ function trace(x: number, y: number, frame: Frame) { const dist = Math.min(Math.abs(step.i - head), step.l - Math.abs(step.i - head)) const tail = (head - TAIL + step.l) % step.l const lag = Math.min(Math.abs(step.i - tail), step.l - Math.abs(step.i - tail)) - const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1 + const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1 const core = Math.exp(-((dist / 1.05) ** 2)) * lerp(0.8, 2.35, rise) const glow = Math.exp(-((dist / 1.85) ** 2)) * lerp(0.08, 0.34, rise) const trail = Math.exp(-((lag / 1.45) ** 2)) * lerp(0.04, 0.42, rise) return (core + glow + trail) * appear * fade } -function bloom(x: number, y: number, frame: Frame) { +function idle( + x: number, + pixelY: number, + frame: Frame, + ctx: LogoContext, + state: IdleState, +): { glow: number; peak: number; primary: number } { + const cfg = state.cfg + const dx = x + 0.5 - cfg.originX + const dy = pixelY - cfg.originY + const dist = Math.hypot(dx, dy) + const angle = Math.atan2(dy, dx) + const wob1 = noise(x * 0.32, pixelY * 0.25, frame.t * 0.0005) - 0.5 + const wob2 = noise(x * 0.12, pixelY * 0.08, frame.t * 0.00022) - 0.5 + const ripple = Math.sin(angle * 3 + frame.t * 0.0012) * 0.3 + const jitter = (wob1 * 0.55 + wob2 * 0.32 + ripple * 0.18) * cfg.noise + const traveled = dist + jitter + let glow = 0 + let peak = 0 + let halo = 0 + let primary = 0 + let ambient = 0 + for (const active of state.active) { + const head = active.head + const eased = active.eased + const delta = traveled - head + // Use shallower exponent (1.6 vs 2) for softer edges on the Gaussians + // so adjacent pixels have smaller brightness deltas + const core = Math.exp(-(Math.abs(delta / cfg.coreWidth) ** 1.8)) + const soft = Math.exp(-(Math.abs(delta / cfg.softWidth) ** 1.6)) + const tailRange = cfg.tail * 2.6 + const tail = delta < 0 && delta > -tailRange ? (1 + delta / tailRange) ** 2.6 : 0 + const haloDelta = delta + cfg.haloOffset + const haloBand = Math.exp(-(Math.abs(haloDelta / cfg.haloWidth) ** 1.6)) + glow += (soft * cfg.softAmp + tail * cfg.tailAmp) * eased + peak += core * cfg.coreAmp * eased + halo += haloBand * cfg.haloAmp * eased + // Primary-tinted fringe follows the halo (which trails behind the core) and the tail + primary += (haloBand + tail * 0.6) * eased + ambient += active.ambient + } + ambient /= state.rings + return { + glow: glow / state.rings, + peak: cfg.breathBase + ambient + (peak + halo) / state.rings, + primary: (primary / state.rings) * cfg.primaryMix, + } +} + +function bloom(x: number, y: number, frame: Frame, ctx: LogoContext) { const item = frame.glow if (!item) return 0 - const glyph = MAP.glyph.get(key(x, y)) + const glyph = ctx.MAP.glyph.get(key(x, y)) if (glyph !== item.glyph) return 0 const age = frame.t - item.at if (age < 0 || age > GLOW_OUT) return 0 const p = age / GLOW_OUT const flash = (1 - p) ** 2 - const dx = x + 0.5 - MAP.center.get(item.glyph)!.x - const dy = y * 2 + 1 - MAP.center.get(item.glyph)!.y + const dx = x + 0.5 - ctx.MAP.center.get(item.glyph)!.x + const dy = y * 2 + 1 - ctx.MAP.center.get(item.glyph)!.y const bias = Math.exp(-((Math.hypot(dx, dy) / 2.8) ** 2)) return lerp(item.force, item.force * 0.18, p) * lerp(0.72, 1.1, bias) * flash } -export function Logo() { - // kilocode_change start - return - // kilocode_change end - const { theme } = useTheme() - const [rings, setRings] = createSignal([]) - const [hold, setHold] = createSignal() - const [release, setRelease] = createSignal() - const [glow, setGlow] = createSignal() - const [now, setNow] = createSignal(0) - let box: BoxRenderable | undefined - let timer: ReturnType | undefined - let hum = false - - const stop = () => { - if (!timer) return - clearInterval(timer) - timer = undefined - } - - const tick = () => { - const t = performance.now() - setNow(t) - const item = hold() - if (item && !hum && t - item.at >= HOLD) { - hum = true - Sound.start() - } - if (item && t - item.at >= CHARGE) { - burst(item.x, item.y) - } - let live = false - setRings((list) => { - const next = list.filter((item) => t - item.at < LIFE) - live = next.length > 0 - return next - }) - const flash = glow() - if (flash && t - flash.at >= GLOW_OUT) { - setGlow(undefined) - } - if (!live) setRelease(undefined) - if (live || hold() || release() || glow()) return - stop() - } - - const start = () => { - if (timer) return - timer = setInterval(tick, 16) - } - - const hit = (x: number, y: number) => { - const char = FULL[y]?.[x] - return char !== undefined && char !== " " - } - - const press = (x: number, y: number, t: number) => { - const last = hold() - if (last) burst(last.x, last.y) - setNow(t) - if (!last) setRelease(undefined) - setHold({ x, y, at: t, glyph: select(x, y) }) - hum = false - start() - } - - const burst = (x: number, y: number) => { - const item = hold() - if (!item) return - hum = false - const t = performance.now() - const age = t - item.at - const rise = ramp(age, HOLD, CHARGE) - const level = push(rise) - setHold(undefined) - setRelease({ x, y, at: t, glyph: item.glyph, level, rise }) - if (item.glyph !== undefined) { - setGlow({ glyph: item.glyph, at: t, force: lerp(0.18, 1.5, rise * level) }) - } - setRings((list) => [ - ...list, - { - x: x + 0.5, - y: y * 2 + 1, - at: t, - force: lerp(0.82, 2.55, level), - kick: lerp(0.32, 0.32 + KICK, level), - }, - ]) - setNow(t) - start() - Sound.pulse(lerp(0.8, 1, level)) - } - - const frame = createMemo(() => { - const t = now() - const item = hold() - return { - t, - list: rings(), - hold: item, - release: release(), - glow: glow(), - spark: item ? noise(item.x, item.y, t) : 0, - } - }) - - const dusk = createMemo(() => { - const base = frame() - const t = base.t - LAG - const item = base.hold - return { - t, - list: base.list, - hold: item, - release: base.release, - glow: base.glow, - spark: item ? noise(item.x, item.y, t) : 0, - } - }) - - const renderLine = ( - line: string, - y: number, - ink: RGBA, - bold: boolean, - off: number, - frame: Frame, - dusk: Frame, - ): JSX.Element[] => { - const shadow = tint(theme.background, ink, 0.25) - const attrs = bold ? TextAttributes.BOLD : undefined - - return Array.from(line).map((char, i) => { - const h = field(off + i, y, frame) - const n = wave(off + i, y, frame, lit(char)) + h - const s = wave(off + i, y, dusk, false) + h - const p = lit(char) ? pick(off + i, y, frame) : 0 - const e = lit(char) ? trace(off + i, y, frame) : 0 - const b = lit(char) ? bloom(off + i, y, frame) : 0 - const q = shimmer(off + i, y, frame) - - if (char === "_") { - return ( - - {" "} - - ) - } - - if (char === "^") { - return ( - - ▀ - - ) - } - - if (char === "~") { - return ( - - ▀ - - ) - } - - if (char === " ") { - return ( - - {char} - - ) - } - - return ( - - {char} - - ) - }) - } - - onCleanup(() => { - stop() - hum = false - Sound.dispose() - }) - - const mouse = (evt: MouseEvent) => { - if (!box) return - if ((evt.type === "down" || evt.type === "drag") && evt.button === MouseButton.LEFT) { - const x = evt.x - box.x - const y = evt.y - box.y - if (!hit(x, y)) return - if (evt.type === "drag" && hold()) return - evt.preventDefault() - evt.stopPropagation() - const t = performance.now() - press(x, y, t) - return - } - - if (!hold()) return - if (evt.type === "up") { - const item = hold() - if (!item) return - burst(item.x, item.y) - } - } - - return ( - (box = item)}> - - - {(line, index) => ( - - {renderLine(line, index(), theme.textMuted, false, 0, frame(), dusk())} - - {renderLine(logo.right[index()], index(), theme.text, true, LEFT + GAP, frame(), dusk())} - - - )} - - - ) +type IdleState = { + cfg: ShimmerConfig + reach: number + rings: number + active: Array<{ + head: number + eased: number + ambient: number + }> +} + +function buildIdleState(t: number, ctx: LogoContext): IdleState { + const cfg = shimmerConfig + const w = ctx.FULL[0]?.length ?? 1 + const h = ctx.FULL.length * 2 + const corners: [number, number][] = [ + [0, 0], + [w, 0], + [0, h], + [w, h], + ] + let maxCorner = 0 + for (const [cx, cy] of corners) { + const d = Math.hypot(cx - cfg.originX, cy - cfg.originY) + if (d > maxCorner) maxCorner = d + } + const reach = maxCorner + cfg.tail * 2 + const rings = Math.max(1, Math.floor(cfg.rings)) + const active = [] as IdleState["active"] + for (let i = 0; i < rings; i++) { + const offset = i / rings + const cyclePhase = (t / cfg.period + offset) % 1 + if (cyclePhase >= cfg.sweepFraction) continue + const phase = cyclePhase / cfg.sweepFraction + const envelope = Math.sin(phase * Math.PI) + const eased = envelope * envelope * (3 - 2 * envelope) + const d = (phase - cfg.ambientCenter) / cfg.ambientWidth + active.push({ + head: phase * reach, + eased, + ambient: Math.abs(d) < 1 ? (1 - d * d) ** 2 * cfg.ambientAmp : 0, + }) + } + return { cfg, reach, rings, active } +} + +// kilocode_change start +export function Logo(_props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = {}) { + return +} +// kilocode_change end + +export function GoLogo() { + const { theme } = useTheme() + const base = tint(theme.background, theme.text, 0.62) + return } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 3e6873a3c3..3920e9e2cc 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1,4 +1,4 @@ -import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core" +import { BoxRenderable, RGBA, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core" import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js" import "opentui-spinner/solid" import path from "path" @@ -12,7 +12,7 @@ import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" import { useEvent } from "@tui/context/event" import { MessageID, PartID } from "@/session/schema" -import { createStore, produce } from "solid-js/store" +import { createStore, produce, unwrap } from "solid-js/store" import { useKeybind } from "@tui/context/keybind" import { usePromptHistory, type PromptInfo } from "./history" import { assign } from "./part" @@ -35,6 +35,7 @@ import { DialogProvider as DialogProviderConnect } from "../dialog-provider" import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" +import { createFadeIn } from "../../util/signal" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" import { useArgs } from "@tui/context/args" @@ -75,6 +76,12 @@ function randomIndex(count: number) { return Math.floor(Math.random() * count) } +function fadeColor(color: RGBA, alpha: number) { + return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha) +} + +let stashed: { prompt: PromptInfo; cursor: number } | undefined + export function Prompt(props: PromptProps) { let input: TextareaRenderable let anchor: BoxRenderable @@ -95,6 +102,7 @@ export function Prompt(props: PromptProps) { const renderer = useRenderer() const { theme, syntax } = useTheme() const kv = useKV() + const animationsEnabled = createMemo(() => kv.get("animations_enabled", true)) const list = createMemo(() => props.placeholders?.normal ?? []) const shell = createMemo(() => props.placeholders?.shell ?? []) const [auto, setAuto] = createSignal() @@ -435,7 +443,22 @@ export function Prompt(props: PromptProps) { }, } + onMount(() => { + const saved = stashed + stashed = undefined + if (store.prompt.input) return + if (saved && saved.prompt.input) { + input.setText(saved.prompt.input) + setStore("prompt", saved.prompt) + restoreExtmarksFromParts(saved.prompt.parts) + input.cursorOffset = saved.cursor + } + }) + onCleanup(() => { + if (store.prompt.input) { + stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset } + } props.ref?.(undefined) }) @@ -844,6 +867,13 @@ export function Prompt(props: PromptProps) { return !!current }) + const agentMetaAlpha = createFadeIn(() => !!local.agent.current(), animationsEnabled) + const modelMetaAlpha = createFadeIn(() => !!local.agent.current() && store.mode === "normal", animationsEnabled) + const variantMetaAlpha = createFadeIn( + () => !!local.agent.current() && store.mode === "normal" && showVariant(), + animationsEnabled, + ) + const placeholderText = createMemo(() => { if (props.showPlaceholder === false) return undefined if (store.mode === "shell") { @@ -1139,7 +1169,7 @@ export function Prompt(props: PromptProps) { }> {(agent) => ( <> - + {/* kilocode_change start */} {store.mode === "shell" ? "Shell" @@ -1149,14 +1179,19 @@ export function Prompt(props: PromptProps) { - + {local.model.parsed().model} - {currentProviderLabel()} + {currentProviderLabel()} - · + · - {local.model.variant.current()} + + {local.model.variant.current()} + diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts index f38db701b3..f22a5b9e30 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts @@ -26,7 +26,6 @@ const TuiLegacy = z interface MigrateInput { cwd: string directories: string[] - custom?: string } /** @@ -134,8 +133,11 @@ async function backupAndStripLegacy(file: string, source: string) { async function opencodeFiles(input: { directories: string[]; cwd: string }) { // kilocode_change start: use kilo directory everywhere - const project = Flag.KILO_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("kilo", input.cwd) + const project = Flag.KILO_DISABLE_PROJECT_CONFIG + ? [] + : await Filesystem.findUp(["kilo.json", "kilo.jsonc"], input.cwd, undefined, { rootFirst: true }) const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "kilo")] + // kilocode_change end for (const dir of unique(input.directories)) { files.push(...ConfigPaths.fileInDirectory(dir, "kilo")) } @@ -149,4 +151,3 @@ async function opencodeFiles(input: { directories: string[]; cwd: string }) { ) return existing.filter((file): file is string => !!file) } -// kilocode_change end diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 604cc37c45..7d49be4e16 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -1,3 +1,5 @@ +export * as TuiConfig from "./tui" + import z from "zod" import { mergeDeep, unique } from "remeda" import { Context, Effect, Fiber, Layer } from "effect" @@ -16,201 +18,202 @@ import { ConfigKeybinds } from "@/config/keybinds" import { InstallationLocal, InstallationVersion } from "@/installation/version" import { makeRuntime } from "@/cli/effect/runtime" import { Filesystem, Log } from "@/util" +import { ConfigVariable } from "@/config/variable" -export namespace TuiConfig { - const log = Log.create({ service: "tui.config" }) +const log = Log.create({ service: "tui.config" }) - export const Info = TuiInfo +export const Info = TuiInfo - type Acc = { - result: Info - } +type Acc = { + result: Info +} - type State = { - config: Info - deps: Array> - } +type State = { + config: Info + deps: Array> +} - export type Info = z.output & { - // Internal resolved plugin list used by runtime loading. - plugin_origins?: ConfigPlugin.Origin[] - } +export type Info = z.output & { + // Internal resolved plugin list used by runtime loading. + plugin_origins?: ConfigPlugin.Origin[] +} - export interface Interface { - readonly get: () => Effect.Effect - readonly waitForDependencies: () => Effect.Effect - } +export interface Interface { + readonly get: () => Effect.Effect + readonly waitForDependencies: () => Effect.Effect +} - export class Service extends Context.Service()("@opencode/TuiConfig") {} +export class Service extends Context.Service()("@opencode/TuiConfig") {} - function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope { - if (Filesystem.contains(ctx.directory, file)) return "local" - // if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local" - return "global" - } +function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope { + if (Filesystem.contains(ctx.directory, file)) return "local" + // if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local" + return "global" +} - function customPath() { - return Flag.KILO_TUI_CONFIG - } - - function normalize(raw: Record) { - const data = { ...raw } - if (!("tui" in data)) return data - if (!isRecord(data.tui)) { - delete data.tui - return data - } - - const tui = data.tui +function normalize(raw: Record) { + const data = { ...raw } + if (!("tui" in data)) return data + if (!isRecord(data.tui)) { delete data.tui - return { - ...tui, - ...data, - } + return data } - async function resolvePlugins(config: Info, configFilepath: string) { - if (!config.plugin) return config - for (let i = 0; i < config.plugin.length; i++) { - config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath) - } - return config - } - - async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) { - const data = await loadFile(file) - acc.result = mergeDeep(acc.result, data) - if (!data.plugin?.length) return - - const scope = pluginScope(file, ctx) - const plugins = ConfigPlugin.deduplicatePluginOrigins([ - ...(acc.result.plugin_origins ?? []), - ...data.plugin.map((spec) => ({ spec, scope, source: file })), - ]) - acc.result.plugin = plugins.map((item) => item.spec) - acc.result.plugin_origins = plugins - } - - async function loadState(ctx: { directory: string }) { - let projectFiles = Flag.KILO_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory) - const directories = await ConfigPaths.directories(ctx.directory) - const custom = customPath() - await migrateTuiConfig({ directories, custom, cwd: ctx.directory }) - // Re-compute after migration since migrateTuiConfig may have created new tui.json files - projectFiles = Flag.KILO_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory) - - const acc: Acc = { - result: {}, - } - - for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) { - await mergeFile(acc, file, ctx) - } - - if (custom) { - await mergeFile(acc, custom, ctx) - log.debug("loaded custom tui config", { path: custom }) - } - - for (const file of projectFiles) { - await mergeFile(acc, file, ctx) - } - - // kilocode_change - also load tui.json from .kilo/.kilocode - const dirs = unique(directories).filter( - (dir) => - dir.endsWith(".kilo") || dir.endsWith(".kilocode") || dir.endsWith(".opencode") || dir === Flag.KILO_CONFIG_DIR, - ) - - for (const dir of dirs) { - for (const file of ConfigPaths.fileInDirectory(dir, "tui")) { - await mergeFile(acc, file, ctx) - } - } - - const keybinds = { ...(acc.result.keybinds ?? {}) } - if (process.platform === "win32") { - // Native Windows terminals do not support POSIX suspend, so prefer prompt undo. - keybinds.terminal_suspend = "none" - keybinds.input_undo ??= unique([ - "ctrl+z", - ...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","), - ]).join(",") - } - acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds) - - return { - config: acc.result, - dirs: acc.result.plugin?.length ? dirs : [], - } - } - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const directory = yield* CurrentWorkingDirectory - const npm = yield* Npm.Service - const data = yield* Effect.promise(() => loadState({ directory })) - const deps = yield* Effect.forEach( - data.dirs, - (dir) => - npm - .install(dir, { - add: ["@kilocode/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)], - }) - .pipe(Effect.forkScoped), - { - concurrency: "unbounded", - }, - ) - - const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config)) - - const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() => - Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid), - ) - return Service.of({ get, waitForDependencies }) - }).pipe(Effect.withSpan("TuiConfig.layer")), - ) - - export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer)) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function waitForDependencies() { - await runPromise((svc) => svc.waitForDependencies()) - } - - export async function get() { - return runPromise((svc) => svc.get()) - } - - async function loadFile(filepath: string): Promise { - const text = await ConfigPaths.readFile(filepath) - if (!text) return {} - return load(text, filepath).catch((error) => { - log.warn("failed to load tui config", { path: filepath, error }) - return {} - }) - } - - async function load(text: string, configFilepath: string): Promise { - return ConfigParse.load(Info, text, { - type: "path", - path: configFilepath, - missing: "empty", - normalize: (data) => { - if (!isRecord(data)) return {} - - // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json - // (mirroring the old opencode.json shape) still get their settings applied. - return normalize(data) - }, - }) - .then((data) => resolvePlugins(data, configFilepath)) - .catch((error) => { - log.warn("invalid tui config", { path: configFilepath, error }) - return {} - }) + const tui = data.tui + delete data.tui + return { + ...tui, + ...data, } } + +async function resolvePlugins(config: Info, configFilepath: string) { + if (!config.plugin) return config + for (let i = 0; i < config.plugin.length; i++) { + config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath) + } + return config +} + +async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) { + const data = await loadFile(file) + acc.result = mergeDeep(acc.result, data) + if (!data.plugin?.length) return + + const scope = pluginScope(file, ctx) + const plugins = ConfigPlugin.deduplicatePluginOrigins([ + ...(acc.result.plugin_origins ?? []), + ...data.plugin.map((spec) => ({ spec, scope, source: file })), + ]) + acc.result.plugin = plugins.map((item) => item.spec) + acc.result.plugin_origins = plugins +} + +const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) { + // Every config dir we may read from: global config dir, any `.opencode` + // folders between cwd and home, and KILO_CONFIG_DIR. + const directories = yield* ConfigPaths.directories(ctx.directory) + yield* Effect.promise(() => migrateTuiConfig({ directories, cwd: ctx.directory })) + + const projectFiles = Flag.KILO_DISABLE_PROJECT_CONFIG ? [] : yield* ConfigPaths.files("tui", ctx.directory) + + const acc: Acc = { + result: {}, + } + + // 1. Global tui config (lowest precedence). + for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) { + yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie) + } + + // 2. Explicit KILO_TUI_CONFIG override, if set. + if (Flag.KILO_TUI_CONFIG) { + const configFile = Flag.KILO_TUI_CONFIG + yield* Effect.promise(() => mergeFile(acc, configFile, ctx)).pipe(Effect.orDie) + log.debug("loaded custom tui config", { path: configFile }) + } + + // 3. Project tui files, applied root-first so the closest file wins. + for (const file of projectFiles) { + yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie) + } + + // 4. `.opencode` directories (and KILO_CONFIG_DIR) discovered while + // walking up the tree. Also returned below so callers can install plugin + // dependencies from each location. + // kilocode_change start - also load tui.json from .kilo/.kilocode + const dirs = unique(directories).filter( + (dir) => + dir.endsWith(".kilo") || dir.endsWith(".kilocode") || dir.endsWith(".opencode") || dir === Flag.KILO_CONFIG_DIR, + ) + // kilocode_change end + + for (const dir of dirs) { + // if (!dir.endsWith(".opencode") && dir !== Flag.KILO_CONFIG_DIR) continue // kilocode_change + for (const file of ConfigPaths.fileInDirectory(dir, "tui")) { + yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie) + } + } + + const keybinds = { ...(acc.result.keybinds ?? {}) } + if (process.platform === "win32") { + // Native Windows terminals do not support POSIX suspend, so prefer prompt undo. + keybinds.terminal_suspend = "none" + keybinds.input_undo ??= unique([ + "ctrl+z", + ...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","), + ]).join(",") + } + acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds) + + return { + config: acc.result, + dirs: acc.result.plugin?.length ? dirs : [], + } +}) + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const directory = yield* CurrentWorkingDirectory + const npm = yield* Npm.Service + const data = yield* loadState({ directory }) + const deps = yield* Effect.forEach( + data.dirs, + (dir) => + npm + .install(dir, { + add: ["@kilocode/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)], + }) + .pipe(Effect.forkScoped), + { + concurrency: "unbounded", + }, + ) + + const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config)) + + const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() => + Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid), + ) + return Service.of({ get, waitForDependencies }) + }).pipe(Effect.withSpan("TuiConfig.layer")), +) + +export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(AppFileSystem.defaultLayer)) + +const { runPromise } = makeRuntime(Service, defaultLayer) + +export async function waitForDependencies() { + await runPromise((svc) => svc.waitForDependencies()) +} + +export async function get() { + return runPromise((svc) => svc.get()) +} + +async function loadFile(filepath: string): Promise { + const text = await ConfigPaths.readFile(filepath) + if (!text) return {} + return load(text, filepath).catch((error) => { + log.warn("failed to load tui config", { path: filepath, error }) + return {} + }) +} + +async function load(text: string, configFilepath: string): Promise { + return ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }) + .then((expanded) => ConfigParse.jsonc(expanded, configFilepath)) + .then((data) => { + if (!isRecord(data)) return {} + + // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json + // (mirroring the old opencode.json shape) still get their settings applied. + return ConfigParse.schema(Info, normalize(data), configFilepath) + }) + .then((data) => resolvePlugins(data, configFilepath)) + .catch((error) => { + log.warn("invalid tui config", { path: configFilepath, error }) + return {} + }) +} diff --git a/packages/opencode/src/cli/cmd/tui/context/kv.tsx b/packages/opencode/src/cli/cmd/tui/context/kv.tsx index 39e976b0e5..803752e766 100644 --- a/packages/opencode/src/cli/cmd/tui/context/kv.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/kv.tsx @@ -12,7 +12,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({ const [store, setStore] = createStore>() const filePath = path.join(Global.Path.state, "kv.json") - Filesystem.readJson(filePath) + Filesystem.readJson>(filePath) .then((x) => { setStore(x) }) diff --git a/packages/opencode/src/cli/cmd/tui/context/project.tsx b/packages/opencode/src/cli/cmd/tui/context/project.tsx index 42b1c5fa38..44f6b400ae 100644 --- a/packages/opencode/src/cli/cmd/tui/context/project.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/project.tsx @@ -10,18 +10,21 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex name: "Project", init: () => { const sdk = useSDK() + + const defaultPath = { + home: "", + state: "", + config: "", + worktree: "", + directory: sdk.directory ?? "", + } satisfies Path + const [store, setStore] = createStore({ project: { id: undefined as string | undefined, }, instance: { - path: { - home: "", - state: "", - config: "", - worktree: "", - directory: sdk.directory ?? "", - } satisfies Path, + path: defaultPath, }, workspace: { current: undefined as string | undefined, @@ -38,7 +41,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex ]) batch(() => { - setStore("instance", "path", reconcile(path.data!)) + setStore("instance", "path", reconcile(path.data || defaultPath)) setStore("project", "id", project.data?.id) }) } diff --git a/packages/opencode/src/cli/cmd/tui/context/route.tsx b/packages/opencode/src/cli/cmd/tui/context/route.tsx index 6c90abfa23..aa05c87c5a 100644 --- a/packages/opencode/src/cli/cmd/tui/context/route.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/route.tsx @@ -1,16 +1,16 @@ -import { createStore, unwrap } from "solid-js/store" +import { createStore, reconcile, unwrap } from "solid-js/store" // kilocode_change import { createSimpleContext } from "./helper" import type { PromptInfo } from "../component/prompt/history" export type HomeRoute = { type: "home" - initialPrompt?: PromptInfo + prompt?: PromptInfo } export type SessionRoute = { type: "session" sessionID: string - initialPrompt?: PromptInfo + prompt?: PromptInfo } // kilocode_change start @@ -29,13 +29,14 @@ export type Route = HomeRoute | SessionRoute | PluginRoute | KiloClawRoute // ki export const { use: useRoute, provider: RouteProvider } = createSimpleContext({ name: "Route", - init: () => { + init: (props: { initialRoute?: Route }) => { const [store, setStore] = createStore( - process.env["KILO_ROUTE"] - ? JSON.parse(process.env["KILO_ROUTE"]) - : { - type: "home", - }, + props.initialRoute ?? + (process.env["KILO_ROUTE"] + ? JSON.parse(process.env["KILO_ROUTE"]) + : { + type: "home", + }), ) // kilocode_change start @@ -48,7 +49,7 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({ }, navigate(route: Route) { previous = structuredClone(unwrap(store)) // kilocode_change - setStore(route) + setStore(reconcile(route)) }, // kilocode_change start back() { diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 17072c8d4e..a52748d8be 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -29,7 +29,7 @@ import { createSimpleContext } from "./helper" import type { Snapshot } from "@/snapshot" import { useExit } from "./exit" import { useArgs } from "./args" -import { batch, createEffect, on } from "solid-js" +import { batch, createEffect, on, onMount } from "solid-js" // kilocode_change - add createEffect/on for workspace re-bootstrap import { handleSuggestionEvent } from "@/kilocode/suggestion/tui/sync" // kilocode_change import { useToast } from "@tui/ui/toast" // kilocode_change import { Log } from "@/util" @@ -125,8 +125,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const sdk = useSDK() const toast = useToast() // kilocode_change - const fullSyncedSessions = new Set() // kilocode_change - // kilocode_change start function evict(sessionID: string) { // Collect child session IDs so we can evict them too. @@ -159,6 +157,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } // kilocode_change end + const fullSyncedSessions = new Set() + let syncedWorkspace = project.workspace.current() + event.subscribe((event) => { switch (event.type) { case "server.instance.disposed": @@ -475,9 +476,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const exit = useExit() const args = useArgs() - async function bootstrap() { - console.log("bootstrapping") + async function bootstrap(input: { fatal?: boolean } = {}) { + const fatal = input.fatal ?? true const workspace = project.workspace.current() + if (workspace !== syncedWorkspace) { + fullSyncedSessions.clear() + syncedWorkspace = workspace + } const start = Date.now() - 30 * 24 * 60 * 60 * 1000 const sessionListPromise = sdk.client.session .list({ start: start }) @@ -593,10 +598,19 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: e instanceof Error ? e.name : undefined, stack: e instanceof Error ? e.stack : undefined, }) - await exit(e) + if (fatal) { + await exit(e) + } else { + throw e + } }) } + onMount(() => { + void bootstrap() + }) + + // kilocode_change start - re-bootstrap when workspace changes (Agent Manager) createEffect( on( () => project.workspace.current(), @@ -604,8 +618,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ fullSyncedSessions.clear() void bootstrap() }, + { defer: true }, ), ) + // kilocode_change end const result = { data: store, @@ -614,6 +630,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return store.status }, get ready() { + return true + if (process.env.KILO_FAST_BOOT) return true return store.status !== "loading" }, get path() { diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index f2c6a65b9f..2e47afeab2 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -410,7 +410,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ if (store.lock) return apply(mode) } - renderer.on(CliRenderEvents.THEME_MODE, handle) + // renderer.on(CliRenderEvents.THEME_MODE, handle) const refresh = () => { renderer.clearPaletteCache() diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 1b86551778..c248c48ede 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -91,7 +91,7 @@ function routeCurrent(route: ReturnType): TuiPluginApi["route"] name: "session", params: { sessionID: route.data.sessionID, - initialPrompt: route.data.initialPrompt, + prompt: route.data.prompt, }, } } diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index efea7cac67..91a3e23296 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -918,113 +918,113 @@ async function installPluginBySpec( } } -export namespace TuiPluginRuntime { - let dir = "" - let loaded: Promise | undefined - let runtime: RuntimeState | undefined - export const Slot = View +let dir = "" +let loaded: Promise | undefined +let runtime: RuntimeState | undefined +export const Slot = View - export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) { - const cwd = process.cwd() - if (loaded) { - if (dir !== cwd) { - throw new Error(`TuiPluginRuntime.init() called with a different working directory. expected=${dir} got=${cwd}`) - } - return loaded +export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) { + const cwd = process.cwd() + if (loaded) { + if (dir !== cwd) { + throw new Error(`TuiPluginRuntime.init() called with a different working directory. expected=${dir} got=${cwd}`) } - - dir = cwd - loaded = load(input) return loaded } - export function list() { - if (!runtime) return [] - return listPluginStatus(runtime) - } + dir = cwd + loaded = load(input) + return loaded +} - export async function activatePlugin(id: string) { - return activatePluginById(runtime, id, true) - } +export function list() { + if (!runtime) return [] + return listPluginStatus(runtime) +} - export async function deactivatePlugin(id: string) { - return deactivatePluginById(runtime, id, true) - } +export async function activatePlugin(id: string) { + return activatePluginById(runtime, id, true) +} - export async function addPlugin(spec: string) { - return addPluginBySpec(runtime, spec) - } +export async function deactivatePlugin(id: string) { + return deactivatePluginById(runtime, id, true) +} - export async function installPlugin(spec: string, options?: { global?: boolean }) { - return installPluginBySpec(runtime, spec, options?.global) - } +export async function addPlugin(spec: string) { + return addPluginBySpec(runtime, spec) +} - export async function dispose() { - const task = loaded - loaded = undefined - dir = "" - if (task) await task - const state = runtime - runtime = undefined - if (!state) return - const queue = [...state.plugins].reverse() - for (const plugin of queue) { - await deactivatePluginEntry(state, plugin, false) - } - } +export async function installPlugin(spec: string, options?: { global?: boolean }) { + return installPluginBySpec(runtime, spec, options?.global) +} - async function load(input: { api: Api; config: TuiConfig.Info }) { - const { api, config } = input - const cwd = process.cwd() - const slots = setupSlots(api) - const next: RuntimeState = { - directory: cwd, - api, - slots, - plugins: [], - plugins_by_id: new Map(), - pending: new Map(), - } - runtime = next - try { - await Instance.provide({ - directory: cwd, - fn: async () => { - const records = Flag.KILO_PURE ? [] : (config.plugin_origins ?? []) - if (Flag.KILO_PURE && config.plugin_origins?.length) { - log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length }) - } - - for (const item of INTERNAL_TUI_PLUGINS) { - log.info("loading internal tui plugin", { id: item.id }) - const entry = loadInternalPlugin(item) - const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id) - addPluginEntry(next, { - id: entry.id, - load: entry, - meta, - themes: {}, - plugin: entry.module.tui, - enabled: true, - }) - } - - const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies()) - await addExternalPluginEntries(next, ready) - - applyInitialPluginEnabledState(next, config) - for (const plugin of next.plugins) { - if (!plugin.enabled) continue - // Keep plugin execution sequential for deterministic side effects: - // command registration order affects keybind/command precedence, - // route registration is last-wins when ids collide, - // and hook chains rely on stable plugin ordering. - await activatePluginEntry(next, plugin, false) - } - }, - }) - } catch (error) { - fail("failed to load tui plugins", { directory: cwd, error }) - } +export async function dispose() { + const task = loaded + loaded = undefined + dir = "" + if (task) await task + const state = runtime + runtime = undefined + if (!state) return + const queue = [...state.plugins].reverse() + for (const plugin of queue) { + await deactivatePluginEntry(state, plugin, false) } } + +async function load(input: { api: Api; config: TuiConfig.Info }) { + const { api, config } = input + const cwd = process.cwd() + const slots = setupSlots(api) + const next: RuntimeState = { + directory: cwd, + api, + slots, + plugins: [], + plugins_by_id: new Map(), + pending: new Map(), + } + runtime = next + try { + await Instance.provide({ + directory: cwd, + fn: async () => { + const records = Flag.KILO_PURE ? [] : (config.plugin_origins ?? []) + if (Flag.KILO_PURE && config.plugin_origins?.length) { + log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length }) + } + + for (const item of INTERNAL_TUI_PLUGINS) { + log.info("loading internal tui plugin", { id: item.id }) + const entry = loadInternalPlugin(item) + const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id) + addPluginEntry(next, { + id: entry.id, + load: entry, + meta, + themes: {}, + plugin: entry.module.tui, + enabled: true, + }) + } + + const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies()) + await addExternalPluginEntries(next, ready) + + applyInitialPluginEnabledState(next, config) + for (const plugin of next.plugins) { + if (!plugin.enabled) continue + // Keep plugin execution sequential for deterministic side effects: + // command registration order affects keybind/command precedence, + // route registration is last-wins when ids collide, + // and hook chains rely on stable plugin ordering. + await activatePluginEntry(next, plugin, false) + } + }, + }) + } catch (error) { + fail("failed to load tui plugins", { directory: cwd, error }) + } +} + +export * as TuiPluginRuntime from "./runtime" diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 1cce7fb396..2f0ff07e9a 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -10,7 +10,6 @@ import { usePromptRef } from "../context/prompt" import { useLocal } from "../context/local" import { TuiPluginRuntime } from "../plugin" -// TODO: what is the best way to do this? let once = false const placeholder = { normal: ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"], @@ -31,8 +30,8 @@ export function Home() { setRef(r) promptRef.set(r) if (once || !r) return - if (route.initialPrompt) { - r.set(route.initialPrompt) + if (route.prompt) { + r.set(route.prompt) once = true return } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx index 04c2655066..0a5435f797 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx @@ -38,7 +38,7 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess messageID: message.id, }) const parts = sync.data.part[message.id] ?? [] - const initialPrompt = parts.reduce( + const prompt = parts.reduce( (agg, part) => { if (part.type === "text") { if (!part.synthetic) agg.input += part.text @@ -51,7 +51,7 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess route.navigate({ sessionID: forked.data!.id, type: "session", - initialPrompt, + prompt, }) dialog.clear() }, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx index 412b4d87eb..aeea2f52ad 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx @@ -81,25 +81,23 @@ export function DialogMessage(props: { sessionID: props.sessionID, messageID: props.messageID, }) - const initialPrompt = (() => { - const msg = message() - if (!msg) return undefined - const parts = sync.data.part[msg.id] - return parts.reduce( - (agg, part) => { - if (part.type === "text") { - if (!part.synthetic) agg.input += part.text - } - if (part.type === "file") agg.parts.push(part) - return agg - }, - { input: "", parts: [] as PromptInfo["parts"] }, - ) - })() + const msg = message() + const prompt = msg + ? sync.data.part[msg.id].reduce( + (agg, part) => { + if (part.type === "text") { + if (!part.synthetic) agg.input += part.text + } + if (part.type === "file") agg.parts.push(part) + return agg + }, + { input: "", parts: [] as PromptInfo["parts"] }, + ) + : undefined route.navigate({ sessionID: result.data!.id, type: "session", - initialPrompt, + prompt, }) dialog.clear() }, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 001f66c130..4130793825 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -48,7 +48,6 @@ import { useSDK } from "@tui/context/sdk" import { useCommandDialog } from "@tui/component/dialog-command" import type { DialogContext } from "@tui/ui/dialog" import { useKeybind } from "@tui/context/keybind" -import { parsePatch } from "diff" import { useDialog } from "../../ui/dialog" import { TodoItem } from "../../component/todo-item" import { DialogMessage } from "./dialog-message" @@ -88,6 +87,7 @@ import { getScrollAcceleration } from "../../util/scroll" import { TuiPluginRuntime } from "../../plugin" import { DialogGoUpsell } from "../../component/dialog-go-upsell" import { SessionRetry } from "@/session/retry" +import { getRevertDiffFiles } from "../../util/revert-diff" addDefaultParsers(parsers.parsers) @@ -262,27 +262,32 @@ export function Session() { const sdk = useSDK() createEffect(async () => { - await sdk.client.session - .get({ sessionID: route.sessionID }, { throwOnError: true }) - .then((x) => { - project.workspace.set(x.data?.workspaceID) - }) - .then(() => sync.session.sync(route.sessionID)) - .then(() => { - if (scroll) scroll.scrollBy(100_000) - }) - .catch((e) => { - console.error(e) - toast.show({ - message: `Session not found: ${route.sessionID}`, - variant: "error", - }) - return navigate({ type: "home" }) + const previousWorkspace = project.workspace.current() + const result = await sdk.client.session.get({ sessionID: route.sessionID }, { throwOnError: true }) + if (!result.data) { + toast.show({ + message: `Session not found: ${route.sessionID}`, + variant: "error", }) + navigate({ type: "home" }) + return + } + + if (result.data.workspaceID !== previousWorkspace) { + project.workspace.set(result.data.workspaceID) + + // Sync all the data for this workspace. Note that this + // workspace may not exist anymore which is why this is not + // fatal. If it doesn't we still want to show the session + // (which will be non-interactive) + try { + await sync.bootstrap({ fatal: false }) + } catch (e) {} + } + await sync.session.sync(route.sessionID) + if (scroll) scroll.scrollBy(100_000) }) - // Handle initial prompt from fork - let seeded = false let lastSwitch: string | undefined = undefined event.on("message.part.updated", (evt) => { const part = evt.properties.part @@ -298,14 +303,15 @@ export function Session() { } }) + let seeded = false let scroll: ScrollBoxRenderable let prompt: PromptRef | undefined const bind = (r: PromptRef | undefined) => { prompt = r promptRef.set(r) - if (seeded || !route.initialPrompt || !r) return + if (seeded || !route.prompt || !r) return seeded = true - r.set(route.initialPrompt) + r.set(route.prompt) } const keybind = useKeybind() const dialog = useDialog() @@ -1075,31 +1081,7 @@ export function Session() { const revertInfo = createMemo(() => session()?.revert) const revertMessageID = createMemo(() => revertInfo()?.messageID) - const revertDiffFiles = createMemo(() => { - const diffText = revertInfo()?.diff ?? "" - if (!diffText) return [] - - try { - const patches = parsePatch(diffText) - return patches.map((patch) => { - const filename = patch.newFileName || patch.oldFileName || "unknown" - const cleanFilename = filename.replace(/^[ab]\//, "") - return { - filename: cleanFilename, - additions: patch.hunks.reduce( - (sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length, - 0, - ), - deletions: patch.hunks.reduce( - (sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length, - 0, - ), - } - }) - } catch { - return [] - } - }) + const revertDiffFiles = createMemo(() => getRevertDiffFiles(revertInfo()?.diff ?? "")) const revertRevertedMessages = createMemo(() => { const messageID = revertMessageID() diff --git a/packages/opencode/src/cli/cmd/tui/util/revert-diff.ts b/packages/opencode/src/cli/cmd/tui/util/revert-diff.ts new file mode 100644 index 0000000000..6ee1737f0b --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/revert-diff.ts @@ -0,0 +1,18 @@ +import { parsePatch } from "diff" + +export function getRevertDiffFiles(diffText: string) { + if (!diffText) return [] + + try { + return parsePatch(diffText).map((patch) => { + const filename = [patch.newFileName, patch.oldFileName].find((item) => item && item !== "/dev/null") ?? "unknown" + return { + filename: filename.replace(/^[ab]\//, ""), + additions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length, 0), + deletions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length, 0), + } + }) + } catch { + return [] + } +} diff --git a/packages/opencode/src/cli/cmd/tui/util/signal.ts b/packages/opencode/src/cli/cmd/tui/util/signal.ts index 15b57886d6..1c7cc0008d 100644 --- a/packages/opencode/src/cli/cmd/tui/util/signal.ts +++ b/packages/opencode/src/cli/cmd/tui/util/signal.ts @@ -1,7 +1,38 @@ -import { createSignal, type Accessor } from "solid-js" +import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js" import { debounce, type Scheduled } from "@solid-primitives/scheduled" export function createDebouncedSignal(value: T, ms: number): [Accessor, Scheduled<[value: T]>] { const [get, set] = createSignal(value) return [get, debounce((v: T) => set(() => v), ms)] } + +export function createFadeIn(show: Accessor, enabled: Accessor) { + const [alpha, setAlpha] = createSignal(show() ? 1 : 0) + + createEffect( + on([show, enabled], ([visible, animate], previous) => { + if (!visible) { + setAlpha(0) + return + } + + if (!animate || !previous) { + setAlpha(1) + return + } + + const start = performance.now() + setAlpha(0) + + const timer = setInterval(() => { + const progress = Math.min((performance.now() - start) / 160, 1) + setAlpha(progress * progress * (3 - 2 * progress)) + if (progress >= 1) clearInterval(timer) + }, 16) + + onCleanup(() => clearInterval(timer)) + }), + ) + + return alpha +} diff --git a/packages/opencode/src/cli/effect/runtime.ts b/packages/opencode/src/cli/effect/runtime.ts index 4d85fa55b6..57b9f8ede9 100644 --- a/packages/opencode/src/cli/effect/runtime.ts +++ b/packages/opencode/src/cli/effect/runtime.ts @@ -6,7 +6,7 @@ export const memoMap = Layer.makeMemoMapUnsafe() export function makeRuntime(service: Context.Service, layer: Layer.Layer) { let rt: ManagedRuntime.ManagedRuntime | undefined const getRuntime = () => - (rt ??= ManagedRuntime.make(Layer.merge(layer, Observability.layer) as Layer.Layer, { memoMap })) + (rt ??= ManagedRuntime.make(Layer.provideMerge(layer, Observability.layer) as Layer.Layer, { memoMap })) return { runSync: (fn: (svc: S) => Effect.Effect) => getRuntime().runSync(service.use(fn)), diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index fad247ec1c..29e22125ee 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -28,10 +28,10 @@ export function FormatError(input: unknown) { // ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] } if (NamedError.hasName(input, "ProviderModelNotFoundError")) { const data = (input as ErrorLike).data - const suggestions = data?.suggestions as string[] | undefined + const suggestions: string[] = Array.isArray(data?.suggestions) ? data.suggestions : [] return [ `Model not found: ${data?.providerID}/${data?.modelID}`, - ...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []), + ...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []), `Try: \`kilo models\` to list available models`, // kilocode_change `Or check your config (opencode.json) provider/model names`, ].join("\n") @@ -64,10 +64,10 @@ export function FormatError(input: unknown) { const data = (input as ErrorLike).data const path = data?.path const message = data?.message - const issues = data?.issues as Array<{ message: string; path: string[] }> | undefined + const issues: Array<{ message: string; path: string[] }> = Array.isArray(data?.issues) ? data.issues : [] return [ `Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""), - ...(issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []), + ...issues.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")), ].join("\n") } diff --git a/packages/opencode/src/cli/heap.ts b/packages/opencode/src/cli/heap.ts index 44b4d05506..dcb57f86ee 100644 --- a/packages/opencode/src/cli/heap.ts +++ b/packages/opencode/src/cli/heap.ts @@ -8,52 +8,52 @@ const log = Log.create({ service: "heap" }) const MINUTE = 60_000 const LIMIT = 2 * 1024 * 1024 * 1024 -export namespace Heap { - let timer: Timer | undefined - let lock = false - let armed = true +let timer: Timer | undefined +let lock = false +let armed = true - export function start() { - if (!Flag.KILO_AUTO_HEAP_SNAPSHOT) return - if (timer) return +export function start() { + if (!Flag.KILO_AUTO_HEAP_SNAPSHOT) return + if (timer) return - const run = async () => { - if (lock) return + const run = async () => { + if (lock) return - const stat = process.memoryUsage() - if (stat.rss <= LIMIT) { - armed = true - return - } - if (!armed) return + const stat = process.memoryUsage() + if (stat.rss <= LIMIT) { + armed = true + return + } + if (!armed) return - lock = true - armed = false - const file = path.join( - Global.Path.log, - `heap-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.heapsnapshot`, - ) - log.warn("heap usage exceeded limit", { - rss: stat.rss, - heap: stat.heapUsed, - file, + lock = true + armed = false + const file = path.join( + Global.Path.log, + `heap-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.heapsnapshot`, + ) + log.warn("heap usage exceeded limit", { + rss: stat.rss, + heap: stat.heapUsed, + file, + }) + + await Promise.resolve() + .then(() => writeHeapSnapshot(file)) + .catch((err) => { + log.error("failed to write heap snapshot", { + error: err instanceof Error ? err.message : String(err), + file, + }) }) - await Promise.resolve() - .then(() => writeHeapSnapshot(file)) - .catch((err) => { - log.error("failed to write heap snapshot", { - error: err instanceof Error ? err.message : String(err), - file, - }) - }) - - lock = false - } - - timer = setInterval(() => { - void run() - }, MINUTE) - timer.unref?.() + lock = false } + + timer = setInterval(() => { + void run() + }, MINUTE) + timer.unref?.() } + +export * as Heap from "./heap" diff --git a/packages/opencode/src/cli/logo.ts b/packages/opencode/src/cli/logo.ts index 37a97c2f88..3a73640ae9 100644 --- a/packages/opencode/src/cli/logo.ts +++ b/packages/opencode/src/cli/logo.ts @@ -8,4 +8,10 @@ export const logo = { ], } // kilocode_change end -export const marks = "_^~" + +export const go = { + left: ["", "", "", ""], // kilocode_change + right: ["", "", "", ""], // kilocode_change +} + +export const marks = "_^~," diff --git a/packages/opencode/src/cli/ui.ts b/packages/opencode/src/cli/ui.ts index beeacdc33c..27fb1e7d4c 100644 --- a/packages/opencode/src/cli/ui.ts +++ b/packages/opencode/src/cli/ui.ts @@ -3,135 +3,133 @@ import { EOL } from "os" import { NamedError } from "@opencode-ai/shared/util/error" import { logo as glyphs } from "./logo" -export namespace UI { - const wordmark = [ - // kilocode_change start - `██ ▄█▀ ██ ██ ▄████▄ ▄█████ ██ ██ `, - `████ ██ ██ ██~~██ ██~~~~ ██ ██ `, - `██ ▀█▄ ██ ██████ ▀████▀ ▀█████ ██████ ██ `, - `~~ ~~ ~~ ~~~~~~ ~~~~ ~~~~~ ~~~~~~ ~~ `, - // kilocode_change end - ] - - export const CancelledError = NamedError.create("UICancelledError", z.void()) - - export const Style = { - TEXT_HIGHLIGHT: "\x1b[96m", - TEXT_HIGHLIGHT_BOLD: "\x1b[96m\x1b[1m", - TEXT_DIM: "\x1b[90m", - TEXT_DIM_BOLD: "\x1b[90m\x1b[1m", - TEXT_NORMAL: "\x1b[0m", - TEXT_NORMAL_BOLD: "\x1b[1m", - TEXT_WARNING: "\x1b[93m", - TEXT_WARNING_BOLD: "\x1b[93m\x1b[1m", - TEXT_DANGER: "\x1b[91m", - TEXT_DANGER_BOLD: "\x1b[91m\x1b[1m", - TEXT_SUCCESS: "\x1b[92m", - TEXT_SUCCESS_BOLD: "\x1b[92m\x1b[1m", - TEXT_INFO: "\x1b[94m", - TEXT_INFO_BOLD: "\x1b[94m\x1b[1m", - } - - export function println(...message: string[]) { - print(...message) - process.stderr.write(EOL) - } - - export function print(...message: string[]) { - blank = false - process.stderr.write(message.join(" ")) - } - - let blank = false - export function empty() { - if (blank) return - println("" + Style.TEXT_NORMAL) - blank = true - } - +const wordmark = [ // kilocode_change start - export function logo(pad?: string) { - if (!process.stdout.isTTY && !process.stderr.isTTY) { - const result = [] - for (const row of wordmark) { - if (pad) result.push(pad) - result.push(row) - result.push(EOL) - } - return result.join("").trimEnd() - } + `██ ▄█▀ ██ ██ ▄████▄ ▄█████ ██ ██ `, + `████ ██ ██ ██~~██ ██~~~~ ██ ██ `, + `██ ▀█▄ ██ ██████ ▀████▀ ▀█████ ██████ ██ `, + `~~ ~~ ~~ ~~~~~~ ~~~~ ~~~~~ ~~~~~~ ~~ `, + // kilocode_change end +] - const result: string[] = [] - const reset = "\x1b[0m" - const left = { - fg: "\x1b[90m", - shadow: "\x1b[38;5;235m", - bg: "\x1b[48;5;235m", - } - const right = { - fg: reset, - shadow: "\x1b[38;5;238m", - bg: "\x1b[48;5;238m", - } - const gap = " " - const draw = (line: string, fg: string, shadow: string, bg: string) => { - const parts: string[] = [] - for (const char of line) { - if (char === "_") { - parts.push(bg, " ", reset) - continue - } - if (char === "^") { - parts.push(fg, bg, "▀", reset) - continue - } - if (char === "~") { - parts.push(shadow, "▀", reset) - continue - } - if (char === " ") { - parts.push(" ") - continue - } - parts.push(fg, char, reset) - } - return parts.join("") - } - glyphs.left.forEach((row, index) => { +export const CancelledError = NamedError.create("UICancelledError", z.void()) + +export const Style = { + TEXT_HIGHLIGHT: "\x1b[96m", + TEXT_HIGHLIGHT_BOLD: "\x1b[96m\x1b[1m", + TEXT_DIM: "\x1b[90m", + TEXT_DIM_BOLD: "\x1b[90m\x1b[1m", + TEXT_NORMAL: "\x1b[0m", + TEXT_NORMAL_BOLD: "\x1b[1m", + TEXT_WARNING: "\x1b[93m", + TEXT_WARNING_BOLD: "\x1b[93m\x1b[1m", + TEXT_DANGER: "\x1b[91m", + TEXT_DANGER_BOLD: "\x1b[91m\x1b[1m", + TEXT_SUCCESS: "\x1b[92m", + TEXT_SUCCESS_BOLD: "\x1b[92m\x1b[1m", + TEXT_INFO: "\x1b[94m", + TEXT_INFO_BOLD: "\x1b[94m\x1b[1m", +} + +export function println(...message: string[]) { + print(...message) + process.stderr.write(EOL) +} + +export function print(...message: string[]) { + blank = false + process.stderr.write(message.join(" ")) +} + +let blank = false +export function empty() { + if (blank) return + println("" + Style.TEXT_NORMAL) + blank = true +} + +export function logo(pad?: string) { + if (!process.stdout.isTTY && !process.stderr.isTTY) { + const result = [] + for (const row of wordmark) { if (pad) result.push(pad) - result.push(draw(row, left.fg, left.shadow, left.bg)) - result.push(gap) - const other = glyphs.right[index] ?? "" - result.push(draw(other, right.fg, right.shadow, right.bg)) + result.push(row) result.push(EOL) - }) + } return result.join("").trimEnd() } - // kilocode_change end - export async function input(prompt: string): Promise { - const readline = require("readline") - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) - - return new Promise((resolve) => { - rl.question(prompt, (answer: string) => { - rl.close() - resolve(answer.trim()) - }) - }) + const result: string[] = [] + const reset = "\x1b[0m" + const left = { + fg: "\x1b[90m", + shadow: "\x1b[38;5;235m", + bg: "\x1b[48;5;235m", } - - export function error(message: string) { - if (message.startsWith("Error: ")) { - message = message.slice("Error: ".length) + const right = { + fg: reset, + shadow: "\x1b[38;5;238m", + bg: "\x1b[48;5;238m", + } + const gap = " " + const draw = (line: string, fg: string, shadow: string, bg: string) => { + const parts: string[] = [] + for (const char of line) { + if (char === "_") { + parts.push(bg, " ", reset) + continue + } + if (char === "^") { + parts.push(fg, bg, "▀", reset) + continue + } + if (char === "~") { + parts.push(shadow, "▀", reset) + continue + } + if (char === " ") { + parts.push(" ") + continue + } + parts.push(fg, char, reset) } - println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message) - } - - export function markdown(text: string): string { - return text + return parts.join("") } + glyphs.left.forEach((row, index) => { + if (pad) result.push(pad) + result.push(draw(row, left.fg, left.shadow, left.bg)) + result.push(gap) + const other = glyphs.right[index] ?? "" + result.push(draw(other, right.fg, right.shadow, right.bg)) + result.push(EOL) + }) + return result.join("").trimEnd() } + +export async function input(prompt: string): Promise { + const readline = require("readline") + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + return new Promise((resolve) => { + rl.question(prompt, (answer: string) => { + rl.close() + resolve(answer.trim()) + }) + }) +} + +export function error(message: string) { + if (message.startsWith("Error: ")) { + message = message.slice("Error: ".length) + } + println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message) +} + +export function markdown(text: string): string { + return text +} + +export * as UI from "./ui" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 5c7fc07592..c5ec5ff0ad 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -12,12 +12,11 @@ import { Auth } from "../auth" import { Env } from "../env" import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser" // kilocode_change - parseTree/findNodeAtLocation used in patchJsonc import { Instance, type InstanceContext } from "../project/instance" -import * as LSPServer from "../lsp/server" import { InstallationLocal, InstallationVersion } from "@/installation/version" import { existsSync } from "fs" import { GlobalBus } from "@/bus/global" import { Event } from "../server/event" -import { Account } from "@/account" +import { Account } from "@/account/account" import { isRecord } from "@/util/record" import type { ConsoleState } from "./console-state" import { AppFileSystem } from "@opencode-ai/shared/filesystem" @@ -42,6 +41,9 @@ import { KilocodeConfig } from "../kilocode/config/config" import { makeRuntime } from "@/effect/run-service" import { unique } from "remeda" // kilocode_change end +import { ConfigFormatter } from "./formatter" +import { ConfigLSP } from "./lsp" +import { ConfigVariable } from "./variable" const log = Log.create({ service: "config" }) @@ -220,56 +222,8 @@ export const Info = z ) .optional() .describe("MCP (Model Context Protocol) server configurations"), - formatter: z - .union([ - z.literal(false), - z.record( - z.string(), - z.object({ - disabled: z.boolean().optional(), - command: z.array(z.string()).optional(), - environment: z.record(z.string(), z.string()).optional(), - extensions: z.array(z.string()).optional(), - }), - ), - ]) - .optional(), - lsp: z - .union([ - z.literal(false), - z.record( - z.string(), - z.union([ - z.object({ - disabled: z.literal(true), - }), - z.object({ - command: z.array(z.string()), - extensions: z.array(z.string()).optional(), - disabled: z.boolean().optional(), - env: z.record(z.string(), z.string()).optional(), - initialization: z.record(z.string(), z.any()).optional(), - }), - ]), - ), - ]) - .optional() - .refine( - (data) => { - if (!data) return true - if (typeof data === "boolean") return true - const serverIds = new Set(Object.values(LSPServer).map((s) => s.id)) - - return Object.entries(data).every(([id, config]) => { - if (config.disabled) return true - if (serverIds.has(id)) return true - return Boolean(config.extensions) - }) - }, - { - error: "For custom LSP servers, 'extensions' array is required.", - }, - ), + formatter: ConfigFormatter.Info.optional(), + lsp: ConfigLSP.Info.optional(), instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"), layout: Layout.optional().describe("@deprecated Always uses stretch layout."), permission: ConfigPermission.Info.optional(), @@ -434,24 +388,16 @@ export const layer = Layer.effect( text: string, options: { path: string } | { dir: string; source: string }, ) { - if (!("path" in options)) { - return yield* Effect.promise(() => - ConfigParse.load(Info, text, { - type: "virtual", - dir: options.dir, - source: options.source, - normalize: normalizeLoadedConfig, - }), - ) - } - - const data = yield* Effect.promise(() => - ConfigParse.load(Info, text, { - type: "path", - path: options.path, - normalize: normalizeLoadedConfig, - }), + const source = "path" in options ? options.path : options.source + const expanded = yield* Effect.promise(() => + ConfigVariable.substitute( + "path" in options ? { text, type: "path", path: options.path } : { text, type: "virtual", ...options }, + ), ) + const parsed = ConfigParse.jsonc(expanded, source) + const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source) + if (!("path" in options)) return data + yield* Effect.promise(() => resolveLoadedPlugins(data, options.path)) if (!data.$schema) { data.$schema = "https://app.kilo.ai/config.json" // kilocode_change @@ -542,352 +488,359 @@ export const layer = Layer.effect( } }) - const loadInstanceState = Effect.fn("Config.loadInstanceState")(function* (ctx: InstanceContext) { - // kilocode_change start - warning accumulator and legacy Kilo config - const warnings: Warning[] = [] - const auth = yield* authSvc.all().pipe(Effect.orDie) + const loadInstanceState = Effect.fn("Config.loadInstanceState")( + function* (ctx: InstanceContext) { + // kilocode_change start - warning accumulator and legacy Kilo config + const warnings: Warning[] = [] + const auth = yield* authSvc.all().pipe(Effect.orDie) - let result: Info = {} - const legacy = yield* Effect.promise(() => - KilocodeConfig.loadLegacyConfigs({ - projectDir: ctx.directory, - merge: mergeConfigConcatArrays, - }), - ) - result = mergeConfigConcatArrays(result, legacy.config) - warnings.push(...legacy.warnings) + let result: Info = {} + const legacy = yield* Effect.promise(() => + KilocodeConfig.loadLegacyConfigs({ + projectDir: ctx.directory, + merge: mergeConfigConcatArrays, + }), + ) + result = mergeConfigConcatArrays(result, legacy.config) + warnings.push(...legacy.warnings) - const orgModes = yield* Effect.promise(() => KilocodeConfig.loadOrganizationModes(auth)) - if (Object.keys(orgModes.agents).length > 0) { - result = mergeConfigConcatArrays(result, { agent: orgModes.agents }) - } - warnings.push(...orgModes.warnings) - // kilocode_change end + const orgModes = yield* Effect.promise(() => KilocodeConfig.loadOrganizationModes(auth)) + if (Object.keys(orgModes.agents).length > 0) { + result = mergeConfigConcatArrays(result, { agent: orgModes.agents }) + } + warnings.push(...orgModes.warnings) + // kilocode_change end - const consoleManagedProviders = new Set() - let activeOrgName: string | undefined + const consoleManagedProviders = new Set() + let activeOrgName: string | undefined - const pluginScopeForSource = Effect.fnUntraced(function* (source: string) { - if (source.startsWith("http://") || source.startsWith("https://")) return "global" - if (source === "KILO_CONFIG_CONTENT") return "local" - if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local" - return "global" - }) + const pluginScopeForSource = Effect.fnUntraced(function* (source: string) { + if (source.startsWith("http://") || source.startsWith("https://")) return "global" + if (source === "KILO_CONFIG_CONTENT") return "local" + if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local" + return "global" + }) - const mergePluginOrigins = Effect.fnUntraced(function* ( - source: string, - // mergePluginOrigins receives raw Specs from one config source, before provenance for this merge step - // is attached. - list: ConfigPlugin.Spec[] | undefined, - // Scope can be inferred from the source path, but some callers already know whether the config should - // behave as global or local and can pass that explicitly. - kind?: ConfigPlugin.Scope, - ) { - if (!list?.length) return - const hit = kind ?? (yield* pluginScopeForSource(source)) - // Merge newly seen plugin origins with previously collected ones, then dedupe by plugin identity while - // keeping the winning source/scope metadata for downstream installs, writes, and diagnostics. - const plugins = ConfigPlugin.deduplicatePluginOrigins([ - ...(result.plugin_origins ?? []), - ...list.map((spec) => ({ spec, source, scope: hit })), - ]) - result.plugin = plugins.map((item) => item.spec) - result.plugin_origins = plugins - }) + const mergePluginOrigins = Effect.fnUntraced(function* ( + source: string, + // mergePluginOrigins receives raw Specs from one config source, before provenance for this merge step + // is attached. + list: ConfigPlugin.Spec[] | undefined, + // Scope can be inferred from the source path, but some callers already know whether the config should + // behave as global or local and can pass that explicitly. + kind?: ConfigPlugin.Scope, + ) { + if (!list?.length) return + const hit = kind ?? (yield* pluginScopeForSource(source)) + // Merge newly seen plugin origins with previously collected ones, then dedupe by plugin identity while + // keeping the winning source/scope metadata for downstream installs, writes, and diagnostics. + const plugins = ConfigPlugin.deduplicatePluginOrigins([ + ...(result.plugin_origins ?? []), + ...list.map((spec) => ({ spec, source, scope: hit })), + ]) + result.plugin = plugins.map((item) => item.spec) + result.plugin_origins = plugins + }) - const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => { - result = mergeConfigConcatArrays(result, next) - return mergePluginOrigins(source, next.plugin, kind) - } + const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => { + result = mergeConfigConcatArrays(result, next) + return mergePluginOrigins(source, next.plugin, kind) + } - for (const [key, value] of Object.entries(auth)) { - if (value.type === "wellknown") { - const url = key.replace(/\/+$/, "") - const source = `${url}/.well-known/opencode` - process.env[value.key] = value.token - log.debug("fetching remote config", { url: source }) - // kilocode_change start - warn instead of fail on wellknown errors - const next = yield* Effect.tryPromise({ - try: async () => { - const response = await fetch(source) - if (!response.ok) { - throw new Error(`failed to fetch remote config from ${url}: ${response.status}`) - } - const wellknown = (await response.json()) as { config?: Record } - const remoteConfig = wellknown.config ?? {} - if (!remoteConfig.$schema) remoteConfig.$schema = "https://app.kilo.ai/config.json" - return remoteConfig - }, - catch: (err) => err, - }).pipe( - Effect.flatMap((remoteConfig) => - loadConfig(JSON.stringify(remoteConfig), { - dir: path.dirname(source), - source, + for (const [key, value] of Object.entries(auth)) { + if (value.type === "wellknown") { + const url = key.replace(/\/+$/, "") + const source = `${url}/.well-known/opencode` + process.env[value.key] = value.token + log.debug("fetching remote config", { url: source }) + // kilocode_change start - warn instead of fail on wellknown errors + const next = yield* Effect.tryPromise({ + try: async () => { + const response = await fetch(source) + if (!response.ok) { + throw new Error(`failed to fetch remote config from ${url}: ${response.status}`) + } + const wellknown = (await response.json()) as { config?: Record } + const remoteConfig = wellknown.config ?? {} + if (!remoteConfig.$schema) remoteConfig.$schema = "https://app.kilo.ai/config.json" + return remoteConfig + }, + catch: (err) => err, + }).pipe( + Effect.flatMap((remoteConfig) => + loadConfig(JSON.stringify(remoteConfig), { + dir: path.dirname(source), + source, + }), + ), + Effect.tap(() => Effect.sync(() => log.debug("loaded remote config from well-known", { url }))), + Effect.catch((err: unknown) => { + caughtWarning(warnings, source, err) + log.warn("skipped remote config due to error", { url, err }) + return Effect.succeed({} as Info) + }), + Effect.catchDefect((err: unknown) => { + caughtWarning(warnings, source, err) + log.warn("skipped remote config due to error", { url, err }) + return Effect.succeed({} as Info) + }), + ) + yield* merge(source, next, "global") + // kilocode_change end + } + } + + // kilocode_change start - capture global config failures as warnings + const global = yield* getGlobal().pipe( + Effect.catchDefect((err: unknown) => { + caughtWarning(warnings, "global config", err) + return Effect.succeed({} as Info) + }), + ) + // kilocode_change end + + yield* merge(Global.Path.config, global, "global") + + if (Flag.KILO_CONFIG) { + // kilocode_change start - capture KILO_CONFIG failures as warnings + yield* merge( + Flag.KILO_CONFIG, + yield* loadFile(Flag.KILO_CONFIG).pipe( + Effect.catchDefect((err: unknown) => { + caughtWarning(warnings, Flag.KILO_CONFIG!, err) + return Effect.succeed({} as Info) }), ), - Effect.tap(() => Effect.sync(() => log.debug("loaded remote config from well-known", { url }))), - Effect.catch((err: unknown) => { - caughtWarning(warnings, source, err) - log.warn("skipped remote config due to error", { url, err }) - return Effect.succeed({} as Info) - }), - Effect.catchDefect((err: unknown) => { - caughtWarning(warnings, source, err) - log.warn("skipped remote config due to error", { url, err }) - return Effect.succeed({} as Info) - }), ) - yield* merge(source, next, "global") + // kilocode_change end + log.debug("loaded custom config", { path: Flag.KILO_CONFIG }) + } + + if (!Flag.KILO_DISABLE_PROJECT_CONFIG) { + // kilocode_change start - also discover kilo.json project files + for (const name of ["kilo", "opencode"] as const) { + for (const file of yield* ConfigPaths.files(name, ctx.directory, ctx.worktree).pipe(Effect.orDie)) { + yield* merge( + file, + yield* loadFile(file).pipe( + Effect.catchDefect((err: unknown) => { + caughtWarning(warnings, file, err) + return Effect.succeed({} as Info) + }), + ), + "local", + ) + } + } // kilocode_change end } - } - const global = yield* getGlobal().pipe( - // kilocode_change start - capture global config failures as warnings - Effect.catchDefect((err: unknown) => { - caughtWarning(warnings, "global config", err) - return Effect.succeed({} as Info) - }), - // kilocode_change end - ) - yield* merge(Global.Path.config, global, "global") + result.agent = result.agent || {} + result.mode = result.mode || {} + result.plugin = result.plugin || [] - if (Flag.KILO_CONFIG) { - // kilocode_change start - capture KILO_CONFIG failures as warnings - yield* merge( - Flag.KILO_CONFIG, - yield* loadFile(Flag.KILO_CONFIG).pipe( - Effect.catchDefect((err: unknown) => { - caughtWarning(warnings, Flag.KILO_CONFIG!, err) - return Effect.succeed({} as Info) - }), - ), - ) - // kilocode_change end - log.debug("loaded custom config", { path: Flag.KILO_CONFIG }) - } + const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree) - if (!Flag.KILO_DISABLE_PROJECT_CONFIG) { - // kilocode_change start - also discover kilo.json project files - for (const name of ["kilo", "opencode"] as const) { - for (const file of yield* Effect.promise(() => ConfigPaths.projectFiles(name, ctx.directory, ctx.worktree))) { - yield* merge( - file, - yield* loadFile(file).pipe( - Effect.catchDefect((err: unknown) => { - caughtWarning(warnings, file, err) - return Effect.succeed({} as Info) - }), - ), - "local", - ) - } + if (Flag.KILO_CONFIG_DIR) { + log.debug("loading config from KILO_CONFIG_DIR", { path: Flag.KILO_CONFIG_DIR }) } - // kilocode_change end - } - result.agent = result.agent || {} - result.mode = result.mode || {} - result.plugin = result.plugin || [] + const deps: Fiber.Fiber[] = [] - const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree)) - - if (Flag.KILO_CONFIG_DIR) { - log.debug("loading config from KILO_CONFIG_DIR", { path: Flag.KILO_CONFIG_DIR }) - } - - const deps: Fiber.Fiber[] = [] - - for (const dir of unique(directories)) { - // kilocode_change start - accept .kilo/.kilocode config dirs and all kilo/opencode filenames - if (KilocodeConfig.isConfigDir(dir, Flag.KILO_CONFIG_DIR)) { - for (const file of KilocodeConfig.ALL_CONFIG_FILES) { - const source = path.join(dir, file) - log.debug(`loading config from ${source}`) - yield* merge( - source, - yield* loadFile(source).pipe( - Effect.catchDefect((err: unknown) => { - caughtWarning(warnings, source, err) - return Effect.succeed({} as Info) - }), - ), - ) - result.agent ??= {} - result.mode ??= {} - result.plugin ??= [] - } - } - // kilocode_change end - - yield* ensureGitignore(dir).pipe(Effect.orDie) - - const dep = yield* npmSvc - .install(dir, { - add: ["@kilocode/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)], - }) - .pipe( - Effect.exit, - Effect.tap((exit) => - Exit.isFailure(exit) - ? Effect.sync(() => { - log.warn("background dependency install failed", { dir, error: String(exit.cause) }) - }) - : Effect.void, - ), - Effect.asVoid, - Effect.forkDetach, - ) - deps.push(dep) - - // kilocode_change start - propagate parse errors to the Warning accumulator - result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir, warnings))) - result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir, warnings))) - result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir, warnings))) - // kilocode_change end - // Auto-discovered plugins under `.opencode/plugin(s)` are already local files, so ConfigPlugin.load - // returns normalized Specs and we only need to attach origin metadata here. - const list = yield* Effect.promise(() => ConfigPlugin.load(dir)) - yield* mergePluginOrigins(dir, list) - } - - if (process.env.KILO_CONFIG_CONTENT) { - // kilocode_change start - capture KILO_CONFIG_CONTENT parse failures as warnings - const source = "KILO_CONFIG_CONTENT" - yield* merge( - source, - yield* loadConfig(process.env.KILO_CONFIG_CONTENT, { - dir: ctx.directory, - source, - }).pipe( - Effect.tap(() => Effect.sync(() => log.debug("loaded custom config from KILO_CONFIG_CONTENT"))), - Effect.catchDefect((err: unknown) => { - caughtWarning(warnings, source, err) - return Effect.succeed({} as Info) - }), - ), - "local", - ) - // kilocode_change end - } - - const activeAccount = Option.getOrUndefined( - yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))), - ) - if (activeAccount?.active_org_id) { - const accountID = activeAccount.id - const orgID = activeAccount.active_org_id - const url = activeAccount.url - yield* Effect.gen(function* () { - const [configOpt, tokenOpt] = yield* Effect.all( - [accountSvc.config(accountID, orgID), accountSvc.token(accountID)], - { concurrency: 2 }, - ) - if (Option.isSome(tokenOpt)) { - process.env["KILO_CONSOLE_TOKEN"] = tokenOpt.value - yield* env.set("KILO_CONSOLE_TOKEN", tokenOpt.value) - } - - if (Option.isSome(configOpt)) { - const source = `${url}/api/config` - const next = yield* loadConfig(JSON.stringify(configOpt.value), { - dir: path.dirname(source), - source, - }) - for (const providerID of Object.keys(next.provider ?? {})) { - consoleManagedProviders.add(providerID) + for (const dir of unique(directories)) { // kilocode_change + // kilocode_change start + if (KilocodeConfig.isConfigDir(dir, Flag.KILO_CONFIG_DIR)) { + for (const file of KilocodeConfig.ALL_CONFIG_FILES) { + const source = path.join(dir, file) + log.debug(`loading config from ${source}`) + yield* merge( + source, + yield* loadFile(source).pipe( + Effect.catchDefect((err: unknown) => { + caughtWarning(warnings, source, err) + return Effect.succeed({} as Info) + }), + ), + ) + result.agent ??= {} + result.mode ??= {} + result.plugin ??= [] } - yield* merge(source, next, "global") } - }).pipe( - Effect.withSpan("Config.loadActiveOrgConfig"), - Effect.catch((err) => { - log.debug("failed to fetch remote account config", { - error: err instanceof Error ? err.message : String(err), + // kilocode_change end + + yield* ensureGitignore(dir).pipe(Effect.orDie) + + const dep = yield* npmSvc + .install(dir, { + add: ["@kilocode/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)], }) - return Effect.void - }), - ) - } + .pipe( + Effect.exit, + Effect.tap((exit) => + Exit.isFailure(exit) + ? Effect.sync(() => { + log.warn("background dependency install failed", { dir, error: String(exit.cause) }) + }) + : Effect.void, + ), + Effect.asVoid, + Effect.forkDetach, + ) + deps.push(dep) - const managedDir = ConfigManaged.managedConfigDir() - // kilocode_change start - include kilo.json/kilo.jsonc in managed dir loading - if (existsSync(managedDir)) { - for (const file of KilocodeConfig.ALL_CONFIG_FILES) { - const source = path.join(managedDir, file) - yield* merge(source, yield* loadFile(source), "global") + // kilocode_change start - propagate parse errors to the Warning accumulator + result.command = mergeDeep( + result.command ?? {}, + yield* Effect.promise(() => ConfigCommand.load(dir, warnings)), + ) + result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir, warnings))) + result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir, warnings))) + // kilocode_change end + // Auto-discovered plugins under `.opencode/plugin(s)` are already local files, so ConfigPlugin.load + // returns normalized Specs and we only need to attach origin metadata here. + const list = yield* Effect.promise(() => ConfigPlugin.load(dir)) + yield* mergePluginOrigins(dir, list) } - } - // kilocode_change end - // macOS managed preferences (.mobileconfig deployed via MDM) override everything - const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences()) - if (managed) { - result = mergeConfigConcatArrays( - result, - yield* loadConfig(managed.text, { - dir: path.dirname(managed.source), - source: managed.source, - }), + if (process.env.KILO_CONFIG_CONTENT) { + // kilocode_change start - capture KILO_CONFIG_CONTENT parse failures as warnings + const source = "KILO_CONFIG_CONTENT" + yield* merge( + source, + yield* loadConfig(process.env.KILO_CONFIG_CONTENT, { + dir: ctx.directory, + source, + }).pipe( + Effect.tap(() => Effect.sync(() => log.debug("loaded custom config from KILO_CONFIG_CONTENT"))), + Effect.catchDefect((err: unknown) => { + caughtWarning(warnings, source, err) + return Effect.succeed({} as Info) + }), + ), + "local", + ) + // kilocode_change end + } + + const activeAccount = Option.getOrUndefined( + yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))), ) - } + if (activeAccount?.active_org_id) { + const accountID = activeAccount.id + const orgID = activeAccount.active_org_id + const url = activeAccount.url + yield* Effect.gen(function* () { + const [configOpt, tokenOpt] = yield* Effect.all( + [accountSvc.config(accountID, orgID), accountSvc.token(accountID)], + { concurrency: 2 }, + ) + if (Option.isSome(tokenOpt)) { + process.env["KILO_CONSOLE_TOKEN"] = tokenOpt.value + yield* env.set("KILO_CONSOLE_TOKEN", tokenOpt.value) + } - for (const [name, mode] of Object.entries(result.mode ?? {})) { - result.agent = mergeDeep(result.agent ?? {}, { - [name]: { - ...mode, - mode: "primary" as const, - }, - }) - } + if (Option.isSome(configOpt)) { + const source = `${url}/api/config` + const next = yield* loadConfig(JSON.stringify(configOpt.value), { + dir: path.dirname(source), + source, + }) + for (const providerID of Object.keys(next.provider ?? {})) { + consoleManagedProviders.add(providerID) + } + yield* merge(source, next, "global") + } + }).pipe( + Effect.withSpan("Config.loadActiveOrgConfig"), + Effect.catch((err) => { + log.debug("failed to fetch remote account config", { + error: err instanceof Error ? err.message : String(err), + }) + return Effect.void + }), + ) + } - if (Flag.KILO_PERMISSION) { - result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.KILO_PERMISSION)) - } - - if (result.tools) { - const perms: Record = {} - for (const [tool, enabled] of Object.entries(result.tools)) { - const action: ConfigPermission.Action = enabled ? "allow" : "deny" - if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { - perms.edit = action - continue + const managedDir = ConfigManaged.managedConfigDir() + // kilocode_change start - include kilo.json/kilo.jsonc in managed dir loading + if (existsSync(managedDir)) { + for (const file of KilocodeConfig.ALL_CONFIG_FILES) { + const source = path.join(managedDir, file) + yield* merge(source, yield* loadFile(source), "global") } - perms[tool] = action } - result.permission = mergeDeep(perms, result.permission ?? {}) - } + // kilocode_change end - if (!result.username) result.username = os.userInfo().username + // macOS managed preferences (.mobileconfig deployed via MDM) override everything + const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences()) + if (managed) { + result = mergeConfigConcatArrays( + result, + yield* loadConfig(managed.text, { + dir: path.dirname(managed.source), + source: managed.source, + }), + ) + } - if (result.autoshare === true && !result.share) { - result.share = "auto" - } + for (const [name, mode] of Object.entries(result.mode ?? {})) { + result.agent = mergeDeep(result.agent ?? {}, { + [name]: { + ...mode, + mode: "primary" as const, + }, + }) + } - if (Flag.KILO_DISABLE_AUTOCOMPACT) { - result.compaction = { ...result.compaction, auto: false } - } - if (Flag.KILO_DISABLE_PRUNE) { - result.compaction = { ...result.compaction, prune: false } - } + if (Flag.KILO_PERMISSION) { + result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.KILO_PERMISSION)) + } - return { - config: result, - directories, - deps, - warnings, // kilocode_change - consoleState: { - consoleManagedProviders: Array.from(consoleManagedProviders), - activeOrgName, - switchableOrgCount: 0, - }, - } - }) + if (result.tools) { + const perms: Record = {} + for (const [tool, enabled] of Object.entries(result.tools)) { + const action: ConfigPermission.Action = enabled ? "allow" : "deny" + if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { + perms.edit = action + continue + } + perms[tool] = action + } + result.permission = mergeDeep(perms, result.permission ?? {}) + } + + if (!result.username) result.username = os.userInfo().username + + if (result.autoshare === true && !result.share) { + result.share = "auto" + } + + if (Flag.KILO_DISABLE_AUTOCOMPACT) { + result.compaction = { ...result.compaction, auto: false } + } + if (Flag.KILO_DISABLE_PRUNE) { + result.compaction = { ...result.compaction, prune: false } + } + + return { + config: result, + directories, + deps, + warnings, // kilocode_change + consoleState: { + consoleManagedProviders: Array.from(consoleManagedProviders), + activeOrgName, + switchableOrgCount: 0, + }, + } + }, + Effect.provideService(AppFileSystem.Service, fs), + ) const state = yield* InstanceState.make( Effect.fn("Config.state")(function* (ctx) { - return yield* loadInstanceState(ctx) + return yield* loadInstanceState(ctx).pipe(Effect.orDie) }), ) @@ -951,17 +904,16 @@ export const layer = Layer.effect( // kilocode_change end const file = globalConfigFile() const before = (yield* readConfigFile(file)) ?? "{}" - const input = writable(config) let next: Info if (!file.endsWith(".jsonc")) { - const existing = ConfigParse.parse(Info, before, file) - const merged = KilocodeConfig.mergeConfig(writable(existing), input) // kilocode_change + const existing = ConfigParse.schema(Info, ConfigParse.jsonc(before, file), file) + const merged = KilocodeConfig.mergeConfig(writable(existing), writable(config)) // kilocode_change yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie) next = merged } else { - const updated = patchJsonc(before, input) - next = ConfigParse.parse(Info, updated, file) + const updated = patchJsonc(before, writable(config)) + next = ConfigParse.schema(Info, ConfigParse.jsonc(updated, file), file) yield* fs.writeFileString(file, updated).pipe(Effect.orDie) } diff --git a/packages/opencode/src/config/formatter.ts b/packages/opencode/src/config/formatter.ts new file mode 100644 index 0000000000..93b87f0281 --- /dev/null +++ b/packages/opencode/src/config/formatter.ts @@ -0,0 +1,13 @@ +export * as ConfigFormatter from "./formatter" + +import z from "zod" + +export const Entry = z.object({ + disabled: z.boolean().optional(), + command: z.array(z.string()).optional(), + environment: z.record(z.string(), z.string()).optional(), + extensions: z.array(z.string()).optional(), +}) + +export const Info = z.union([z.boolean(), z.record(z.string(), Entry)]) +export type Info = z.infer diff --git a/packages/opencode/src/config/index.ts b/packages/opencode/src/config/index.ts index c4a1c608b1..a05c29d25c 100644 --- a/packages/opencode/src/config/index.ts +++ b/packages/opencode/src/config/index.ts @@ -2,6 +2,8 @@ export * as Config from "./config" export * as ConfigAgent from "./agent" export * as ConfigCommand from "./command" export * as ConfigError from "./error" +export * as ConfigFormatter from "./formatter" +export * as ConfigLSP from "./lsp" export * as ConfigVariable from "./variable" export { ConfigManaged } from "./managed" export * as ConfigMarkdown from "./markdown" diff --git a/packages/opencode/src/config/keybinds.ts b/packages/opencode/src/config/keybinds.ts index 952a66fc45..7739d7d417 100644 --- a/packages/opencode/src/config/keybinds.ts +++ b/packages/opencode/src/config/keybinds.ts @@ -106,7 +106,12 @@ export const Keybinds = z input_delete_to_line_start: z.string().optional().default("ctrl+u").describe("Delete to start of line in input"), input_backspace: z.string().optional().default("backspace,shift+backspace").describe("Backspace in input"), input_delete: z.string().optional().default("ctrl+d,delete,shift+delete").describe("Delete character in input"), - input_undo: z.string().optional().default("ctrl+-,super+z").describe("Undo in input"), + input_undo: z + .string() + .optional() + // On Windows prepend ctrl+z since terminal_suspend releases the binding. + .default(process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z") + .describe("Undo in input"), input_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"), input_word_forward: z .string() @@ -144,7 +149,12 @@ export const Keybinds = z session_child_cycle: z.string().optional().default("right").describe("Go to next child session"), session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"), session_parent: z.string().optional().default("up").describe("Go to parent session"), - terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"), + terminal_suspend: z + .string() + .optional() + .default("ctrl+z") + .transform((v) => (process.platform === "win32" ? "none" : v)) + .describe("Suspend terminal"), terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"), tips_toggle: z.string().optional().default("h").describe("Toggle tips on home screen"), news_toggle: z.string().optional().default("none").describe("Toggle news on home screen"), // kilocode_change diff --git a/packages/opencode/src/config/lsp.ts b/packages/opencode/src/config/lsp.ts new file mode 100644 index 0000000000..5530a5be56 --- /dev/null +++ b/packages/opencode/src/config/lsp.ts @@ -0,0 +1,37 @@ +export * as ConfigLSP from "./lsp" + +import z from "zod" +import * as LSPServer from "../lsp/server" + +export const Disabled = z.object({ + disabled: z.literal(true), +}) + +export const Entry = z.union([ + Disabled, + z.object({ + command: z.array(z.string()), + extensions: z.array(z.string()).optional(), + disabled: z.boolean().optional(), + env: z.record(z.string(), z.string()).optional(), + initialization: z.record(z.string(), z.any()).optional(), + }), +]) + +export const Info = z.union([z.boolean(), z.record(z.string(), Entry)]).refine( + (data) => { + if (typeof data === "boolean") return true + const serverIds = new Set(Object.values(LSPServer).map((server) => server.id)) + + return Object.entries(data).every(([id, config]) => { + if (config.disabled) return true + if (serverIds.has(id)) return true + return Boolean(config.extensions) + }) + }, + { + error: "For custom LSP servers, 'extensions' array is required.", + }, +) + +export type Info = z.infer diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts index fb8f8caa41..5036cd6e4f 100644 --- a/packages/opencode/src/config/mcp.ts +++ b/packages/opencode/src/config/mcp.ts @@ -1,70 +1,68 @@ import z from "zod" -export namespace ConfigMCP { - export const Local = z - .object({ - type: z.literal("local").describe("Type of MCP server connection"), - command: z.string().array().describe("Command and arguments to run the MCP server"), - environment: z - .record(z.string(), z.string()) - .optional() - .describe("Environment variables to set when running the MCP server"), - enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"), - timeout: z - .number() - .int() - .positive() - .optional() - .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), - }) - .strict() - .meta({ - ref: "McpLocalConfig", - }) +export const Local = z + .object({ + type: z.literal("local").describe("Type of MCP server connection"), + command: z.string().array().describe("Command and arguments to run the MCP server"), + environment: z + .record(z.string(), z.string()) + .optional() + .describe("Environment variables to set when running the MCP server"), + enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"), + timeout: z + .number() + .int() + .positive() + .optional() + .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), + }) + .strict() + .meta({ + ref: "McpLocalConfig", + }) - export const OAuth = z - .object({ - clientId: z - .string() - .optional() - .describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."), - clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"), - scope: z.string().optional().describe("OAuth scopes to request during authorization"), - redirectUri: z - .string() - .optional() - .describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."), - }) - .strict() - .meta({ - ref: "McpOAuthConfig", - }) - export type OAuth = z.infer +export const OAuth = z + .object({ + clientId: z + .string() + .optional() + .describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."), + clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"), + scope: z.string().optional().describe("OAuth scopes to request during authorization"), + redirectUri: z + .string() + .optional() + .describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."), + }) + .strict() + .meta({ + ref: "McpOAuthConfig", + }) +export type OAuth = z.infer - export const Remote = z - .object({ - type: z.literal("remote").describe("Type of MCP server connection"), - url: z.string().describe("URL of the remote MCP server"), - enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"), - headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"), - oauth: z - .union([OAuth, z.literal(false)]) - .optional() - .describe( - "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.", - ), - timeout: z - .number() - .int() - .positive() - .optional() - .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), - }) - .strict() - .meta({ - ref: "McpRemoteConfig", - }) +export const Remote = z + .object({ + type: z.literal("remote").describe("Type of MCP server connection"), + url: z.string().describe("URL of the remote MCP server"), + enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"), + headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"), + oauth: z + .union([OAuth, z.literal(false)]) + .optional() + .describe("OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection."), + timeout: z + .number() + .int() + .positive() + .optional() + .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), + }) + .strict() + .meta({ + ref: "McpRemoteConfig", + }) - export const Info = z.discriminatedUnion("type", [Local, Remote]) - export type Info = z.infer -} +export const Info = z.discriminatedUnion("type", [Local, Remote]) +export type Info = z.infer + +export * as ConfigMCP from "./mcp" diff --git a/packages/opencode/src/config/parse.ts b/packages/opencode/src/config/parse.ts index 65cc483859..7472029ead 100644 --- a/packages/opencode/src/config/parse.ts +++ b/packages/opencode/src/config/parse.ts @@ -1,80 +1,44 @@ export * as ConfigParse from "./parse" -import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser" +import { type ParseError as JsoncParseError, parse as parseJsoncImpl, printParseErrorCode } from "jsonc-parser" import z from "zod" -import { ConfigVariable } from "./variable" import { InvalidError, JsonError } from "./error" type Schema = z.ZodType -type VariableMode = "error" | "empty" -export type LoadOptions = - | { - type: "path" - path: string - missing?: VariableMode - normalize?: (data: unknown, source: string) => unknown - } - | { - type: "virtual" - dir: string - source: string - missing?: VariableMode - normalize?: (data: unknown, source: string) => unknown - } - -function issues(text: string, errors: JsoncParseError[]) { - const lines = text.split("\n") - return errors - .map((e) => { - const beforeOffset = text.substring(0, e.offset).split("\n") - const line = beforeOffset.length - const column = beforeOffset[beforeOffset.length - 1].length + 1 - const problemLine = lines[line - 1] - - const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}` - if (!problemLine) return error - - return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^` - }) - .join("\n") -} - -export function parse(schema: Schema, text: string, filepath: string): T { +export function jsonc(text: string, filepath: string): unknown { const errors: JsoncParseError[] = [] - const data = parseJsonc(text, errors, { allowTrailingComma: true }) + const data = parseJsoncImpl(text, errors, { allowTrailingComma: true }) if (errors.length) { + const lines = text.split("\n") + const issues = errors + .map((e) => { + const beforeOffset = text.substring(0, e.offset).split("\n") + const line = beforeOffset.length + const column = beforeOffset[beforeOffset.length - 1].length + 1 + const problemLine = lines[line - 1] + + const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}` + if (!problemLine) return error + + return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^` + }) + .join("\n") throw new JsonError({ path: filepath, - message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${issues(text, errors)}\n--- End ---`, + message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${issues}\n--- End ---`, }) } + return data +} + +export function schema(schema: Schema, data: unknown, source: string): T { const parsed = schema.safeParse(data) if (parsed.success) return parsed.data throw new InvalidError({ - path: filepath, + path: source, issues: parsed.error.issues, }) } - -export async function load(schema: Schema, text: string, options: LoadOptions): Promise { - const source = options.type === "path" ? options.path : options.source - const expanded = await ConfigVariable.substitute( - text, - options.type === "path" ? { type: "path", path: options.path } : options, - options.missing, - ) - const data = parse(z.unknown(), expanded, source) - const normalized = options.normalize ? options.normalize(data, source) : data - const parsed = schema.safeParse(normalized) - if (!parsed.success) { - throw new InvalidError({ - path: source, - issues: parsed.error.issues, - }) - } - - return parsed.data -} diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index 0287e7ba34..3eb4c5d55b 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -6,33 +6,41 @@ import { Flag } from "@/flag/flag" import { Global } from "@/global" import { unique } from "remeda" import { JsonError } from "./error" +import * as Effect from "effect/Effect" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" -export async function projectFiles(name: string, directory: string, worktree?: string) { - return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true }) -} +export const files = Effect.fn("ConfigPaths.projectFiles")(function* ( + name: string, + directory: string, + worktree?: string, +) { + const afs = yield* AppFileSystem.Service + return (yield* afs.up({ + targets: [`${name}.jsonc`, `${name}.json`], + start: directory, + stop: worktree, + })).toReversed() +}) -export async function directories(directory: string, worktree?: string) { +export const directories = Effect.fn("ConfigPaths.directories")(function* (directory: string, worktree?: string) { + const afs = yield* AppFileSystem.Service return unique([ Global.Path.config, ...(!Flag.KILO_DISABLE_PROJECT_CONFIG - ? await Array.fromAsync( - Filesystem.up({ - targets: [".kilocode", ".kilo", ".opencode"], // kilocode_change - start: directory, - stop: worktree, - }), - ) + ? yield* afs.up({ + targets: [".kilocode", ".kilo", ".opencode"], // kilocode_change + start: directory, + stop: worktree, + }) : []), - ...(await Array.fromAsync( - Filesystem.up({ - targets: [".kilocode", ".kilo", ".opencode"], // kilocode_change - start: Global.Path.home, - stop: Global.Path.home, - }), - )), + ...(yield* afs.up({ + targets: [".kilocode", ".kilo", ".opencode"], // kilocode_change + start: Global.Path.home, + stop: Global.Path.home, + })), ...(Flag.KILO_CONFIG_DIR ? [Flag.KILO_CONFIG_DIR] : []), ]) -} +}) export function fileInDirectory(dir: string, name: string) { return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)] diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index 483ce5f387..123ebf35f8 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -1,125 +1,121 @@ import z from "zod" -export namespace ConfigProvider { - export const Model = z - .object({ - id: z.string(), - name: z.string(), - family: z.string().optional(), - release_date: z.string(), - attachment: z.boolean(), - reasoning: z.boolean(), - temperature: z.boolean(), - tool_call: z.boolean(), - interleaved: z - .union([ - z.literal(true), - z - .object({ - field: z.enum(["reasoning_content", "reasoning_details"]), - }) - .strict(), - ]) - .optional(), - cost: z - .object({ - input: z.number(), - output: z.number(), - cache_read: z.number().optional(), - cache_write: z.number().optional(), - context_over_200k: z - .object({ - input: z.number(), - output: z.number(), - cache_read: z.number().optional(), - cache_write: z.number().optional(), - }) - .optional(), - }) - .optional(), - limit: z.object({ - context: z.number(), - input: z.number().optional(), +export const Model = z + .object({ + id: z.string(), + name: z.string(), + family: z.string().optional(), + release_date: z.string(), + attachment: z.boolean(), + reasoning: z.boolean(), + temperature: z.boolean(), + tool_call: z.boolean(), + interleaved: z + .union([ + z.literal(true), + z + .object({ + field: z.enum(["reasoning_content", "reasoning_details"]), + }) + .strict(), + ]) + .optional(), + cost: z + .object({ + input: z.number(), output: z.number(), - }), - modalities: z - .object({ - input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), - output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), - }) - .optional(), - experimental: z.boolean().optional(), - status: z.enum(["alpha", "beta", "deprecated"]).optional(), - provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(), - options: z.record(z.string(), z.any()), - headers: z.record(z.string(), z.string()).optional(), - variants: z - .record( - z.string(), - // kilocode_change start - allow null values so removed variants can be deleted via stripNulls on save - z - .object({ - disabled: z.boolean().optional().describe("Disable this variant for the model"), - }) - .catchall(z.any()) - .nullable(), - // kilocode_change end - ) - .optional() - .describe("Variant-specific configuration"), - }) - .partial() + cache_read: z.number().optional(), + cache_write: z.number().optional(), + context_over_200k: z + .object({ + input: z.number(), + output: z.number(), + cache_read: z.number().optional(), + cache_write: z.number().optional(), + }) + .optional(), + }) + .optional(), + limit: z.object({ + context: z.number(), + input: z.number().optional(), + output: z.number(), + }), + modalities: z + .object({ + input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), + output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), + }) + .optional(), + experimental: z.boolean().optional(), + status: z.enum(["alpha", "beta", "deprecated"]).optional(), + provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(), + options: z.record(z.string(), z.any()), + headers: z.record(z.string(), z.string()).optional(), + variants: z + .record( + z.string(), + z + .object({ + disabled: z.boolean().optional().describe("Disable this variant for the model"), + }) + .catchall(z.any()) + .nullable(), // kilocode_change - allow null values so removed variants can be deleted via stripNulls on save + ) + .optional() + .describe("Variant-specific configuration"), + }) + .partial() - export const Info = z - .object({ - api: z.string().optional(), - name: z.string(), - env: z.array(z.string()), - id: z.string(), - npm: z.string().optional(), - whitelist: z.array(z.string()).optional(), - blacklist: z.array(z.string()).optional(), - options: z - .object({ - apiKey: z.string().optional(), - baseURL: z.string().optional(), - enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"), - setCacheKey: z.boolean().optional().describe("Enable promptCacheKey for this provider (default false)"), - // kilocode_change start - timeout: z - .union([ - z - .number() - .int() - .positive() - .describe( - "Timeout in milliseconds for requests to this provider. Default is 120000 (2 minutes). Set to false to disable timeout.", - ), - z.literal(false).describe("Disable timeout for this provider entirely."), - // kilocode_change end - ]) - .optional() - .describe( - "Timeout in milliseconds for requests to this provider. Default is 120000 (2 minutes). Set to false to disable timeout.", // kilocode_change - ), - chunkTimeout: z - .number() - .int() - .positive() - .optional() - .describe( - "Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.", - ), - }) - .catchall(z.any()) - .optional(), - models: z.record(z.string(), Model.nullable()).optional(), // kilocode_change - allow null values so removed models can be deleted via stripNulls on save - }) - .partial() - .strict() - .meta({ - ref: "ProviderConfig", - }) +export const Info = z + .object({ + api: z.string().optional(), + name: z.string(), + env: z.array(z.string()), + id: z.string(), + npm: z.string().optional(), + whitelist: z.array(z.string()).optional(), + blacklist: z.array(z.string()).optional(), + options: z + .object({ + apiKey: z.string().optional(), + baseURL: z.string().optional(), + enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"), + setCacheKey: z.boolean().optional().describe("Enable promptCacheKey for this provider (default false)"), + timeout: z + .union([ + z + .number() + .int() + .positive() + .describe( + "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", + ), + z.literal(false).describe("Disable timeout for this provider entirely."), + ]) + .optional() + .describe( + "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", + ), + chunkTimeout: z + .number() + .int() + .positive() + .optional() + .describe( + "Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.", + ), + }) + .catchall(z.any()) + .optional(), + models: z.record(z.string(), Model.nullable()).optional(), // kilocode_change - allow null values so removed models can be deleted via stripNulls on save + }) + .partial() + .strict() + .meta({ + ref: "ProviderConfig", + }) - export type Info = z.infer -} +export type Info = z.infer + +export * as ConfigProvider from "./provider" diff --git a/packages/opencode/src/config/skills.ts b/packages/opencode/src/config/skills.ts index bdc63f5d6a..38cbf99e7d 100644 --- a/packages/opencode/src/config/skills.ts +++ b/packages/opencode/src/config/skills.ts @@ -1,13 +1,13 @@ import z from "zod" -export namespace ConfigSkills { - export const Info = z.object({ - paths: z.array(z.string()).optional().describe("Additional paths to skill folders"), - urls: z - .array(z.string()) - .optional() - .describe("URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)"), - }) +export const Info = z.object({ + paths: z.array(z.string()).optional().describe("Additional paths to skill folders"), + urls: z + .array(z.string()) + .optional() + .describe("URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)"), +}) - export type Info = z.infer -} +export type Info = z.infer + +export * as ConfigSkills from "./skills" diff --git a/packages/opencode/src/config/variable.ts b/packages/opencode/src/config/variable.ts index e016e33a21..e52db6147c 100644 --- a/packages/opencode/src/config/variable.ts +++ b/packages/opencode/src/config/variable.ts @@ -16,6 +16,11 @@ type ParseSource = dir: string } +type SubstituteInput = ParseSource & { + text: string + missing?: "error" | "empty" +} + function source(input: ParseSource) { return input.type === "path" ? input.path : input.source } @@ -25,8 +30,9 @@ function dir(input: ParseSource) { } /** Apply {env:VAR} and {file:path} substitutions to config text. */ -export async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") { - text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => { +export async function substitute(input: SubstituteInput) { + const missing = input.missing ?? "error" + let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => { return process.env[varName] || "" }) diff --git a/packages/opencode/src/control-plane/workspace-context.ts b/packages/opencode/src/control-plane/workspace-context.ts index 3d4fa5baef..85ef596e7a 100644 --- a/packages/opencode/src/control-plane/workspace-context.ts +++ b/packages/opencode/src/control-plane/workspace-context.ts @@ -2,13 +2,13 @@ import { LocalContext } from "../util" import type { WorkspaceID } from "../control-plane/schema" export interface WorkspaceContext { - workspaceID: WorkspaceID + workspaceID: WorkspaceID | undefined } const context = LocalContext.create("instance") export const WorkspaceContext = { - async provide(input: { workspaceID: WorkspaceID; fn: () => R }): Promise { + async provide(input: { workspaceID?: WorkspaceID; fn: () => R }): Promise { return context.provide({ workspaceID: input.workspaceID }, () => input.fn()) }, diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 2932e447c1..2c9db5c177 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -26,173 +26,238 @@ import { AppRuntime } from "@/effect/app-runtime" import { EventSequenceTable } from "@/sync/event.sql" import { waitEvent } from "./util" -export namespace Workspace { - export const Info = WorkspaceInfo.meta({ - ref: "Workspace", - }) - export type Info = z.infer +export const Info = WorkspaceInfo.meta({ + ref: "Workspace", +}) +export type Info = z.infer - export const ConnectionStatus = z.object({ - workspaceID: WorkspaceID.zod, - status: z.enum(["connected", "connecting", "disconnected", "error"]), - error: z.string().optional(), - }) - export type ConnectionStatus = z.infer +export const ConnectionStatus = z.object({ + workspaceID: WorkspaceID.zod, + status: z.enum(["connected", "connecting", "disconnected", "error"]), +}) +export type ConnectionStatus = z.infer - const Restore = z.object({ - workspaceID: WorkspaceID.zod, - sessionID: SessionID.zod, - total: z.number().int().min(0), - step: z.number().int().min(0), - }) +const Restore = z.object({ + workspaceID: WorkspaceID.zod, + sessionID: SessionID.zod, + total: z.number().int().min(0), + step: z.number().int().min(0), +}) - export const Event = { - Ready: BusEvent.define( - "workspace.ready", - z.object({ - name: z.string(), - }), - ), - Failed: BusEvent.define( - "workspace.failed", - z.object({ - message: z.string(), - }), - ), - Restore: BusEvent.define("workspace.restore", Restore), - Status: BusEvent.define("workspace.status", ConnectionStatus), +export const Event = { + Ready: BusEvent.define( + "workspace.ready", + z.object({ + name: z.string(), + }), + ), + Failed: BusEvent.define( + "workspace.failed", + z.object({ + message: z.string(), + }), + ), + Restore: BusEvent.define("workspace.restore", Restore), + Status: BusEvent.define("workspace.status", ConnectionStatus), +} + +function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { + return { + id: row.id, + type: row.type, + branch: row.branch, + name: row.name, + directory: row.directory, + extra: row.extra, + projectID: row.project_id, + } +} + +const CreateInput = z.object({ + id: WorkspaceID.zod.optional(), + type: Info.shape.type, + branch: Info.shape.branch, + projectID: ProjectID.zod, + extra: Info.shape.extra, +}) + +export const create = fn(CreateInput, async (input) => { + const id = WorkspaceID.ascending(input.id) + const adaptor = await getAdaptor(input.projectID, input.type) + + const config = await adaptor.configure({ ...input, id, name: Slug.create(), directory: null }) + + const info: Info = { + id, + type: config.type, + branch: config.branch ?? null, + name: config.name ?? null, + directory: config.directory ?? null, + extra: config.extra ?? null, + projectID: input.projectID, } - function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { - return { - id: row.id, - type: row.type, - branch: row.branch, - name: row.name, - directory: row.directory, - extra: row.extra, - projectID: row.project_id, - } - } - - const CreateInput = z.object({ - id: WorkspaceID.zod.optional(), - type: Info.shape.type, - branch: Info.shape.branch, - projectID: ProjectID.zod, - extra: Info.shape.extra, + Database.use((db) => { + db.insert(WorkspaceTable) + .values({ + id: info.id, + type: info.type, + branch: info.branch, + name: info.name, + directory: info.directory, + extra: info.extra, + project_id: info.projectID, + }) + .run() }) - export const create = fn(CreateInput, async (input) => { - const id = WorkspaceID.ascending(input.id) - const adaptor = await getAdaptor(input.projectID, input.type) + const env = { + KILO_AUTH_CONTENT: JSON.stringify(await AppRuntime.runPromise(Auth.Service.use((auth) => auth.all()))), + KILO_WORKSPACE_ID: config.id, + KILO_EXPERIMENTAL_WORKSPACES: "true", + } + await adaptor.create(config, env) - const config = await adaptor.configure({ ...input, id, name: Slug.create(), directory: null }) + startSync(info) - const info: Info = { - id, - type: config.type, - branch: config.branch ?? null, - name: config.name ?? null, - directory: config.directory ?? null, - extra: config.extra ?? null, - projectID: input.projectID, - } + await waitEvent({ + timeout: TIMEOUT, + fn(event) { + if (event.workspace === info.id && event.payload.type === Event.Status.type) { + const { status } = event.payload.properties + return status === "error" || status === "connected" + } + return false + }, + }) - Database.use((db) => { - db.insert(WorkspaceTable) - .values({ - id: info.id, - type: info.type, - branch: info.branch, - name: info.name, - directory: info.directory, - extra: info.extra, - project_id: info.projectID, - }) - .run() - }) + return info +}) - const env = { - KILO_AUTH_CONTENT: JSON.stringify(await AppRuntime.runPromise(Auth.Service.use((auth) => auth.all()))), - KILO_WORKSPACE_ID: config.id, - KILO_EXPERIMENTAL_WORKSPACES: "true", - } - await adaptor.create(config, env) +const SessionRestoreInput = z.object({ + workspaceID: WorkspaceID.zod, + sessionID: SessionID.zod, +}) - startSync(info) +export const sessionRestore = fn(SessionRestoreInput, async (input) => { + log.info("session restore requested", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + }) + try { + const space = await get(input.workspaceID) + if (!space) throw new Error(`Workspace not found: ${input.workspaceID}`) - await waitEvent({ - timeout: TIMEOUT, - fn(event) { - if (event.workspace === info.id && event.payload.type === Event.Status.type) { - const { status } = event.payload.properties - return status === "error" || status === "connected" - } - return false + const adaptor = await getAdaptor(space.projectID, space.type) + const target = await adaptor.target(space) + + // Need to switch the workspace of the session + SyncEvent.run(Session.Event.Updated, { + sessionID: input.sessionID, + info: { + workspaceID: input.workspaceID, }, }) - return info - }) + const rows = Database.use((db) => + db + .select({ + id: EventTable.id, + aggregateID: EventTable.aggregate_id, + seq: EventTable.seq, + type: EventTable.type, + data: EventTable.data, + }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, input.sessionID)) + .orderBy(asc(EventTable.seq)) + .all(), + ) + if (rows.length === 0) throw new Error(`No events found for session: ${input.sessionID}`) - const SessionRestoreInput = z.object({ - workspaceID: WorkspaceID.zod, - sessionID: SessionID.zod, - }) + const all = rows - export const sessionRestore = fn(SessionRestoreInput, async (input) => { - log.info("session restore requested", { + const size = 10 + const sets = Array.from({ length: Math.ceil(all.length / size) }, (_, i) => all.slice(i * size, (i + 1) * size)) + const total = sets.length + log.info("session restore prepared", { workspaceID: input.workspaceID, sessionID: input.sessionID, + workspaceType: space.type, + directory: space.directory, + target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory, + events: all.length, + batches: total, + first: all[0]?.seq, + last: all.at(-1)?.seq, }) - try { - const space = await get(input.workspaceID) - if (!space) throw new Error(`Workspace not found: ${input.workspaceID}`) - - const adaptor = await getAdaptor(space.projectID, space.type) - const target = await adaptor.target(space) - - // Need to switch the workspace of the session - SyncEvent.run(Session.Event.Updated, { - sessionID: input.sessionID, - info: { + GlobalBus.emit("event", { + directory: "global", + workspace: input.workspaceID, + payload: { + type: Event.Restore.type, + properties: { workspaceID: input.workspaceID, + sessionID: input.sessionID, + total, + step: 0, }, - }) - - const rows = Database.use((db) => - db - .select({ - id: EventTable.id, - aggregateID: EventTable.aggregate_id, - seq: EventTable.seq, - type: EventTable.type, - data: EventTable.data, - }) - .from(EventTable) - .where(eq(EventTable.aggregate_id, input.sessionID)) - .orderBy(asc(EventTable.seq)) - .all(), - ) - if (rows.length === 0) throw new Error(`No events found for session: ${input.sessionID}`) - - const all = rows - - const size = 10 - const sets = Array.from({ length: Math.ceil(all.length / size) }, (_, i) => all.slice(i * size, (i + 1) * size)) - const total = sets.length - log.info("session restore prepared", { + }, + }) + for (const [i, events] of sets.entries()) { + log.info("session restore batch starting", { workspaceID: input.workspaceID, sessionID: input.sessionID, - workspaceType: space.type, - directory: space.directory, + step: i + 1, + total, + events: events.length, + first: events[0]?.seq, + last: events.at(-1)?.seq, target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory, - events: all.length, - batches: total, - first: all[0]?.seq, - last: all.at(-1)?.seq, }) + if (target.type === "local") { + SyncEvent.replayAll(events) + log.info("session restore batch replayed locally", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + step: i + 1, + total, + events: events.length, + }) + } else { + const url = route(target.url, "/sync/replay") + const headers = new Headers(target.headers) + headers.set("content-type", "application/json") + const res = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ + directory: space.directory ?? "", + events, + }), + }) + if (!res.ok) { + const body = await res.text() + log.error("session restore batch failed", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + step: i + 1, + total, + status: res.status, + body, + }) + throw new Error( + `Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`, + ) + } + log.info("session restore batch posted", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + step: i + 1, + total, + status: res.status, + }) + } GlobalBus.emit("event", { directory: "global", workspace: input.workspaceID, @@ -202,266 +267,206 @@ export namespace Workspace { workspaceID: input.workspaceID, sessionID: input.sessionID, total, - step: 0, + step: i + 1, }, }, }) - for (const [i, events] of sets.entries()) { - log.info("session restore batch starting", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - step: i + 1, - total, - events: events.length, - first: events[0]?.seq, - last: events.at(-1)?.seq, - target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory, + } + + log.info("session restore complete", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + batches: total, + }) + + return { + total, + } + } catch (err) { + log.error("session restore failed", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + error: errorData(err), + }) + throw err + } +}) + +export function list(project: Project.Info) { + const rows = Database.use((db) => + db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(), + ) + const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id)) + + for (const space of spaces) startSync(space) + return spaces +} + +function lookup(id: WorkspaceID) { + const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) + if (!row) return + return fromRow(row) +} + +export const get = fn(WorkspaceID.zod, async (id) => { + const space = lookup(id) + if (!space) return + startSync(space) + return space +}) + +export const remove = fn(WorkspaceID.zod, async (id) => { + const sessions = Database.use((db) => + db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, id)).all(), + ) + for (const session of sessions) { + await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(session.id))) + } + + const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) + + if (row) { + stopSync(id) + + const info = fromRow(row) + try { + const adaptor = await getAdaptor(info.projectID, row.type) + await adaptor.remove(info) + } catch { + log.error("adaptor not available when removing workspace", { type: row.type }) + } + Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run()) + return info + } +}) + +const connections = new Map() +const aborts = new Map() +const TIMEOUT = 5000 + +function setStatus(id: WorkspaceID, status: ConnectionStatus["status"]) { + const prev = connections.get(id) + if (prev?.status === status) return + const next = { workspaceID: id, status } + connections.set(id, next) + + if (status === "error") { + aborts.delete(id) + } + + GlobalBus.emit("event", { + directory: "global", + workspace: id, + payload: { + type: Event.Status.type, + properties: next, + }, + }) +} + +export function status(): ConnectionStatus[] { + return [...connections.values()] +} + +function synced(state: Record) { + const ids = Object.keys(state) + if (ids.length === 0) return true + + const done = Object.fromEntries( + Database.use((db) => + db + .select({ + id: EventSequenceTable.aggregate_id, + seq: EventSequenceTable.seq, }) - if (target.type === "local") { - SyncEvent.replayAll(events) - log.info("session restore batch replayed locally", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - step: i + 1, - total, - events: events.length, - }) - } else { - const url = route(target.url, "/sync/replay") - const headers = new Headers(target.headers) - headers.set("content-type", "application/json") - const res = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify({ - directory: space.directory ?? "", - events, - }), - }) - if (!res.ok) { - const body = await res.text() - log.error("session restore batch failed", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - step: i + 1, - total, - status: res.status, - body, - }) - throw new Error( - `Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`, - ) - } - log.info("session restore batch posted", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - step: i + 1, - total, - status: res.status, - }) + .from(EventSequenceTable) + .where(inArray(EventSequenceTable.aggregate_id, ids)) + .all(), + ).map((row) => [row.id, row.seq]), + ) as Record + + return ids.every((id) => { + return (done[id] ?? -1) >= state[id] + }) +} + +export async function isSyncing(workspaceID: WorkspaceID) { + return aborts.has(workspaceID) +} + +export async function waitForSync(workspaceID: WorkspaceID, state: Record, signal?: AbortSignal) { + if (synced(state)) return + + try { + await waitEvent({ + timeout: TIMEOUT, + signal, + fn(event) { + if (event.workspace !== workspaceID && event.payload.type !== "sync") { + return false } - GlobalBus.emit("event", { - directory: "global", - workspace: input.workspaceID, - payload: { - type: Event.Restore.type, - properties: { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - total, - step: i + 1, - }, - }, - }) - } - - log.info("session restore complete", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - batches: total, - }) - - return { - total, - } - } catch (err) { - log.error("session restore failed", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - error: errorData(err), - }) - throw err - } - }) - - export function list(project: Project.Info) { - const rows = Database.use((db) => - db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(), - ) - const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id)) - - for (const space of spaces) startSync(space) - return spaces - } - - function lookup(id: WorkspaceID) { - const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) - if (!row) return - return fromRow(row) - } - - export const get = fn(WorkspaceID.zod, async (id) => { - const space = lookup(id) - if (!space) return - startSync(space) - return space - }) - - export const remove = fn(WorkspaceID.zod, async (id) => { - const sessions = Database.use((db) => - db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, id)).all(), - ) - for (const session of sessions) { - await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(session.id))) - } - - const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) - - if (row) { - stopSync(id) - - const info = fromRow(row) - try { - const adaptor = await getAdaptor(info.projectID, row.type) - await adaptor.remove(info) - } catch { - log.error("adaptor not available when removing workspace", { type: row.type }) - } - Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run()) - return info - } - }) - - const connections = new Map() - const aborts = new Map() - const TIMEOUT = 5000 - - function setStatus(id: WorkspaceID, status: ConnectionStatus["status"], error?: string) { - const prev = connections.get(id) - if (prev?.status === status && prev?.error === error) return - const next = { workspaceID: id, status, error } - connections.set(id, next) - - if (status === "error") { - aborts.delete(id) - } - - GlobalBus.emit("event", { - directory: "global", - workspace: id, - payload: { - type: Event.Status.type, - properties: next, + return synced(state) }, }) + } catch { + if (signal?.aborted) throw signal.reason ?? new Error("Request aborted") + throw new Error(`Timed out waiting for sync fence: ${JSON.stringify(state)}`) } +} - export function status(): ConnectionStatus[] { - return [...connections.values()] - } +const log = Log.create({ service: "workspace-sync" }) - function synced(state: Record) { - const ids = Object.keys(state) - if (ids.length === 0) return true +function route(url: string | URL, path: string) { + const next = new URL(url) + next.pathname = `${next.pathname.replace(/\/$/, "")}${path}` + next.search = "" + next.hash = "" + return next +} - const done = Object.fromEntries( - Database.use((db) => - db - .select({ - id: EventSequenceTable.aggregate_id, - seq: EventSequenceTable.seq, - }) - .from(EventSequenceTable) - .where(inArray(EventSequenceTable.aggregate_id, ids)) - .all(), - ).map((row) => [row.id, row.seq]), - ) as Record +async function connectSSE(url: URL | string, headers: HeadersInit | undefined, signal: AbortSignal) { + const res = await fetch(route(url, "/global/event"), { + method: "GET", + headers, + signal, + }) - return ids.every((id) => { - return (done[id] ?? -1) >= state[id] - }) - } + if (!res.ok) throw new Error(`Workspace sync HTTP failure: ${res.status}`) + if (!res.body) throw new Error("No response body from global sync") - export async function isSyncing(workspaceID: WorkspaceID) { - return aborts.has(workspaceID) - } + return res.body +} - export async function waitForSync(workspaceID: WorkspaceID, state: Record, signal?: AbortSignal) { - if (synced(state)) return +async function syncWorkspaceLoop(space: Info, signal: AbortSignal) { + const adaptor = await getAdaptor(space.projectID, space.type) + const target = await adaptor.target(space) + if (target.type === "local") return null + + let attempt = 0 + + while (!signal.aborted) { + log.info("connecting to global sync", { workspace: space.name }) + setStatus(space.id, "connecting") + + let stream try { - await waitEvent({ - timeout: TIMEOUT, - signal, - fn(event) { - if (event.workspace !== workspaceID && event.payload.type !== "sync") { - return false - } - return synced(state) - }, + stream = await connectSSE(target.url, target.headers, signal) + } catch (err) { + setStatus(space.id, "error") + log.info("failed to connect to global sync", { + workspace: space.name, + err, }) - } catch { - if (signal?.aborted) throw signal.reason ?? new Error("Request aborted") - throw new Error(`Timed out waiting for sync fence: ${JSON.stringify(state)}`) } - } - const log = Log.create({ service: "workspace-sync" }) - - function route(url: string | URL, path: string) { - const next = new URL(url) - next.pathname = `${next.pathname.replace(/\/$/, "")}${path}` - next.search = "" - next.hash = "" - return next - } - - async function syncWorkspace(space: Info, signal: AbortSignal) { - while (!signal.aborted) { - log.info("connecting to global sync", { workspace: space.name }) - setStatus(space.id, "connecting") - - const adaptor = await getAdaptor(space.projectID, space.type) - const target = await adaptor.target(space) - - if (target.type === "local") return - - const res = await fetch(route(target.url, "/global/event"), { - method: "GET", - headers: target.headers, - signal, - }).catch((err: unknown) => { - setStatus(space.id, "error", err instanceof Error ? err.message : String(err)) - - log.info("failed to connect to global sync", { - workspace: space.name, - error: err, - }) - return undefined - }) - - if (!res || !res.ok || !res.body) { - const error = !res ? "No response from global sync" : `Global sync HTTP ${res.status}` - log.info("failed to connect to global sync", { workspace: space.name, error }) - setStatus(space.id, "error", error) - await sleep(1000) - continue - } + if (stream) { + attempt = 0 log.info("global sync connected", { workspace: space.name }) setStatus(space.id, "connected") - await parseSSE(res.body, signal, (evt: any) => { + await parseSSE(stream, signal, (evt: any) => { try { if (!("payload" in evt)) return @@ -485,46 +490,50 @@ export namespace Workspace { log.info("disconnected from global sync: " + space.id) setStatus(space.id, "disconnected") - - // TODO: Implement exponential backoff - await sleep(1000) - } - } - - async function startSync(space: Info) { - if (!Flag.KILO_EXPERIMENTAL_WORKSPACES) return - - const adaptor = await getAdaptor(space.projectID, space.type) - const target = await adaptor.target(space) - - if (target.type === "local") { - void Filesystem.exists(target.directory).then((exists) => { - setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist") - }) - return } - if (aborts.has(space.id)) return true - - setStatus(space.id, "disconnected") - - const abort = new AbortController() - aborts.set(space.id, abort) - - void syncWorkspace(space, abort.signal).catch((error) => { - aborts.delete(space.id) - - setStatus(space.id, "error", String(error)) - log.warn("workspace listener failed", { - workspaceID: space.id, - error, - }) - }) - } - - function stopSync(id: WorkspaceID) { - aborts.get(id)?.abort() - aborts.delete(id) - connections.delete(id) + // Back off reconnect attempts up to 2 minutes while the workspace + // stays unavailable. + await sleep(Math.min(120_000, 1_000 * 2 ** attempt)) + attempt += 1 } } + +async function startSync(space: Info) { + if (!Flag.KILO_EXPERIMENTAL_WORKSPACES) return + + const adaptor = await getAdaptor(space.projectID, space.type) + const target = await adaptor.target(space) + + if (target.type === "local") { + void Filesystem.exists(target.directory).then((exists) => { + setStatus(space.id, exists ? "connected" : "error") + }) + return + } + + if (aborts.has(space.id)) return true + + setStatus(space.id, "disconnected") + + const abort = new AbortController() + aborts.set(space.id, abort) + + void syncWorkspaceLoop(space, abort.signal).catch((error) => { + aborts.delete(space.id) + + setStatus(space.id, "error") + log.warn("workspace listener failed", { + workspaceID: space.id, + error, + }) + }) +} + +function stopSync(id: WorkspaceID) { + aborts.get(id)?.abort() + aborts.delete(id) + connections.delete(id) +} + +export * as Workspace from "./workspace" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index f06c41e319..eae52d6366 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -5,11 +5,10 @@ import * as Observability from "./observability" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Bus } from "@/bus" import { Auth } from "@/auth" -import { Account } from "@/account" +import { Account } from "@/account/account" import { Config } from "@/config" import { Git } from "@/git" import { Ripgrep } from "@/file/ripgrep" -import { FileTime } from "@/file/time" import { File } from "@/file" import { FileWatcher } from "@/file/watcher" import { Storage } from "@/storage" @@ -58,7 +57,6 @@ export const AppLayer = Layer.mergeAll( Config.defaultLayer, Git.defaultLayer, Ripgrep.defaultLayer, - FileTime.defaultLayer, File.defaultLayer, FileWatcher.defaultLayer, Storage.defaultLayer, diff --git a/packages/opencode/src/file/ignore.ts b/packages/opencode/src/file/ignore.ts index 63f2f594eb..efce872808 100644 --- a/packages/opencode/src/file/ignore.ts +++ b/packages/opencode/src/file/ignore.ts @@ -1,81 +1,81 @@ import { Glob } from "@opencode-ai/shared/util/glob" -export namespace FileIgnore { - const FOLDERS = new Set([ - "node_modules", - "bower_components", - ".pnpm-store", - "vendor", - ".npm", - "dist", - "build", - "out", - ".next", - "target", - "bin", - "obj", - ".git", - ".svn", - ".hg", - ".vscode", - ".idea", - ".turbo", - ".output", - "desktop", - ".sst", - ".cache", - ".webkit-cache", - "__pycache__", - ".pytest_cache", - "mypy_cache", - ".history", - ".gradle", - ]) +const FOLDERS = new Set([ + "node_modules", + "bower_components", + ".pnpm-store", + "vendor", + ".npm", + "dist", + "build", + "out", + ".next", + "target", + "bin", + "obj", + ".git", + ".svn", + ".hg", + ".vscode", + ".idea", + ".turbo", + ".output", + "desktop", + ".sst", + ".cache", + ".webkit-cache", + "__pycache__", + ".pytest_cache", + "mypy_cache", + ".history", + ".gradle", +]) - const FILES = [ - "**/*.swp", - "**/*.swo", +const FILES = [ + "**/*.swp", + "**/*.swo", - "**/*.pyc", + "**/*.pyc", - // OS - "**/.DS_Store", - "**/Thumbs.db", + // OS + "**/.DS_Store", + "**/Thumbs.db", - // Logs & temp - "**/logs/**", - "**/tmp/**", - "**/temp/**", - "**/*.log", + // Logs & temp + "**/logs/**", + "**/tmp/**", + "**/temp/**", + "**/*.log", - // Coverage/test outputs - "**/coverage/**", - "**/.nyc_output/**", - ] + // Coverage/test outputs + "**/coverage/**", + "**/.nyc_output/**", +] - export const PATTERNS = [...FILES, ...FOLDERS] +export const PATTERNS = [...FILES, ...FOLDERS] - export function match( - filepath: string, - opts?: { - extra?: string[] - whitelist?: string[] - }, - ) { - for (const pattern of opts?.whitelist || []) { - if (Glob.match(pattern, filepath)) return false - } - - const parts = filepath.split(/[/\\]/) - for (let i = 0; i < parts.length; i++) { - if (FOLDERS.has(parts[i])) return true - } - - const extra = opts?.extra || [] - for (const pattern of [...FILES, ...extra]) { - if (Glob.match(pattern, filepath)) return true - } - - return false +export function match( + filepath: string, + opts?: { + extra?: string[] + whitelist?: string[] + }, +) { + for (const pattern of opts?.whitelist || []) { + if (Glob.match(pattern, filepath)) return false } + + const parts = filepath.split(/[/\\]/) + for (let i = 0; i < parts.length; i++) { + if (FOLDERS.has(parts[i])) return true + } + + const extra = opts?.extra || [] + for (const pattern of [...FILES, ...extra]) { + if (Glob.match(pattern, filepath)) return true + } + + return false } + +export * as FileIgnore from "./ignore" diff --git a/packages/opencode/src/file/protected.ts b/packages/opencode/src/file/protected.ts index d519746193..a316e790b8 100644 --- a/packages/opencode/src/file/protected.ts +++ b/packages/opencode/src/file/protected.ts @@ -37,23 +37,23 @@ const DARWIN_ROOT = ["/.DocumentRevisions-V100", "/.Spotlight-V100", "/.Trashes" const WIN32_HOME = ["AppData", "Downloads", "Desktop", "Documents", "Pictures", "Music", "Videos", "OneDrive"] -export namespace Protected { - /** Directory basenames to skip when scanning the home directory. */ - export function names(): ReadonlySet { - if (process.platform === "darwin") return new Set(DARWIN_HOME) - if (process.platform === "win32") return new Set(WIN32_HOME) - return new Set() - } - - /** Absolute paths that should never be watched, stated, or scanned. */ - export function paths(): string[] { - if (process.platform === "darwin") - return [ - ...DARWIN_HOME.map((n) => path.join(home, n)), - ...DARWIN_LIBRARY.map((n) => path.join(home, "Library", n)), - ...DARWIN_ROOT, - ] - if (process.platform === "win32") return WIN32_HOME.map((n) => path.join(home, n)) - return [] - } +/** Directory basenames to skip when scanning the home directory. */ +export function names(): ReadonlySet { + if (process.platform === "darwin") return new Set(DARWIN_HOME) + if (process.platform === "win32") return new Set(WIN32_HOME) + return new Set() } + +/** Absolute paths that should never be watched, stated, or scanned. */ +export function paths(): string[] { + if (process.platform === "darwin") + return [ + ...DARWIN_HOME.map((n) => path.join(home, n)), + ...DARWIN_LIBRARY.map((n) => path.join(home, "Library", n)), + ...DARWIN_ROOT, + ] + if (process.platform === "win32") return WIN32_HOME.map((n) => path.join(home, n)) + return [] +} + +export * as Protected from "./protected" diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index f32b429623..ec7cbe953f 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -6,565 +6,574 @@ import { Cause, Context, Effect, Layer, Queue, Stream } from "effect" import { ripgrep } from "ripgrep" import { Filesystem } from "@/util" import { Log } from "@/util" -import { RipgrepStream } from "../kilocode/ripgrep-stream" // kilocode_change - share UTF-8 stream decoding +import { KiloRipgrepStream } from "../kilocode/kilo-ripgrep-stream" // kilocode_change - UTF-8 safe stream decoder shared with worker +const log = Log.create({ service: "ripgrep" }) -export namespace Ripgrep { - const log = Log.create({ service: "ripgrep" }) +const Stats = z.object({ + elapsed: z.object({ + secs: z.number(), + nanos: z.number(), + human: z.string(), + }), + searches: z.number(), + searches_with_match: z.number(), + bytes_searched: z.number(), + bytes_printed: z.number(), + matched_lines: z.number(), + matches: z.number(), +}) - const Stats = z.object({ - elapsed: z.object({ - secs: z.number(), - nanos: z.number(), +const Begin = z.object({ + type: z.literal("begin"), + data: z.object({ + path: z.object({ + text: z.string(), + }), + }), +}) + +export const Match = z.object({ + type: z.literal("match"), + data: z.object({ + path: z.object({ + text: z.string(), + }), + lines: z.object({ + text: z.string(), + }), + line_number: z.number(), + absolute_offset: z.number(), + submatches: z.array( + z.object({ + match: z.object({ + text: z.string(), + }), + start: z.number(), + end: z.number(), + }), + ), + }), +}) + +const End = z.object({ + type: z.literal("end"), + data: z.object({ + path: z.object({ + text: z.string(), + }), + binary_offset: z.number().nullable(), + stats: Stats, + }), +}) + +const Summary = z.object({ + type: z.literal("summary"), + data: z.object({ + elapsed_total: z.object({ human: z.string(), + nanos: z.number(), + secs: z.number(), }), - searches: z.number(), - searches_with_match: z.number(), - bytes_searched: z.number(), - bytes_printed: z.number(), - matched_lines: z.number(), - matches: z.number(), - }) + stats: Stats, + }), +}) - const Begin = z.object({ - type: z.literal("begin"), - data: z.object({ - path: z.object({ - text: z.string(), - }), - }), - }) +const Result = z.union([Begin, Match, End, Summary]) - export const Match = z.object({ - type: z.literal("match"), - data: z.object({ - path: z.object({ - text: z.string(), - }), - lines: z.object({ - text: z.string(), - }), - line_number: z.number(), - absolute_offset: z.number(), - submatches: z.array( - z.object({ - match: z.object({ - text: z.string(), - }), - start: z.number(), - end: z.number(), - }), - ), - }), - }) +export type Result = z.infer +export type Match = z.infer +export type Item = Match["data"] +export type Begin = z.infer +export type End = z.infer +export type Summary = z.infer +export type Row = Match["data"] - const End = z.object({ - type: z.literal("end"), - data: z.object({ - path: z.object({ - text: z.string(), - }), - binary_offset: z.number().nullable(), - stats: Stats, - }), - }) +export interface SearchResult { + items: Item[] + partial: boolean +} - const Summary = z.object({ - type: z.literal("summary"), - data: z.object({ - elapsed_total: z.object({ - human: z.string(), - nanos: z.number(), - secs: z.number(), - }), - stats: Stats, - }), - }) +export interface FilesInput { + cwd: string + glob?: string[] + hidden?: boolean + follow?: boolean + maxDepth?: number + signal?: AbortSignal +} - const Result = z.union([Begin, Match, End, Summary]) +export interface SearchInput { + cwd: string + pattern: string + glob?: string[] + limit?: number + follow?: boolean + file?: string[] + signal?: AbortSignal +} - export type Result = z.infer - export type Match = z.infer - export type Item = Match["data"] - export type Begin = z.infer - export type End = z.infer - export type Summary = z.infer - export type Row = Match["data"] +export interface TreeInput { + cwd: string + limit?: number + signal?: AbortSignal +} - export interface SearchResult { - items: Item[] - partial: boolean +export interface Interface { + readonly files: (input: FilesInput) => Stream.Stream + readonly tree: (input: TreeInput) => Effect.Effect + readonly search: (input: SearchInput) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Ripgrep") {} + +type Run = { kind: "files" | "search"; cwd: string; args: string[] } + +type WorkerResult = { + type: "result" + code: number + stdout: string + stderr: string +} + +type WorkerLine = { + type: "line" + line: string +} + +type WorkerDone = { + type: "done" + code: number + stderr: string +} + +type WorkerError = { + type: "error" + error: { + message: string + name?: string + stack?: string } +} - export interface FilesInput { - cwd: string - glob?: string[] - hidden?: boolean - follow?: boolean - maxDepth?: number - signal?: AbortSignal +function env() { + const env = Object.fromEntries( + Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined), + ) + delete env.RIPGREP_CONFIG_PATH + return env +} + +function text(input: unknown) { + if (typeof input === "string") return input + if (input instanceof ArrayBuffer) return Buffer.from(input).toString() + if (ArrayBuffer.isView(input)) return Buffer.from(input.buffer, input.byteOffset, input.byteLength).toString() + return String(input) +} + +function toError(input: unknown) { + if (input instanceof Error) return input + if (typeof input === "string") return new Error(input) + return new Error(String(input)) +} + +function abort(signal?: AbortSignal) { + const err = signal?.reason + if (err instanceof Error) return err + const out = new Error("Aborted") + out.name = "AbortError" + return out +} + +function error(stderr: string, code: number) { + const err = new Error(stderr.trim() || `ripgrep failed with code ${code}`) + err.name = "RipgrepError" + return err +} + +function clean(file: string) { + return path.normalize(file.replace(/^\.[\\/]/, "")) +} + +function row(data: Row): Row { + return { + ...data, + path: { + ...data.path, + text: clean(data.path.text), + }, } +} - export interface SearchInput { - cwd: string - pattern: string - glob?: string[] - limit?: number - follow?: boolean - file?: string[] - signal?: AbortSignal +function opts(cwd: string) { + return { + env: env(), + preopens: { ".": cwd }, } +} - export interface TreeInput { - cwd: string - limit?: number - signal?: AbortSignal - } - - export interface Interface { - readonly files: (input: FilesInput) => Stream.Stream - readonly tree: (input: TreeInput) => Effect.Effect - readonly search: (input: SearchInput) => Effect.Effect - } - - export class Service extends Context.Service()("@opencode/Ripgrep") {} - - type Run = { kind: "files" | "search"; cwd: string; args: string[] } - - type WorkerResult = { - type: "result" - code: number - stdout: string - stderr: string - } - - type WorkerLine = { - type: "line" - line: string - } - - type WorkerDone = { - type: "done" - code: number - stderr: string - } - - type WorkerError = { - type: "error" - error: { - message: string - name?: string - stack?: string - } - } - - function env() { - const env = Object.fromEntries( - Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined), - ) - delete env.RIPGREP_CONFIG_PATH - return env - } - - function text(input: unknown) { - if (typeof input === "string") return input - if (input instanceof ArrayBuffer) return Buffer.from(input).toString() - if (ArrayBuffer.isView(input)) return Buffer.from(input.buffer, input.byteOffset, input.byteLength).toString() - return String(input) - } - - function toError(input: unknown) { - if (input instanceof Error) return input - if (typeof input === "string") return new Error(input) - return new Error(String(input)) - } - - function abort(signal?: AbortSignal) { - const err = signal?.reason - if (err instanceof Error) return err - const out = new Error("Aborted") - out.name = "AbortError" - return out - } - - function error(stderr: string, code: number) { - const err = new Error(stderr.trim() || `ripgrep failed with code ${code}`) - err.name = "RipgrepError" - return err - } - - function clean(file: string) { - return path.normalize(file.replace(/^\.[\\/]/, "")) - } - - function row(data: Row): Row { - return { - ...data, - path: { - ...data.path, - text: clean(data.path.text), - }, - } - } - - function opts(cwd: string) { - return { - env: env(), - preopens: { ".": cwd }, - } - } - - function check(cwd: string) { - return Effect.tryPromise({ - try: () => fs.stat(cwd).catch(() => undefined), - catch: toError, - }).pipe( - Effect.flatMap((stat) => - stat?.isDirectory() - ? Effect.void - : Effect.fail( - Object.assign(new Error(`No such file or directory: '${cwd}'`), { - code: "ENOENT", - errno: -2, - path: cwd, - }), - ), - ), - ) - } - - function filesArgs(input: FilesInput) { - const args = ["--files", "--glob=!.git/*"] - if (input.follow) args.push("--follow") - if (input.hidden !== false) args.push("--hidden") - if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`) - if (input.glob) { - for (const glob of input.glob) { - args.push(`--glob=${glob}`) - } - } - args.push(".") - return args - } - - function searchArgs(input: SearchInput) { - const args = ["--json", "--hidden", "--glob=!.git/*", "--no-messages"] - if (input.follow) args.push("--follow") - if (input.glob) { - for (const glob of input.glob) { - args.push(`--glob=${glob}`) - } - } - if (input.limit) args.push(`--max-count=${input.limit}`) - args.push("--", input.pattern, ...(input.file ?? ["."])) - return args - } - - function parse(stdout: string) { - return stdout - .trim() - .split(/\r?\n/) - .filter(Boolean) - .map((line) => Result.parse(JSON.parse(line))) - .flatMap((item) => (item.type === "match" ? [row(item.data)] : [])) - } - - declare const KILO_RIPGREP_WORKER_PATH: string - - function target(): Effect.Effect { - if (typeof KILO_RIPGREP_WORKER_PATH !== "undefined") { - return Effect.succeed(KILO_RIPGREP_WORKER_PATH) - } - const js = new URL("./ripgrep.worker.js", import.meta.url) - return Effect.tryPromise({ - try: () => Filesystem.exists(fileURLToPath(js)), - catch: toError, - }).pipe(Effect.map((exists) => (exists ? js : new URL("./ripgrep.worker.ts", import.meta.url)))) - } - - function worker() { - return target().pipe(Effect.flatMap((file) => Effect.sync(() => new Worker(file, { env: env() })))) - } - - function fail(queue: Queue.Queue, err: Error) { - Queue.failCauseUnsafe(queue, Cause.fail(err)) - } - - function searchDirect(input: SearchInput) { - return Effect.tryPromise({ - try: () => - ripgrep(searchArgs(input), { - buffer: true, - ...opts(input.cwd), - }), - catch: toError, - }).pipe( - Effect.flatMap((ret) => { - const out = ret.stdout ?? "" - if (ret.code !== 0 && ret.code !== 1 && ret.code !== 2) { - return Effect.fail(error(ret.stderr ?? "", ret.code ?? 1)) - } - return Effect.sync(() => ({ - items: ret.code === 1 ? [] : parse(out), - partial: ret.code === 2, - })) - }), - ) - } - - function searchWorker(input: SearchInput) { - if (input.signal?.aborted) return Effect.fail(abort(input.signal)) - - return Effect.acquireUseRelease( - worker(), - (w) => - Effect.callback((resume, signal) => { - let open = true - const done = (effect: Effect.Effect) => { - if (!open) return - open = false - resume(effect) - } - const onabort = () => done(Effect.fail(abort(input.signal))) - - w.onerror = (evt) => { - done(Effect.fail(toError(evt.error ?? evt.message))) - } - w.onmessage = (evt: MessageEvent) => { - const msg = evt.data - if (msg.type === "error") { - done(Effect.fail(Object.assign(new Error(msg.error.message), msg.error))) - return - } - if (msg.code === 1) { - done(Effect.succeed({ items: [], partial: false })) - return - } - if (msg.code !== 0 && msg.code !== 1 && msg.code !== 2) { - done(Effect.fail(error(msg.stderr, msg.code))) - return - } - done( - Effect.sync(() => ({ - items: parse(msg.stdout), - partial: msg.code === 2, - })), - ) - } - - input.signal?.addEventListener("abort", onabort, { once: true }) - signal.addEventListener("abort", onabort, { once: true }) - w.postMessage({ - kind: "search", - cwd: input.cwd, - args: searchArgs(input), - } satisfies Run) - - return Effect.sync(() => { - input.signal?.removeEventListener("abort", onabort) - signal.removeEventListener("abort", onabort) - w.onerror = null - w.onmessage = null - }) - }), - (w) => Effect.sync(() => w.terminate()), - ) - } - - function filesDirect(input: FilesInput) { - return Stream.callback( - Effect.fnUntraced(function* (queue: Queue.Queue) { - let buf = "" - let err = "" - // kilocode_change start - keep decoder state across stdout chunks - const decoder = RipgrepStream.decoder() - - const out = { - write(chunk: unknown) { - buf = RipgrepStream.drain(decoder, buf, chunk, (line) => { - Queue.offerUnsafe(queue, clean(line)) - }) - }, - } - // kilocode_change end - - const stderr = { - write(chunk: unknown) { - err += text(chunk) - }, - } - - yield* Effect.forkScoped( - Effect.gen(function* () { - yield* check(input.cwd) - const ret = yield* Effect.tryPromise({ - try: () => - ripgrep(filesArgs(input), { - stdout: out, - stderr, - ...opts(input.cwd), - }), - catch: toError, - }) - buf += decoder.end() // kilocode_change - flush any trailing buffered bytes - if (buf) Queue.offerUnsafe(queue, clean(buf)) - if (ret.code === 0 || ret.code === 1) { - Queue.endUnsafe(queue) - return - } - fail(queue, error(err, ret.code ?? 1)) - }).pipe( - Effect.catch((err) => - Effect.sync(() => { - fail(queue, err) - }), - ), +function check(cwd: string) { + return Effect.tryPromise({ + try: () => fs.stat(cwd).catch(() => undefined), + catch: toError, + }).pipe( + Effect.flatMap((stat) => + stat?.isDirectory() + ? Effect.void + : Effect.fail( + Object.assign(new Error(`No such file or directory: '${cwd}'`), { + code: "ENOENT", + errno: -2, + path: cwd, + }), ), - ) - }), - ) + ), + ) +} + +function filesArgs(input: FilesInput) { + const args = ["--files", "--glob=!.git/*"] + if (input.follow) args.push("--follow") + if (input.hidden !== false) args.push("--hidden") + if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`) + if (input.glob) { + for (const glob of input.glob) { + args.push(`--glob=${glob}`) + } } + args.push(".") + return args +} - function filesWorker(input: FilesInput) { - return Stream.callback( - Effect.fnUntraced(function* (queue: Queue.Queue) { - if (input.signal?.aborted) { - fail(queue, abort(input.signal)) - return - } +function searchArgs(input: SearchInput) { + const args = ["--json", "--hidden", "--glob=!.git/*", "--no-messages"] + if (input.follow) args.push("--follow") + if (input.glob) { + for (const glob of input.glob) { + args.push(`--glob=${glob}`) + } + } + if (input.limit) args.push(`--max-count=${input.limit}`) + args.push("--", input.pattern, ...(input.file ?? ["."])) + return args +} - const w = yield* Effect.acquireRelease(worker(), (w) => Effect.sync(() => w.terminate())) +function parse(stdout: string) { + return stdout + .trim() + .split(/\r?\n/) + .filter(Boolean) + .map((line) => Result.parse(JSON.parse(line))) + .flatMap((item) => (item.type === "match" ? [row(item.data)] : [])) +} + +declare const KILO_RIPGREP_WORKER_PATH: string + +function target(): Effect.Effect { + if (typeof KILO_RIPGREP_WORKER_PATH !== "undefined") { + return Effect.succeed(KILO_RIPGREP_WORKER_PATH) + } + const js = new URL("./ripgrep.worker.js", import.meta.url) + return Effect.tryPromise({ + try: () => Filesystem.exists(fileURLToPath(js)), + catch: toError, + }).pipe(Effect.map((exists) => (exists ? js : new URL("./ripgrep.worker.ts", import.meta.url)))) +} + +function worker() { + return target().pipe(Effect.flatMap((file) => Effect.sync(() => new Worker(file, { env: env() })))) +} + +// kilocode_change start - delegate to KiloRipgrepStream for UTF-8 safe decoding +function drain( + dec: ReturnType, + buf: string, + chunk: unknown, + push: (line: string) => void, +) { + return KiloRipgrepStream.drain(dec, buf, chunk, push) +} +// kilocode_change end + +function fail(queue: Queue.Queue, err: Error) { + Queue.failCauseUnsafe(queue, Cause.fail(err)) +} + +function searchDirect(input: SearchInput) { + return Effect.tryPromise({ + try: () => + ripgrep(searchArgs(input), { + buffer: true, + ...opts(input.cwd), + }), + catch: toError, + }).pipe( + Effect.flatMap((ret) => { + const out = ret.stdout ?? "" + if (ret.code !== 0 && ret.code !== 1 && ret.code !== 2) { + return Effect.fail(error(ret.stderr ?? "", ret.code ?? 1)) + } + return Effect.sync(() => ({ + items: ret.code === 1 ? [] : parse(out), + partial: ret.code === 2, + })) + }), + ) +} + +function searchWorker(input: SearchInput) { + if (input.signal?.aborted) return Effect.fail(abort(input.signal)) + + return Effect.acquireUseRelease( + worker(), + (w) => + Effect.callback((resume, signal) => { let open = true - const close = () => { - if (!open) return false + const done = (effect: Effect.Effect) => { + if (!open) return open = false - return true - } - const onabort = () => { - if (!close()) return - fail(queue, abort(input.signal)) + resume(effect) } + const onabort = () => done(Effect.fail(abort(input.signal))) w.onerror = (evt) => { - if (!close()) return - fail(queue, toError(evt.error ?? evt.message)) + done(Effect.fail(toError(evt.error ?? evt.message))) } - w.onmessage = (evt: MessageEvent) => { + w.onmessage = (evt: MessageEvent) => { const msg = evt.data - if (msg.type === "line") { - if (open) Queue.offerUnsafe(queue, msg.line) - return - } - if (!close()) return if (msg.type === "error") { - fail(queue, Object.assign(new Error(msg.error.message), msg.error)) + done(Effect.fail(Object.assign(new Error(msg.error.message), msg.error))) return } - if (msg.code === 0 || msg.code === 1) { - Queue.endUnsafe(queue) + if (msg.code === 1) { + done(Effect.succeed({ items: [], partial: false })) return } - fail(queue, error(msg.stderr, msg.code)) - } - - yield* Effect.acquireRelease( - Effect.sync(() => { - input.signal?.addEventListener("abort", onabort, { once: true }) - w.postMessage({ - kind: "files", - cwd: input.cwd, - args: filesArgs(input), - } satisfies Run) - }), - () => - Effect.sync(() => { - input.signal?.removeEventListener("abort", onabort) - w.onerror = null - w.onmessage = null - }), - ) - }), - ) - } - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const source = (input: FilesInput) => { - const useWorker = !!input.signal && typeof Worker !== "undefined" - if (!useWorker && input.signal) { - log.warn("worker unavailable, ripgrep abort disabled") - } - return useWorker ? filesWorker(input) : filesDirect(input) - } - - const files: Interface["files"] = (input) => source(input) - - const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) { - log.info("tree", input) - const list = Array.from(yield* source({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect)) - - interface Node { - name: string - children: Map - } - - function child(node: Node, name: string) { - const item = node.children.get(name) - if (item) return item - const next = { name, children: new Map() } - node.children.set(name, next) - return next - } - - function count(node: Node): number { - return Array.from(node.children.values()).reduce((sum, child) => sum + 1 + count(child), 0) - } - - const root: Node = { name: "", children: new Map() } - for (const file of list) { - if (file.includes(".kilo") || file.includes(".opencode")) continue // kilocode_change - const parts = file.split(path.sep) - if (parts.length < 2) continue - let node = root - for (const part of parts.slice(0, -1)) { - node = child(node, part) + if (msg.code !== 0 && msg.code !== 1 && msg.code !== 2) { + done(Effect.fail(error(msg.stderr, msg.code))) + return } - } - - const total = count(root) - const limit = input.limit ?? total - const lines: string[] = [] - const queue: Array<{ node: Node; path: string }> = Array.from(root.children.values()) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((node) => ({ node, path: node.name })) - - let used = 0 - for (let i = 0; i < queue.length && used < limit; i++) { - const item = queue[i] - lines.push(item.path) - used++ - queue.push( - ...Array.from(item.node.children.values()) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((node) => ({ node, path: `${item.path}/${node.name}` })), + done( + Effect.sync(() => ({ + items: parse(msg.stdout), + partial: msg.code === 2, + })), ) } - if (total > used) lines.push(`[${total - used} truncated]`) - return lines.join("\n") - }) + input.signal?.addEventListener("abort", onabort, { once: true }) + signal.addEventListener("abort", onabort, { once: true }) + w.postMessage({ + kind: "search", + cwd: input.cwd, + args: searchArgs(input), + } satisfies Run) - const search: Interface["search"] = Effect.fn("Ripgrep.search")(function* (input: SearchInput) { - const useWorker = !!input.signal && typeof Worker !== "undefined" - if (!useWorker && input.signal) { - log.warn("worker unavailable, ripgrep abort disabled") - } - return yield* useWorker ? searchWorker(input) : searchDirect(input) - }) + return Effect.sync(() => { + input.signal?.removeEventListener("abort", onabort) + signal.removeEventListener("abort", onabort) + w.onerror = null + w.onmessage = null + }) + }), + (w) => Effect.sync(() => w.terminate()), + ) +} - return Service.of({ files, tree, search }) +function filesDirect(input: FilesInput) { + return Stream.callback( + Effect.fnUntraced(function* (queue: Queue.Queue) { + let buf = "" + let err = "" + // kilocode_change start + const decoder = KiloRipgrepStream.decoder() + const out = { + write(chunk: unknown) { + buf = drain(decoder, buf, chunk, (line) => { + Queue.offerUnsafe(queue, clean(line)) + }) + }, + } + // kilocode_change end + + const stderr = { + write(chunk: unknown) { + err += text(chunk) + }, + } + + yield* Effect.forkScoped( + Effect.gen(function* () { + yield* check(input.cwd) + const ret = yield* Effect.tryPromise({ + try: () => + ripgrep(filesArgs(input), { + stdout: out, + stderr, + ...opts(input.cwd), + }), + catch: toError, + }) + buf += decoder.end() // kilocode_change + if (buf) Queue.offerUnsafe(queue, clean(buf)) + if (ret.code === 0 || ret.code === 1) { + Queue.endUnsafe(queue) + return + } + fail(queue, error(err, ret.code ?? 1)) + }).pipe( + Effect.catch((err) => + Effect.sync(() => { + fail(queue, err) + }), + ), + ), + ) }), ) - - export const defaultLayer = layer } + +function filesWorker(input: FilesInput) { + return Stream.callback( + Effect.fnUntraced(function* (queue: Queue.Queue) { + if (input.signal?.aborted) { + fail(queue, abort(input.signal)) + return + } + + const w = yield* Effect.acquireRelease(worker(), (w) => Effect.sync(() => w.terminate())) + let open = true + const close = () => { + if (!open) return false + open = false + return true + } + const onabort = () => { + if (!close()) return + fail(queue, abort(input.signal)) + } + + w.onerror = (evt) => { + if (!close()) return + fail(queue, toError(evt.error ?? evt.message)) + } + w.onmessage = (evt: MessageEvent) => { + const msg = evt.data + if (msg.type === "line") { + if (open) Queue.offerUnsafe(queue, msg.line) + return + } + if (!close()) return + if (msg.type === "error") { + fail(queue, Object.assign(new Error(msg.error.message), msg.error)) + return + } + if (msg.code === 0 || msg.code === 1) { + Queue.endUnsafe(queue) + return + } + fail(queue, error(msg.stderr, msg.code)) + } + + yield* Effect.acquireRelease( + Effect.sync(() => { + input.signal?.addEventListener("abort", onabort, { once: true }) + w.postMessage({ + kind: "files", + cwd: input.cwd, + args: filesArgs(input), + } satisfies Run) + }), + () => + Effect.sync(() => { + input.signal?.removeEventListener("abort", onabort) + w.onerror = null + w.onmessage = null + }), + ) + }), + ) +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const source = (input: FilesInput) => { + const useWorker = !!input.signal && typeof Worker !== "undefined" + if (!useWorker && input.signal) { + log.warn("worker unavailable, ripgrep abort disabled") + } + return useWorker ? filesWorker(input) : filesDirect(input) + } + + const files: Interface["files"] = (input) => source(input) + + const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) { + log.info("tree", input) + const list = Array.from(yield* source({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect)) + + interface Node { + name: string + children: Map + } + + function child(node: Node, name: string) { + const item = node.children.get(name) + if (item) return item + const next = { name, children: new Map() } + node.children.set(name, next) + return next + } + + function count(node: Node): number { + return Array.from(node.children.values()).reduce((sum, child) => sum + 1 + count(child), 0) + } + + const root: Node = { name: "", children: new Map() } + for (const file of list) { + if (file.includes(".kilo") || file.includes(".opencode")) continue // kilocode_change + const parts = file.split(path.sep) + if (parts.length < 2) continue + let node = root + for (const part of parts.slice(0, -1)) { + node = child(node, part) + } + } + + const total = count(root) + const limit = input.limit ?? total + const lines: string[] = [] + const queue: Array<{ node: Node; path: string }> = Array.from(root.children.values()) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((node) => ({ node, path: node.name })) + + let used = 0 + for (let i = 0; i < queue.length && used < limit; i++) { + const item = queue[i] + lines.push(item.path) + used++ + queue.push( + ...Array.from(item.node.children.values()) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((node) => ({ node, path: `${item.path}/${node.name}` })), + ) + } + + if (total > used) lines.push(`[${total - used} truncated]`) + return lines.join("\n") + }) + + const search: Interface["search"] = Effect.fn("Ripgrep.search")(function* (input: SearchInput) { + const useWorker = !!input.signal && typeof Worker !== "undefined" + if (!useWorker && input.signal) { + log.warn("worker unavailable, ripgrep abort disabled") + } + return yield* useWorker ? searchWorker(input) : searchDirect(input) + }) + + return Service.of({ files, tree, search }) + }), +) + +export const defaultLayer = layer + +export * as Ripgrep from "./ripgrep" diff --git a/packages/opencode/src/file/ripgrep.worker.ts b/packages/opencode/src/file/ripgrep.worker.ts index 7a961fd727..a48ce71dfe 100644 --- a/packages/opencode/src/file/ripgrep.worker.ts +++ b/packages/opencode/src/file/ripgrep.worker.ts @@ -1,5 +1,5 @@ import { ripgrep } from "ripgrep" -import { RipgrepStream } from "../kilocode/ripgrep-stream" // kilocode_change - share UTF-8 stream decoding +import { KiloRipgrepStream } from "../kilocode/kilo-ripgrep-stream" // kilocode_change - share UTF-8 stream decoding function env() { const env = Object.fromEntries( @@ -68,10 +68,10 @@ onmessage = async (evt: MessageEvent) => { let buf = "" let err = "" // kilocode_change start - keep decoder state across stdout chunks - const decoder = RipgrepStream.decoder() + const decoder = KiloRipgrepStream.decoder() const out = { write(chunk: unknown) { - buf = RipgrepStream.drain(decoder, buf, chunk, (line) => postMessage({ type: "line", line: clean(line) })) + buf = KiloRipgrepStream.drain(decoder, buf, chunk, (line) => postMessage({ type: "line", line: clean(line) })) }, } // kilocode_change end diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts deleted file mode 100644 index 40c873775b..0000000000 --- a/packages/opencode/src/file/time.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { DateTime, Effect, Layer, Option, Semaphore, Context } from "effect" -import { InstanceState } from "@/effect" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { Flag } from "@/flag/flag" -import type { SessionID } from "@/session/schema" -import { Log } from "../util" - -export namespace FileTime { - const log = Log.create({ service: "file.time" }) - - export type Stamp = { - readonly read: Date - readonly mtime: number | undefined - readonly size: number | undefined - } - - const session = (reads: Map>, sessionID: SessionID) => { - const value = reads.get(sessionID) - if (value) return value - - const next = new Map() - reads.set(sessionID, next) - return next - } - - interface State { - reads: Map> - locks: Map - } - - export interface Interface { - readonly read: (sessionID: SessionID, file: string) => Effect.Effect - readonly get: (sessionID: SessionID, file: string) => Effect.Effect - readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect - readonly withLock: (filepath: string, fn: () => Effect.Effect) => Effect.Effect - } - - export class Service extends Context.Service()("@opencode/FileTime") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const fsys = yield* AppFileSystem.Service - const disableCheck = yield* Flag.KILO_DISABLE_FILETIME_CHECK - - const stamp = Effect.fnUntraced(function* (file: string) { - const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.void)) - return { - read: yield* DateTime.nowAsDate, - mtime: info ? Option.getOrUndefined(info.mtime)?.getTime() : undefined, - size: info ? Number(info.size) : undefined, - } - }) - const state = yield* InstanceState.make( - Effect.fn("FileTime.state")(() => - Effect.succeed({ - reads: new Map>(), - locks: new Map(), - }), - ), - ) - - const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) { - filepath = AppFileSystem.normalizePath(filepath) - const locks = (yield* InstanceState.get(state)).locks - const lock = locks.get(filepath) - if (lock) return lock - - const next = Semaphore.makeUnsafe(1) - locks.set(filepath, next) - return next - }) - - const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) { - file = AppFileSystem.normalizePath(file) - const reads = (yield* InstanceState.get(state)).reads - log.info("read", { sessionID, file }) - session(reads, sessionID).set(file, yield* stamp(file)) - }) - - const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) { - file = AppFileSystem.normalizePath(file) - const reads = (yield* InstanceState.get(state)).reads - return reads.get(sessionID)?.get(file)?.read - }) - - const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) { - if (disableCheck) return - filepath = AppFileSystem.normalizePath(filepath) - - const reads = (yield* InstanceState.get(state)).reads - const time = reads.get(sessionID)?.get(filepath) - if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`) - - const next = yield* stamp(filepath) - const changed = next.mtime !== time.mtime || next.size !== time.size - if (!changed) return - - throw new Error( - `File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`, - ) - }) - - const withLock = Effect.fn("FileTime.withLock")(function* (filepath: string, fn: () => Effect.Effect) { - return yield* fn().pipe((yield* getLock(filepath)).withPermits(1)) - }) - - return Service.of({ read, get, assert, withLock }) - }), - ).pipe(Layer.orDie) - - export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) -} diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index d55ca58adb..7fa2752d73 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -19,145 +19,145 @@ import { Log } from "../util" declare const KILO_LIBC: string | undefined -export namespace FileWatcher { - const log = Log.create({ service: "file.watcher" }) - const SUBSCRIBE_TIMEOUT_MS = 10_000 +const log = Log.create({ service: "file.watcher" }) +const SUBSCRIBE_TIMEOUT_MS = 10_000 - export const Event = { - Updated: BusEvent.define( - "file.watcher.updated", - z.object({ - file: z.string(), - event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]), - }), - ), - } - - const watcher = lazy((): typeof import("@parcel/watcher") | undefined => { - try { - const binding = require( - `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${KILO_LIBC || "glibc"}` : ""}`, - ) - return createWrapper(binding) as typeof import("@parcel/watcher") - } catch (error) { - log.error("failed to load watcher binding", { error }) - return - } - }) - - function getBackend() { - if (process.platform === "win32") return "windows" - if (process.platform === "darwin") return "fs-events" - if (process.platform === "linux") return "inotify" - } - - function protecteds(dir: string) { - return Protected.paths().filter((item) => { - const rel = path.relative(dir, item) - return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel) - }) - } - - export const hasNativeBinding = () => !!watcher() - - export interface Interface { - readonly init: () => Effect.Effect - } - - export class Service extends Context.Service()("@opencode/FileWatcher") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const config = yield* Config.Service - const git = yield* Git.Service - - const state = yield* InstanceState.make( - Effect.fn("FileWatcher.state")( - function* () { - if (yield* Flag.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER) return - - log.info("init", { directory: Instance.directory }) - - const backend = getBackend() - if (!backend) { - log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform }) - return - } - - const w = watcher() - if (!w) return - - log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend }) - - const subs: ParcelWatcher.AsyncSubscription[] = [] - yield* Effect.addFinalizer(() => - Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))), - ) - - const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => { - if (err) return - for (const evt of evts) { - if (evt.type === "create") void Bus.publish(Event.Updated, { file: evt.path, event: "add" }) - if (evt.type === "update") void Bus.publish(Event.Updated, { file: evt.path, event: "change" }) - if (evt.type === "delete") void Bus.publish(Event.Updated, { file: evt.path, event: "unlink" }) - } - }) - - const subscribe = (dir: string, ignore: string[]) => { - const pending = w.subscribe(dir, cb, { ignore, backend }) - return Effect.gen(function* () { - const sub = yield* Effect.promise(() => pending) - subs.push(sub) - }).pipe( - Effect.timeout(SUBSCRIBE_TIMEOUT_MS), - Effect.catchCause((cause) => { - log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) }) - pending.then((s) => s.unsubscribe()).catch(() => {}) - return Effect.void - }), - ) - } - - const cfg = yield* config.get() - const cfgIgnores = cfg.watcher?.ignore ?? [] - - if (yield* Flag.KILO_EXPERIMENTAL_FILEWATCHER) { - yield* subscribe(Instance.directory, [ - ...FileIgnore.PATTERNS, - ...cfgIgnores, - ...protecteds(Instance.directory), - ]) - } - - if (Instance.project.vcs === "git") { - const result = yield* git.run(["rev-parse", "--git-dir"], { - cwd: Instance.project.worktree, - }) - const vcsDir = - result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined - if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { - const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter( - (entry) => entry !== "HEAD", - ) - yield* subscribe(vcsDir, ignore) - } - } - }, - Effect.catchCause((cause) => { - log.error("failed to init watcher service", { cause: Cause.pretty(cause) }) - return Effect.void - }), - ), - ) - - return Service.of({ - init: Effect.fn("FileWatcher.init")(function* () { - yield* InstanceState.get(state) - }), - }) +export const Event = { + Updated: BusEvent.define( + "file.watcher.updated", + z.object({ + file: z.string(), + event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]), }), - ) - - export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer)) + ), } + +const watcher = lazy((): typeof import("@parcel/watcher") | undefined => { + try { + const binding = require( + `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${KILO_LIBC || "glibc"}` : ""}`, + ) + return createWrapper(binding) as typeof import("@parcel/watcher") + } catch (error) { + log.error("failed to load watcher binding", { error }) + return + } +}) + +function getBackend() { + if (process.platform === "win32") return "windows" + if (process.platform === "darwin") return "fs-events" + if (process.platform === "linux") return "inotify" +} + +function protecteds(dir: string) { + return Protected.paths().filter((item) => { + const rel = path.relative(dir, item) + return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel) + }) +} + +export const hasNativeBinding = () => !!watcher() + +export interface Interface { + readonly init: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/FileWatcher") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const git = yield* Git.Service + + const state = yield* InstanceState.make( + Effect.fn("FileWatcher.state")( + function* () { + if (yield* Flag.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER) return + + log.info("init", { directory: Instance.directory }) + + const backend = getBackend() + if (!backend) { + log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform }) + return + } + + const w = watcher() + if (!w) return + + log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend }) + + const subs: ParcelWatcher.AsyncSubscription[] = [] + yield* Effect.addFinalizer(() => + Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))), + ) + + const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => { + if (err) return + for (const evt of evts) { + if (evt.type === "create") void Bus.publish(Event.Updated, { file: evt.path, event: "add" }) + if (evt.type === "update") void Bus.publish(Event.Updated, { file: evt.path, event: "change" }) + if (evt.type === "delete") void Bus.publish(Event.Updated, { file: evt.path, event: "unlink" }) + } + }) + + const subscribe = (dir: string, ignore: string[]) => { + const pending = w.subscribe(dir, cb, { ignore, backend }) + return Effect.gen(function* () { + const sub = yield* Effect.promise(() => pending) + subs.push(sub) + }).pipe( + Effect.timeout(SUBSCRIBE_TIMEOUT_MS), + Effect.catchCause((cause) => { + log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) }) + pending.then((s) => s.unsubscribe()).catch(() => {}) + return Effect.void + }), + ) + } + + const cfg = yield* config.get() + const cfgIgnores = cfg.watcher?.ignore ?? [] + + if (yield* Flag.KILO_EXPERIMENTAL_FILEWATCHER) { + yield* subscribe(Instance.directory, [ + ...FileIgnore.PATTERNS, + ...cfgIgnores, + ...protecteds(Instance.directory), + ]) + } + + if (Instance.project.vcs === "git") { + const result = yield* git.run(["rev-parse", "--git-dir"], { + cwd: Instance.project.worktree, + }) + const vcsDir = + result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined + if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { + const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter( + (entry) => entry !== "HEAD", + ) + yield* subscribe(vcsDir, ignore) + } + } + }, + Effect.catchCause((cause) => { + log.error("failed to init watcher service", { cause: Cause.pretty(cause) }) + return Effect.void + }), + ), + ) + + return Service.of({ + init: Effect.fn("FileWatcher.init")(function* () { + yield* InstanceState.get(state) + }), + }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer)) + +export * as FileWatcher from "./watcher" diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index ba5dd69138..ff01b402d0 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -10,163 +10,100 @@ function falsy(key: string) { return value === "false" || value === "0" } -export namespace Flag { - export const OTEL_EXPORTER_OTLP_ENDPOINT = process.env["OTEL_EXPORTER_OTLP_ENDPOINT"] - export const OTEL_EXPORTER_OTLP_HEADERS = process.env["OTEL_EXPORTER_OTLP_HEADERS"] - - export const KILO_AUTO_SHARE = truthy("KILO_AUTO_SHARE") - export const KILO_AUTO_HEAP_SNAPSHOT = truthy("KILO_AUTO_HEAP_SNAPSHOT") - export const KILO_GIT_BASH_PATH = process.env["KILO_GIT_BASH_PATH"] - export const KILO_CONFIG = process.env["KILO_CONFIG"] - export declare const KILO_PURE: boolean - export declare const KILO_TUI_CONFIG: string | undefined - export declare const KILO_CONFIG_DIR: string | undefined - export declare const KILO_PLUGIN_META_FILE: string | undefined - export const KILO_CONFIG_CONTENT = process.env["KILO_CONFIG_CONTENT"] - export const KILO_DISABLE_AUTOUPDATE = truthy("KILO_DISABLE_AUTOUPDATE") - export const KILO_ALWAYS_NOTIFY_UPDATE = truthy("KILO_ALWAYS_NOTIFY_UPDATE") - export const KILO_DISABLE_PRUNE = truthy("KILO_DISABLE_PRUNE") - export const KILO_DISABLE_TERMINAL_TITLE = truthy("KILO_DISABLE_TERMINAL_TITLE") - export const KILO_SHOW_TTFD = truthy("KILO_SHOW_TTFD") - export const KILO_PERMISSION = process.env["KILO_PERMISSION"] - export const KILO_DISABLE_DEFAULT_PLUGINS = truthy("KILO_DISABLE_DEFAULT_PLUGINS") - export const KILO_DISABLE_LSP_DOWNLOAD = truthy("KILO_DISABLE_LSP_DOWNLOAD") - export const KILO_ENABLE_EXPERIMENTAL_MODELS = truthy("KILO_ENABLE_EXPERIMENTAL_MODELS") - export const KILO_DISABLE_AUTOCOMPACT = truthy("KILO_DISABLE_AUTOCOMPACT") - export const KILO_DISABLE_MODELS_FETCH = truthy("KILO_DISABLE_MODELS_FETCH") - export const KILO_DISABLE_MOUSE = truthy("KILO_DISABLE_MOUSE") - export const KILO_DISABLE_CLAUDE_CODE = truthy("KILO_DISABLE_CLAUDE_CODE") - export const KILO_DISABLE_CLAUDE_CODE_PROMPT = KILO_DISABLE_CLAUDE_CODE || truthy("KILO_DISABLE_CLAUDE_CODE_PROMPT") - export const KILO_DISABLE_CLAUDE_CODE_SKILLS = KILO_DISABLE_CLAUDE_CODE || truthy("KILO_DISABLE_CLAUDE_CODE_SKILLS") - export const KILO_DISABLE_EXTERNAL_SKILLS = KILO_DISABLE_CLAUDE_CODE_SKILLS || truthy("KILO_DISABLE_EXTERNAL_SKILLS") - export declare const KILO_DISABLE_PROJECT_CONFIG: boolean - export const KILO_FAKE_VCS = process.env["KILO_FAKE_VCS"] - export declare const KILO_CLIENT: string - export const KILO_SERVER_PASSWORD = process.env["KILO_SERVER_PASSWORD"] - export const KILO_SERVER_USERNAME = process.env["KILO_SERVER_USERNAME"] - export const KILO_ENABLE_QUESTION_TOOL = truthy("KILO_ENABLE_QUESTION_TOOL") - - // Experimental - export const KILO_EXPERIMENTAL = truthy("KILO_EXPERIMENTAL") - export const KILO_EXPERIMENTAL_FILEWATCHER = Config.boolean("KILO_EXPERIMENTAL_FILEWATCHER").pipe( - Config.withDefault(false), - ) - export const KILO_EXPERIMENTAL_DISABLE_FILEWATCHER = Config.boolean("KILO_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe( - Config.withDefault(false), - ) - export const KILO_EXPERIMENTAL_ICON_DISCOVERY = KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_ICON_DISCOVERY") - - const copy = process.env["KILO_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"] - export const KILO_EXPERIMENTAL_DISABLE_COPY_ON_SELECT = - copy === undefined ? process.platform === "win32" : truthy("KILO_EXPERIMENTAL_DISABLE_COPY_ON_SELECT") - export const KILO_ENABLE_EXA = truthy("KILO_ENABLE_EXA") || KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_EXA") - export const KILO_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS = number("KILO_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS") - export const KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX = number("KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX") - export const KILO_EXPERIMENTAL_OXFMT = KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_OXFMT") - export const KILO_EXPERIMENTAL_LSP_TY = truthy("KILO_EXPERIMENTAL_LSP_TY") - export const KILO_EXPERIMENTAL_LSP_TOOL = KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_LSP_TOOL") - export const KILO_DISABLE_FILETIME_CHECK = Config.boolean("KILO_DISABLE_FILETIME_CHECK").pipe( - Config.withDefault(false), - ) - export const KILO_EXPERIMENTAL_PLAN_MODE = KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_PLAN_MODE") - export const KILO_EXPERIMENTAL_MARKDOWN = !falsy("KILO_EXPERIMENTAL_MARKDOWN") - export const KILO_MODELS_URL = process.env["KILO_MODELS_URL"] - export const KILO_MODELS_PATH = process.env["KILO_MODELS_PATH"] - export const KILO_DISABLE_EMBEDDED_WEB_UI = truthy("KILO_DISABLE_EMBEDDED_WEB_UI") - export const KILO_DB = process.env["KILO_DB"] - export const KILO_DISABLE_CHANNEL_DB = truthy("KILO_DISABLE_CHANNEL_DB") - export const KILO_SKIP_MIGRATIONS = truthy("KILO_SKIP_MIGRATIONS") - - export const KILO_WORKSPACE_ID = process.env["KILO_WORKSPACE_ID"] - export const KILO_EXPERIMENTAL_HTTPAPI = KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_HTTPAPI") - export const KILO_EXPERIMENTAL_WORKSPACES = KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_WORKSPACES") - - function number(key: string) { - const value = process.env[key] - if (!value) return undefined - const parsed = Number(value) - return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined - } - - export declare const KILO_SESSION_RETRY_LIMIT: number | undefined // kilocode_change — dynamic getter below +function number(key: string) { + const value = process.env[key] + if (!value) return undefined + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined } -// kilocode_change start — Dynamic getter for KILO_SESSION_RETRY_LIMIT -// Must be evaluated at access time so tests can set the env var at runtime -Object.defineProperty(Flag, "KILO_SESSION_RETRY_LIMIT", { - get() { - const value = process.env["KILO_SESSION_RETRY_LIMIT"] - if (!value) return undefined - const parsed = Number(value) - return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined - }, - enumerable: true, - configurable: false, -}) -// kilocode_change end +const KILO_EXPERIMENTAL = truthy("KILO_EXPERIMENTAL") +const KILO_DISABLE_CLAUDE_CODE = truthy("KILO_DISABLE_CLAUDE_CODE") +const KILO_DISABLE_CLAUDE_CODE_SKILLS = KILO_DISABLE_CLAUDE_CODE || truthy("KILO_DISABLE_CLAUDE_CODE_SKILLS") +const copy = process.env["KILO_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"] -// Dynamic getter for KILO_DISABLE_PROJECT_CONFIG -// This must be evaluated at access time, not module load time, -// because external tooling may set this env var at runtime -Object.defineProperty(Flag, "KILO_DISABLE_PROJECT_CONFIG", { - get() { +export const Flag = { + OTEL_EXPORTER_OTLP_ENDPOINT: process.env["OTEL_EXPORTER_OTLP_ENDPOINT"], + OTEL_EXPORTER_OTLP_HEADERS: process.env["OTEL_EXPORTER_OTLP_HEADERS"], + + KILO_AUTO_SHARE: truthy("KILO_AUTO_SHARE"), + KILO_AUTO_HEAP_SNAPSHOT: truthy("KILO_AUTO_HEAP_SNAPSHOT"), + KILO_GIT_BASH_PATH: process.env["KILO_GIT_BASH_PATH"], + KILO_CONFIG: process.env["KILO_CONFIG"], + KILO_CONFIG_CONTENT: process.env["KILO_CONFIG_CONTENT"], + KILO_DISABLE_AUTOUPDATE: truthy("KILO_DISABLE_AUTOUPDATE"), + KILO_ALWAYS_NOTIFY_UPDATE: truthy("KILO_ALWAYS_NOTIFY_UPDATE"), + KILO_DISABLE_PRUNE: truthy("KILO_DISABLE_PRUNE"), + KILO_DISABLE_TERMINAL_TITLE: truthy("KILO_DISABLE_TERMINAL_TITLE"), + KILO_SHOW_TTFD: truthy("KILO_SHOW_TTFD"), + KILO_PERMISSION: process.env["KILO_PERMISSION"], + KILO_DISABLE_DEFAULT_PLUGINS: truthy("KILO_DISABLE_DEFAULT_PLUGINS"), + KILO_DISABLE_LSP_DOWNLOAD: truthy("KILO_DISABLE_LSP_DOWNLOAD"), + KILO_ENABLE_EXPERIMENTAL_MODELS: truthy("KILO_ENABLE_EXPERIMENTAL_MODELS"), + KILO_DISABLE_AUTOCOMPACT: truthy("KILO_DISABLE_AUTOCOMPACT"), + KILO_DISABLE_MODELS_FETCH: truthy("KILO_DISABLE_MODELS_FETCH"), + KILO_DISABLE_MOUSE: truthy("KILO_DISABLE_MOUSE"), + KILO_DISABLE_CLAUDE_CODE, + KILO_DISABLE_CLAUDE_CODE_PROMPT: KILO_DISABLE_CLAUDE_CODE || truthy("KILO_DISABLE_CLAUDE_CODE_PROMPT"), + KILO_DISABLE_CLAUDE_CODE_SKILLS, + KILO_DISABLE_EXTERNAL_SKILLS: KILO_DISABLE_CLAUDE_CODE_SKILLS || truthy("KILO_DISABLE_EXTERNAL_SKILLS"), + KILO_FAKE_VCS: process.env["KILO_FAKE_VCS"], + KILO_SERVER_PASSWORD: process.env["KILO_SERVER_PASSWORD"], + KILO_SERVER_USERNAME: process.env["KILO_SERVER_USERNAME"], + KILO_ENABLE_QUESTION_TOOL: truthy("KILO_ENABLE_QUESTION_TOOL"), + + // Experimental + KILO_EXPERIMENTAL, + KILO_EXPERIMENTAL_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_FILEWATCHER").pipe(Config.withDefault(false)), + KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe( + Config.withDefault(false), + ), + KILO_EXPERIMENTAL_ICON_DISCOVERY: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_ICON_DISCOVERY"), + KILO_EXPERIMENTAL_DISABLE_COPY_ON_SELECT: + copy === undefined ? process.platform === "win32" : truthy("KILO_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"), + KILO_ENABLE_EXA: truthy("KILO_ENABLE_EXA") || KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_EXA"), + KILO_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: number("KILO_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"), + KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX: number("KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX"), + KILO_EXPERIMENTAL_OXFMT: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_OXFMT"), + KILO_EXPERIMENTAL_LSP_TY: truthy("KILO_EXPERIMENTAL_LSP_TY"), + KILO_EXPERIMENTAL_LSP_TOOL: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_LSP_TOOL"), + KILO_EXPERIMENTAL_PLAN_MODE: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_PLAN_MODE"), + KILO_EXPERIMENTAL_MARKDOWN: !falsy("KILO_EXPERIMENTAL_MARKDOWN"), + KILO_MODELS_URL: process.env["KILO_MODELS_URL"], + KILO_MODELS_PATH: process.env["KILO_MODELS_PATH"], + KILO_DISABLE_EMBEDDED_WEB_UI: truthy("KILO_DISABLE_EMBEDDED_WEB_UI"), + KILO_DB: process.env["KILO_DB"], + KILO_DISABLE_CHANNEL_DB: truthy("KILO_DISABLE_CHANNEL_DB"), + KILO_SKIP_MIGRATIONS: truthy("KILO_SKIP_MIGRATIONS"), + KILO_STRICT_CONFIG_DEPS: truthy("KILO_STRICT_CONFIG_DEPS"), + + KILO_WORKSPACE_ID: process.env["KILO_WORKSPACE_ID"], + KILO_EXPERIMENTAL_HTTPAPI: truthy("KILO_EXPERIMENTAL_HTTPAPI"), + KILO_EXPERIMENTAL_WORKSPACES: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_WORKSPACES"), + + // Evaluated at access time (not module load) because tests, the CLI, and + // external tooling set these env vars at runtime. + get KILO_DISABLE_PROJECT_CONFIG() { return truthy("KILO_DISABLE_PROJECT_CONFIG") }, - enumerable: true, - configurable: false, -}) - -// Dynamic getter for KILO_TUI_CONFIG -// This must be evaluated at access time, not module load time, -// because tests and external tooling may set this env var at runtime -Object.defineProperty(Flag, "KILO_TUI_CONFIG", { - get() { + get KILO_TUI_CONFIG() { return process.env["KILO_TUI_CONFIG"] }, - enumerable: true, - configurable: false, -}) - -// Dynamic getter for KILO_CONFIG_DIR -// This must be evaluated at access time, not module load time, -// because external tooling may set this env var at runtime -Object.defineProperty(Flag, "KILO_CONFIG_DIR", { - get() { + get KILO_CONFIG_DIR() { return process.env["KILO_CONFIG_DIR"] }, - enumerable: true, - configurable: false, -}) - -// Dynamic getter for KILO_PURE -// This must be evaluated at access time, not module load time, -// because the CLI can set this flag at runtime -Object.defineProperty(Flag, "KILO_PURE", { - get() { + get KILO_PURE() { return truthy("KILO_PURE") }, - enumerable: true, - configurable: false, -}) - -// Dynamic getter for KILO_PLUGIN_META_FILE -// This must be evaluated at access time, not module load time, -// because tests and external tooling may set this env var at runtime -Object.defineProperty(Flag, "KILO_PLUGIN_META_FILE", { - get() { + get KILO_PLUGIN_META_FILE() { return process.env["KILO_PLUGIN_META_FILE"] }, - enumerable: true, - configurable: false, -}) - -// Dynamic getter for KILO_CLIENT -// This must be evaluated at access time, not module load time, -// because some commands override the client at runtime -Object.defineProperty(Flag, "KILO_CLIENT", { - get() { + get KILO_CLIENT() { return process.env["KILO_CLIENT"] ?? "cli" }, - enumerable: true, - configurable: false, -}) + // kilocode_change start + get KILO_SESSION_RETRY_LIMIT() { + return number("KILO_SESSION_RETRY_LIMIT") + }, + // kilocode_change end +} diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index d0ae59d05e..2d0f80a10c 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -41,39 +41,6 @@ export const layer = Layer.effect( const commands: Record = {} const formatters: Record = {} - const cfg = yield* config.get() - - if (cfg.formatter !== false) { - for (const item of Object.values(Formatter)) { - formatters[item.name] = item - } - for (const [name, item] of Object.entries(cfg.formatter ?? {})) { - // Ruff and uv are both the same formatter, so disabling either should disable both. - if (["ruff", "uv"].includes(name) && (cfg.formatter?.ruff?.disabled || cfg.formatter?.uv?.disabled)) { - // TODO combine formatters so shared backends like Ruff/uv don't need linked disable handling here. - delete formatters.ruff - delete formatters.uv - continue - } - if (item.disabled) { - delete formatters[name] - continue - } - const info = mergeDeep(formatters[name] ?? {}, { - extensions: [], - ...item, - }) - - formatters[name] = { - ...info, - name, - enabled: async () => info.command ?? false, - } - } - } else { - log.info("all formatters are disabled") - } - async function getCommand(item: Formatter.Info) { let cmd = commands[item.name] if (cmd === false || cmd === undefined) { @@ -149,6 +116,48 @@ export const layer = Layer.effect( }) } + const cfg = yield* config.get() + + if (!cfg.formatter) { + log.info("all formatters are disabled") + log.info("init") + return { + formatters, + isEnabled, + formatFile, + } + } + + for (const item of Object.values(Formatter)) { + formatters[item.name] = item + } + + if (cfg.formatter !== true) { + for (const [name, item] of Object.entries(cfg.formatter)) { + const builtIn = Formatter[name as keyof typeof Formatter] + + // Ruff and uv are both the same formatter, so disabling either should disable both. + if (["ruff", "uv"].includes(name) && (cfg.formatter.ruff?.disabled || cfg.formatter.uv?.disabled)) { + // TODO combine formatters so shared backends like Ruff/uv don't need linked disable handling here. + delete formatters.ruff + delete formatters.uv + continue + } + if (item.disabled) { + delete formatters[name] + continue + } + const info = mergeDeep(builtIn ?? { extensions: [] }, item) + + formatters[name] = { + ...info, + name, + extensions: info.extensions ?? [], + enabled: builtIn && !info.command ? builtIn.enabled : async () => info.command ?? false, + } + } + } + log.info("init") return { diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index a1395da77d..57c5f7f24a 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -1,87 +1,87 @@ import z from "zod" import { randomBytes } from "crypto" -export namespace Identifier { - const prefixes = { - event: "evt", - session: "ses", - message: "msg", - permission: "per", - question: "que", - suggestion: "sug", // kilocode_change - user: "usr", - part: "prt", - pty: "pty", - tool: "tool", - workspace: "wrk", - entry: "ent", - } as const +const prefixes = { + event: "evt", + session: "ses", + message: "msg", + permission: "per", + question: "que", + suggestion: "sug", // kilocode_change + user: "usr", + part: "prt", + pty: "pty", + tool: "tool", + workspace: "wrk", + entry: "ent", +} as const - export function schema(prefix: keyof typeof prefixes) { - return z.string().startsWith(prefixes[prefix]) - } - - const LENGTH = 26 - - // State for monotonic ID generation - let lastTimestamp = 0 - let counter = 0 - - export function ascending(prefix: keyof typeof prefixes, given?: string) { - return generateID(prefix, "ascending", given) - } - - export function descending(prefix: keyof typeof prefixes, given?: string) { - return generateID(prefix, "descending", given) - } - - function generateID(prefix: keyof typeof prefixes, direction: "descending" | "ascending", given?: string): string { - if (!given) { - return create(prefixes[prefix], direction) - } - - if (!given.startsWith(prefixes[prefix])) { - throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`) - } - return given - } - - function randomBase62(length: number): string { - const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - let result = "" - const bytes = randomBytes(length) - for (let i = 0; i < length; i++) { - result += chars[bytes[i] % 62] - } - return result - } - - export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string { - const currentTimestamp = timestamp ?? Date.now() - - if (currentTimestamp !== lastTimestamp) { - lastTimestamp = currentTimestamp - counter = 0 - } - counter++ - - let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter) - - now = direction === "descending" ? ~now : now - - const timeBytes = Buffer.alloc(6) - for (let i = 0; i < 6; i++) { - timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff)) - } - - return prefix + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12) - } - - /** Extract timestamp from an ascending ID. Does not work with descending IDs. */ - export function timestamp(id: string): number { - const prefix = id.split("_")[0] - const hex = id.slice(prefix.length + 1, prefix.length + 13) - const encoded = BigInt("0x" + hex) - return Number(encoded / BigInt(0x1000)) - } +export function schema(prefix: keyof typeof prefixes) { + return z.string().startsWith(prefixes[prefix]) } + +const LENGTH = 26 + +// State for monotonic ID generation +let lastTimestamp = 0 +let counter = 0 + +export function ascending(prefix: keyof typeof prefixes, given?: string) { + return generateID(prefix, "ascending", given) +} + +export function descending(prefix: keyof typeof prefixes, given?: string) { + return generateID(prefix, "descending", given) +} + +function generateID(prefix: keyof typeof prefixes, direction: "descending" | "ascending", given?: string): string { + if (!given) { + return create(prefixes[prefix], direction) + } + + if (!given.startsWith(prefixes[prefix])) { + throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`) + } + return given +} + +function randomBase62(length: number): string { + const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + let result = "" + const bytes = randomBytes(length) + for (let i = 0; i < length; i++) { + result += chars[bytes[i] % 62] + } + return result +} + +export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string { + const currentTimestamp = timestamp ?? Date.now() + + if (currentTimestamp !== lastTimestamp) { + lastTimestamp = currentTimestamp + counter = 0 + } + counter++ + + let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter) + + now = direction === "descending" ? ~now : now + + const timeBytes = Buffer.alloc(6) + for (let i = 0; i < 6; i++) { + timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff)) + } + + return prefix + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12) +} + +/** Extract timestamp from an ascending ID. Does not work with descending IDs. */ +export function timestamp(id: string): number { + const prefix = id.split("_")[0] + const hex = id.slice(prefix.length + 1, prefix.length + 13) + const encoded = BigInt("0x" + hex) + return Number(encoded / BigInt(0x1000)) +} + +export * as Identifier from "./id" diff --git a/packages/opencode/src/kilocode/ripgrep-stream.ts b/packages/opencode/src/kilocode/kilo-ripgrep-stream.ts similarity index 95% rename from packages/opencode/src/kilocode/ripgrep-stream.ts rename to packages/opencode/src/kilocode/kilo-ripgrep-stream.ts index 7805d3f92e..eacedee8a5 100644 --- a/packages/opencode/src/kilocode/ripgrep-stream.ts +++ b/packages/opencode/src/kilocode/kilo-ripgrep-stream.ts @@ -1,6 +1,6 @@ import { StringDecoder } from "string_decoder" -export namespace RipgrepStream { +export namespace KiloRipgrepStream { export function decoder() { return new StringDecoder("utf8") } diff --git a/packages/opencode/src/kilocode/provider/provider.ts b/packages/opencode/src/kilocode/provider/provider.ts index 100b9defeb..552061a73c 100644 --- a/packages/opencode/src/kilocode/provider/provider.ts +++ b/packages/opencode/src/kilocode/provider/provider.ts @@ -6,12 +6,11 @@ // This module exports patch functions and data that the upstream provider.ts // calls at well-defined injection points (each marked with kilocode_change). -import { createKilo, type KiloProvider } from "@kilocode/kilo-gateway" +import { createKilo, type KiloProvider, AI_SDK_PROVIDERS, PROMPTS } from "@kilocode/kilo-gateway" import { DEFAULT_HEADERS } from "@/kilocode/const" import { AiSdkProvider, Prompt } from "@/provider/models" import { ProviderID, ModelID } from "@/provider/schema" -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import type { LanguageModelV3 } from "@ai-sdk/provider" import { mapValues, omit, pickBy } from "remeda" @@ -32,14 +31,14 @@ export const KILO_BUNDLED_PROVIDERS: Record Promise<(options: any) } // --------------------------------------------------------------------------- -// Model schema extensions (spread into Provider.Model via .extend()) +// Model schema extensions (spread into Provider.Model Schema.Struct) // --------------------------------------------------------------------------- export const KILO_MODEL_SCHEMA_EXTENSIONS = { - recommendedIndex: z.number().optional(), - prompt: Prompt.optional().catch(undefined), - isFree: z.boolean().optional(), - ai_sdk_provider: AiSdkProvider.optional(), + recommendedIndex: Schema.optional(Schema.Number), + prompt: Schema.optional(Schema.Literals(PROMPTS)), + isFree: Schema.optional(Schema.Boolean), + ai_sdk_provider: Schema.optional(Schema.Literals(AI_SDK_PROVIDERS)), } // --------------------------------------------------------------------------- diff --git a/packages/opencode/src/kilocode/server/instance.ts b/packages/opencode/src/kilocode/server/instance.ts index b374b4e61e..7a5e5db23e 100644 --- a/packages/opencode/src/kilocode/server/instance.ts +++ b/packages/opencode/src/kilocode/server/instance.ts @@ -5,13 +5,13 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" -import { TelemetryRoutes } from "../../server/instance/telemetry" +import { TelemetryRoutes } from "../../server/routes/instance/telemetry" import { CommitMessageRoutes } from "./routes/commit-message" -import { EnhancePromptRoutes } from "../../server/instance/enhance-prompt" -import { KilocodeRoutes } from "../../server/instance/kilocode" +import { EnhancePromptRoutes } from "../../server/routes/instance/enhance-prompt" +import { KilocodeRoutes } from "../../server/routes/instance/kilocode" import { PermissionKilocodeRoutes } from "../permission/routes" -import { RemoteRoutes } from "../../server/instance/remote" -import { NetworkRoutes } from "../../server/instance/network" +import { RemoteRoutes } from "../../server/routes/instance/remote" +import { NetworkRoutes } from "../../server/routes/instance/network" import { SuggestionRoutes } from "../suggestion/routes" import { createKiloRoutes } from "@kilocode/kilo-gateway" import { Auth } from "../../auth" diff --git a/packages/opencode/src/kilocode/tool/registry.ts b/packages/opencode/src/kilocode/tool/registry.ts index 84d96af273..a59b642c31 100644 --- a/packages/opencode/src/kilocode/tool/registry.ts +++ b/packages/opencode/src/kilocode/tool/registry.ts @@ -49,11 +49,6 @@ export namespace KiloToolRegistry { return [...(cfg.experimental?.codebase_search === true ? [tools.codebase] : []), tools.recall] } - /** Check whether exa-based tools (codesearch/websearch) are enabled for a provider */ - export function exa(providerID: ProviderID): boolean { - return providerID === ProviderID.kilo || Flag.KILO_ENABLE_EXA - } - /** Check for E2E LLM URL (uses KILO_E2E_LLM_URL env var) */ export function e2e(): boolean { return !!process.env["KILO_E2E_LLM_URL"] diff --git a/packages/opencode/src/lsp/diagnostic.ts b/packages/opencode/src/lsp/diagnostic.ts new file mode 100644 index 0000000000..4bc085e788 --- /dev/null +++ b/packages/opencode/src/lsp/diagnostic.ts @@ -0,0 +1,29 @@ +import * as LSPClient from "./client" + +const MAX_PER_FILE = 20 + +export function pretty(diagnostic: LSPClient.Diagnostic) { + const severityMap = { + 1: "ERROR", + 2: "WARN", + 3: "INFO", + 4: "HINT", + } + + const severity = severityMap[diagnostic.severity || 1] + const line = diagnostic.range.start.line + 1 + const col = diagnostic.range.start.character + 1 + + return `${severity} [${line}:${col}] ${diagnostic.message}` +} + +export function report(file: string, issues: LSPClient.Diagnostic[]) { + const errors = issues.filter((item) => item.severity === 1) + if (errors.length === 0) return "" + const limited = errors.slice(0, MAX_PER_FILE) + const more = errors.length - MAX_PER_FILE + const suffix = more > 0 ? `\n... and ${more} more` : "" + return `\n${limited.map(pretty).join("\n")}${suffix}\n` +} + +export * as Diagnostic from "./diagnostic" diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index f42b12f0f9..be2763b4e3 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -168,7 +168,7 @@ export const layer = Layer.effect( const servers: Record = {} - if (cfg.lsp === false) { + if (!cfg.lsp) { log.info("all LSPs are disabled") } else { for (const server of Object.values(LSPServer)) { @@ -177,25 +177,27 @@ export const layer = Layer.effect( filterExperimentalServers(servers) - for (const [name, item] of Object.entries(cfg.lsp ?? {})) { - const existing = servers[name] - if (item.disabled) { - log.info(`LSP server ${name} is disabled`) - delete servers[name] - continue - } - servers[name] = { - ...existing, - id: name, - root: existing?.root ?? (async () => Instance.directory), - extensions: item.extensions ?? existing?.extensions ?? [], - spawn: async (root) => ({ - process: lspspawn(item.command[0], item.command.slice(1), { - cwd: root, - env: { ...process.env, ...item.env }, + if (cfg.lsp !== true) { + for (const [name, item] of Object.entries(cfg.lsp)) { + const existing = servers[name] + if (item.disabled) { + log.info(`LSP server ${name} is disabled`) + delete servers[name] + continue + } + servers[name] = { + ...existing, + id: name, + root: existing?.root ?? (async () => Instance.directory), + extensions: item.extensions ?? existing?.extensions ?? [], + spawn: async (root) => ({ + process: lspspawn(item.command[0], item.command.slice(1), { + cwd: root, + env: { ...process.env, ...item.env }, + }), + initialization: item.initialization, }), - initialization: item.initialization, - }), + } } } @@ -457,12 +459,11 @@ export const layer = Layer.effect( const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) { const results = yield* runAll((client) => client.connection - .sendRequest("workspace/symbol", { query }) - .then((result: any) => result.filter((x: Symbol) => kinds.includes(x.kind))) - .then((result: any) => result.slice(0, 10)) - .catch(() => []), + .sendRequest("workspace/symbol", { query }) + .then((result) => result.filter((x) => kinds.includes(x.kind)).slice(0, 10)) + .catch(() => [] as Symbol[]), ) - return results.flat() as Symbol[] + return results.flat() }) const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) { @@ -523,30 +524,4 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) -export namespace Diagnostic { - const MAX_PER_FILE = 20 - - export function pretty(diagnostic: LSPClient.Diagnostic) { - const severityMap = { - 1: "ERROR", - 2: "WARN", - 3: "INFO", - 4: "HINT", - } - - const severity = severityMap[diagnostic.severity || 1] - const line = diagnostic.range.start.line + 1 - const col = diagnostic.range.start.character + 1 - - return `${severity} [${line}:${col}] ${diagnostic.message}` - } - - export function report(file: string, issues: LSPClient.Diagnostic[]) { - const errors = issues.filter((item) => item.severity === 1) - if (errors.length === 0) return "" - const limited = errors.slice(0, MAX_PER_FILE) - const more = errors.length - MAX_PER_FILE - const suffix = more > 0 ? `\n... and ${more} more` : "" - return `\n${limited.map(pretty).join("\n")}${suffix}\n` - } -} +export * as Diagnostic from "./diagnostic" diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index 85f9e1d8c9..efb046d7a7 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -4,141 +4,141 @@ import { Global } from "../global" import { Effect, Layer, Context } from "effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" -export namespace McpAuth { - export const Tokens = z.object({ - accessToken: z.string(), - refreshToken: z.string().optional(), - expiresAt: z.number().optional(), - scope: z.string().optional(), - }) - export type Tokens = z.infer +export const Tokens = z.object({ + accessToken: z.string(), + refreshToken: z.string().optional(), + expiresAt: z.number().optional(), + scope: z.string().optional(), +}) +export type Tokens = z.infer - export const ClientInfo = z.object({ - clientId: z.string(), - clientSecret: z.string().optional(), - clientIdIssuedAt: z.number().optional(), - clientSecretExpiresAt: z.number().optional(), - }) - export type ClientInfo = z.infer +export const ClientInfo = z.object({ + clientId: z.string(), + clientSecret: z.string().optional(), + clientIdIssuedAt: z.number().optional(), + clientSecretExpiresAt: z.number().optional(), +}) +export type ClientInfo = z.infer - export const Entry = z.object({ - tokens: Tokens.optional(), - clientInfo: ClientInfo.optional(), - codeVerifier: z.string().optional(), - oauthState: z.string().optional(), - serverUrl: z.string().optional(), - }) - export type Entry = z.infer +export const Entry = z.object({ + tokens: Tokens.optional(), + clientInfo: ClientInfo.optional(), + codeVerifier: z.string().optional(), + oauthState: z.string().optional(), + serverUrl: z.string().optional(), +}) +export type Entry = z.infer - const filepath = path.join(Global.Path.data, "mcp-auth.json") +const filepath = path.join(Global.Path.data, "mcp-auth.json") - export interface Interface { - readonly all: () => Effect.Effect> - readonly get: (mcpName: string) => Effect.Effect - readonly getForUrl: (mcpName: string, serverUrl: string) => Effect.Effect - readonly set: (mcpName: string, entry: Entry, serverUrl?: string) => Effect.Effect - readonly remove: (mcpName: string) => Effect.Effect - readonly updateTokens: (mcpName: string, tokens: Tokens, serverUrl?: string) => Effect.Effect - readonly updateClientInfo: (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) => Effect.Effect - readonly updateCodeVerifier: (mcpName: string, codeVerifier: string) => Effect.Effect - readonly clearCodeVerifier: (mcpName: string) => Effect.Effect - readonly updateOAuthState: (mcpName: string, oauthState: string) => Effect.Effect - readonly getOAuthState: (mcpName: string) => Effect.Effect - readonly clearOAuthState: (mcpName: string) => Effect.Effect - readonly isTokenExpired: (mcpName: string) => Effect.Effect - } - - export class Service extends Context.Service()("@opencode/McpAuth") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - - const all = Effect.fn("McpAuth.all")(function* () { - return yield* fs.readJson(filepath).pipe( - Effect.map((data) => data as Record), - Effect.catch(() => Effect.succeed({} as Record)), - ) - }) - - const get = Effect.fn("McpAuth.get")(function* (mcpName: string) { - const data = yield* all() - return data[mcpName] - }) - - const getForUrl = Effect.fn("McpAuth.getForUrl")(function* (mcpName: string, serverUrl: string) { - const entry = yield* get(mcpName) - if (!entry) return undefined - if (!entry.serverUrl) return undefined - if (entry.serverUrl !== serverUrl) return undefined - return entry - }) - - const set = Effect.fn("McpAuth.set")(function* (mcpName: string, entry: Entry, serverUrl?: string) { - const data = yield* all() - if (serverUrl) entry.serverUrl = serverUrl - yield* fs.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600).pipe(Effect.orDie) - }) - - const remove = Effect.fn("McpAuth.remove")(function* (mcpName: string) { - const data = yield* all() - delete data[mcpName] - yield* fs.writeJson(filepath, data, 0o600).pipe(Effect.orDie) - }) - - const updateField = (field: K, spanName: string) => - Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string, value: NonNullable, serverUrl?: string) { - const entry = (yield* get(mcpName)) ?? {} - entry[field] = value - yield* set(mcpName, entry, serverUrl) - }) - - const clearField = (field: K, spanName: string) => - Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) { - const entry = yield* get(mcpName) - if (entry) { - delete entry[field] - yield* set(mcpName, entry) - } - }) - - const updateTokens = updateField("tokens", "updateTokens") - const updateClientInfo = updateField("clientInfo", "updateClientInfo") - const updateCodeVerifier = updateField("codeVerifier", "updateCodeVerifier") - const updateOAuthState = updateField("oauthState", "updateOAuthState") - const clearCodeVerifier = clearField("codeVerifier", "clearCodeVerifier") - const clearOAuthState = clearField("oauthState", "clearOAuthState") - - const getOAuthState = Effect.fn("McpAuth.getOAuthState")(function* (mcpName: string) { - const entry = yield* get(mcpName) - return entry?.oauthState - }) - - const isTokenExpired = Effect.fn("McpAuth.isTokenExpired")(function* (mcpName: string) { - const entry = yield* get(mcpName) - if (!entry?.tokens) return null - if (!entry.tokens.expiresAt) return false - return entry.tokens.expiresAt < Date.now() / 1000 - }) - - return Service.of({ - all, - get, - getForUrl, - set, - remove, - updateTokens, - updateClientInfo, - updateCodeVerifier, - clearCodeVerifier, - updateOAuthState, - getOAuthState, - clearOAuthState, - isTokenExpired, - }) - }), - ) - - export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) +export interface Interface { + readonly all: () => Effect.Effect> + readonly get: (mcpName: string) => Effect.Effect + readonly getForUrl: (mcpName: string, serverUrl: string) => Effect.Effect + readonly set: (mcpName: string, entry: Entry, serverUrl?: string) => Effect.Effect + readonly remove: (mcpName: string) => Effect.Effect + readonly updateTokens: (mcpName: string, tokens: Tokens, serverUrl?: string) => Effect.Effect + readonly updateClientInfo: (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) => Effect.Effect + readonly updateCodeVerifier: (mcpName: string, codeVerifier: string) => Effect.Effect + readonly clearCodeVerifier: (mcpName: string) => Effect.Effect + readonly updateOAuthState: (mcpName: string, oauthState: string) => Effect.Effect + readonly getOAuthState: (mcpName: string) => Effect.Effect + readonly clearOAuthState: (mcpName: string) => Effect.Effect + readonly isTokenExpired: (mcpName: string) => Effect.Effect } + +export class Service extends Context.Service()("@opencode/McpAuth") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + + const all = Effect.fn("McpAuth.all")(function* () { + return yield* fs.readJson(filepath).pipe( + Effect.map((data) => data as Record), + Effect.catch(() => Effect.succeed({} as Record)), + ) + }) + + const get = Effect.fn("McpAuth.get")(function* (mcpName: string) { + const data = yield* all() + return data[mcpName] + }) + + const getForUrl = Effect.fn("McpAuth.getForUrl")(function* (mcpName: string, serverUrl: string) { + const entry = yield* get(mcpName) + if (!entry) return undefined + if (!entry.serverUrl) return undefined + if (entry.serverUrl !== serverUrl) return undefined + return entry + }) + + const set = Effect.fn("McpAuth.set")(function* (mcpName: string, entry: Entry, serverUrl?: string) { + const data = yield* all() + if (serverUrl) entry.serverUrl = serverUrl + yield* fs.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600).pipe(Effect.orDie) + }) + + const remove = Effect.fn("McpAuth.remove")(function* (mcpName: string) { + const data = yield* all() + delete data[mcpName] + yield* fs.writeJson(filepath, data, 0o600).pipe(Effect.orDie) + }) + + const updateField = (field: K, spanName: string) => + Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string, value: NonNullable, serverUrl?: string) { + const entry = (yield* get(mcpName)) ?? {} + entry[field] = value + yield* set(mcpName, entry, serverUrl) + }) + + const clearField = (field: K, spanName: string) => + Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) { + const entry = yield* get(mcpName) + if (entry) { + delete entry[field] + yield* set(mcpName, entry) + } + }) + + const updateTokens = updateField("tokens", "updateTokens") + const updateClientInfo = updateField("clientInfo", "updateClientInfo") + const updateCodeVerifier = updateField("codeVerifier", "updateCodeVerifier") + const updateOAuthState = updateField("oauthState", "updateOAuthState") + const clearCodeVerifier = clearField("codeVerifier", "clearCodeVerifier") + const clearOAuthState = clearField("oauthState", "clearOAuthState") + + const getOAuthState = Effect.fn("McpAuth.getOAuthState")(function* (mcpName: string) { + const entry = yield* get(mcpName) + return entry?.oauthState + }) + + const isTokenExpired = Effect.fn("McpAuth.isTokenExpired")(function* (mcpName: string) { + const entry = yield* get(mcpName) + if (!entry?.tokens) return null + if (!entry.tokens.expiresAt) return false + return entry.tokens.expiresAt < Date.now() / 1000 + }) + + return Service.of({ + all, + get, + getForUrl, + set, + remove, + updateTokens, + updateClientInfo, + updateCodeVerifier, + clearCodeVerifier, + updateOAuthState, + getOAuthState, + clearOAuthState, + isTokenExpired, + }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) + +export * as McpAuth from "./auth" diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index ffb896fb1d..efbd3f6295 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -62,193 +62,193 @@ interface PendingAuth { timeout: ReturnType } -export namespace McpOAuthCallback { - let server: ReturnType | undefined - const pendingAuths = new Map() - // Reverse index: mcpName → oauthState, so cancelPending(mcpName) can - // find the right entry in pendingAuths (which is keyed by oauthState). - const mcpNameToState = new Map() +let server: ReturnType | undefined +const pendingAuths = new Map() +// Reverse index: mcpName → oauthState, so cancelPending(mcpName) can +// find the right entry in pendingAuths (which is keyed by oauthState). +const mcpNameToState = new Map() - const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes +const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes - function cleanupStateIndex(oauthState: string) { - for (const [name, state] of mcpNameToState) { - if (state === oauthState) { - mcpNameToState.delete(name) - break - } +function cleanupStateIndex(oauthState: string) { + for (const [name, state] of mcpNameToState) { + if (state === oauthState) { + mcpNameToState.delete(name) + break } } - - function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) { - const url = new URL(req.url || "/", `http://localhost:${currentPort}`) - - if (url.pathname !== currentPath) { - res.writeHead(404) - res.end("Not found") - return - } - - const code = url.searchParams.get("code") - const state = url.searchParams.get("state") - const error = url.searchParams.get("error") - const errorDescription = url.searchParams.get("error_description") - - log.info("received oauth callback", { hasCode: !!code, state, error }) - - // Enforce state parameter presence - if (!state) { - const errorMsg = "Missing required state parameter - potential CSRF attack" - log.error("oauth callback missing state parameter", { url: url.toString() }) - res.writeHead(400, { "Content-Type": "text/html" }) - res.end(HTML_ERROR(errorMsg)) - return - } - - if (error) { - const errorMsg = errorDescription || error - if (pendingAuths.has(state)) { - const pending = pendingAuths.get(state)! - clearTimeout(pending.timeout) - pendingAuths.delete(state) - cleanupStateIndex(state) - pending.reject(new Error(errorMsg)) - } - res.writeHead(200, { "Content-Type": "text/html" }) - res.end(HTML_ERROR(errorMsg)) - return - } - - if (!code) { - res.writeHead(400, { "Content-Type": "text/html" }) - res.end(HTML_ERROR("No authorization code provided")) - return - } - - // Validate state parameter - if (!pendingAuths.has(state)) { - const errorMsg = "Invalid or expired state parameter - potential CSRF attack" - log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) }) - res.writeHead(400, { "Content-Type": "text/html" }) - res.end(HTML_ERROR(errorMsg)) - return - } - - const pending = pendingAuths.get(state)! - - clearTimeout(pending.timeout) - pendingAuths.delete(state) - cleanupStateIndex(state) - pending.resolve(code) - - res.writeHead(200, { "Content-Type": "text/html" }) - res.end(HTML_SUCCESS) - } - - export async function ensureRunning(redirectUri?: string): Promise { - // Parse the redirect URI to get port and path (uses defaults if not provided) - const { port, path } = parseRedirectUri(redirectUri) - - // If server is running on a different port/path, stop it first - if (server && (currentPort !== port || currentPath !== path)) { - log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port }) - await stop() - } - - if (server) return - - const running = await isPortInUse(port) - if (running) { - log.info("oauth callback server already running on another instance", { port }) - return - } - - currentPort = port - currentPath = path - - server = createServer(handleRequest) - await new Promise((resolve, reject) => { - // kilocode_change start - EADDRINUSE can still fire when another process - // races us between isPortInUse() and listen() (notably across parallel - // bun test subprocesses). Treat it as "another instance owns the port", - // matching the isPortInUse() branch above instead of crashing. - const onError = (err: Error & { code?: string }) => { - if (err.code === "EADDRINUSE") { - log.info("oauth callback port bound by another instance", { port: currentPort }) - server?.close() - server = undefined - resolve() - return - } - reject(err) - } - server!.on("error", onError) - // kilocode_change end - server!.listen(currentPort, () => { - server!.off("error", onError) // kilocode_change - log.info("oauth callback server started", { port: currentPort, path: currentPath }) - resolve() - }) - }) - } - - export function waitForCallback(oauthState: string, mcpName?: string): Promise { - if (mcpName) mcpNameToState.set(mcpName, oauthState) - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - if (pendingAuths.has(oauthState)) { - pendingAuths.delete(oauthState) - if (mcpName) mcpNameToState.delete(mcpName) - reject(new Error("OAuth callback timeout - authorization took too long")) - } - }, CALLBACK_TIMEOUT_MS) - - pendingAuths.set(oauthState, { resolve, reject, timeout }) - }) - } - - export function cancelPending(mcpName: string): void { - // Look up the oauthState for this mcpName via the reverse index - const oauthState = mcpNameToState.get(mcpName) - const key = oauthState ?? mcpName - const pending = pendingAuths.get(key) - if (pending) { - clearTimeout(pending.timeout) - pendingAuths.delete(key) - mcpNameToState.delete(mcpName) - pending.reject(new Error("Authorization cancelled")) - } - } - - export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise { - return new Promise((resolve) => { - const socket = createConnection(port, "127.0.0.1") - socket.on("connect", () => { - socket.destroy() - resolve(true) - }) - socket.on("error", () => { - resolve(false) - }) - }) - } - - export async function stop(): Promise { - if (server) { - await new Promise((resolve) => server!.close(() => resolve())) - server = undefined - log.info("oauth callback server stopped") - } - - for (const [_name, pending] of pendingAuths) { - clearTimeout(pending.timeout) - pending.reject(new Error("OAuth callback server stopped")) - } - pendingAuths.clear() - mcpNameToState.clear() - } - - export function isRunning(): boolean { - return server !== undefined - } } + +function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) { + const url = new URL(req.url || "/", `http://localhost:${currentPort}`) + + if (url.pathname !== currentPath) { + res.writeHead(404) + res.end("Not found") + return + } + + const code = url.searchParams.get("code") + const state = url.searchParams.get("state") + const error = url.searchParams.get("error") + const errorDescription = url.searchParams.get("error_description") + + log.info("received oauth callback", { hasCode: !!code, state, error }) + + // Enforce state parameter presence + if (!state) { + const errorMsg = "Missing required state parameter - potential CSRF attack" + log.error("oauth callback missing state parameter", { url: url.toString() }) + res.writeHead(400, { "Content-Type": "text/html" }) + res.end(HTML_ERROR(errorMsg)) + return + } + + if (error) { + const errorMsg = errorDescription || error + if (pendingAuths.has(state)) { + const pending = pendingAuths.get(state)! + clearTimeout(pending.timeout) + pendingAuths.delete(state) + cleanupStateIndex(state) + pending.reject(new Error(errorMsg)) + } + res.writeHead(200, { "Content-Type": "text/html" }) + res.end(HTML_ERROR(errorMsg)) + return + } + + if (!code) { + res.writeHead(400, { "Content-Type": "text/html" }) + res.end(HTML_ERROR("No authorization code provided")) + return + } + + // Validate state parameter + if (!pendingAuths.has(state)) { + const errorMsg = "Invalid or expired state parameter - potential CSRF attack" + log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) }) + res.writeHead(400, { "Content-Type": "text/html" }) + res.end(HTML_ERROR(errorMsg)) + return + } + + const pending = pendingAuths.get(state)! + + clearTimeout(pending.timeout) + pendingAuths.delete(state) + cleanupStateIndex(state) + pending.resolve(code) + + res.writeHead(200, { "Content-Type": "text/html" }) + res.end(HTML_SUCCESS) +} + +export async function ensureRunning(redirectUri?: string): Promise { + // Parse the redirect URI to get port and path (uses defaults if not provided) + const { port, path } = parseRedirectUri(redirectUri) + + // If server is running on a different port/path, stop it first + if (server && (currentPort !== port || currentPath !== path)) { + log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port }) + await stop() + } + + if (server) return + + const running = await isPortInUse(port) + if (running) { + log.info("oauth callback server already running on another instance", { port }) + return + } + + currentPort = port + currentPath = path + + server = createServer(handleRequest) + await new Promise((resolve, reject) => { + // kilocode_change start - EADDRINUSE can still fire when another process + // races us between isPortInUse() and listen() (notably across parallel + // bun test subprocesses). Treat it as "another instance owns the port", + // matching the isPortInUse() branch above instead of crashing. + const onError = (err: Error & { code?: string }) => { + if (err.code === "EADDRINUSE") { + log.info("oauth callback port bound by another instance", { port: currentPort }) + server?.close() + server = undefined + resolve() + return + } + reject(err) + } + server!.on("error", onError) + // kilocode_change end + server!.listen(currentPort, () => { + server!.off("error", onError) // kilocode_change + log.info("oauth callback server started", { port: currentPort, path: currentPath }) + resolve() + }) + }) +} + +export function waitForCallback(oauthState: string, mcpName?: string): Promise { + if (mcpName) mcpNameToState.set(mcpName, oauthState) + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + if (pendingAuths.has(oauthState)) { + pendingAuths.delete(oauthState) + if (mcpName) mcpNameToState.delete(mcpName) + reject(new Error("OAuth callback timeout - authorization took too long")) + } + }, CALLBACK_TIMEOUT_MS) + + pendingAuths.set(oauthState, { resolve, reject, timeout }) + }) +} + +export function cancelPending(mcpName: string): void { + // Look up the oauthState for this mcpName via the reverse index + const oauthState = mcpNameToState.get(mcpName) + const key = oauthState ?? mcpName + const pending = pendingAuths.get(key) + if (pending) { + clearTimeout(pending.timeout) + pendingAuths.delete(key) + mcpNameToState.delete(mcpName) + pending.reject(new Error("Authorization cancelled")) + } +} + +export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise { + return new Promise((resolve) => { + const socket = createConnection(port, "127.0.0.1") + socket.on("connect", () => { + socket.destroy() + resolve(true) + }) + socket.on("error", () => { + resolve(false) + }) + }) +} + +export async function stop(): Promise { + if (server) { + await new Promise((resolve) => server!.close(() => resolve())) + server = undefined + log.info("oauth callback server stopped") + } + + for (const [_name, pending] of pendingAuths) { + clearTimeout(pending.timeout) + pending.reject(new Error("OAuth callback server stopped")) + } + pendingAuths.clear() + mcpNameToState.clear() +} + +export function isRunning(): boolean { + return server !== undefined +} + +export * as McpOAuthCallback from "./oauth-callback" diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts index 174df12974..425b27f420 100644 --- a/packages/opencode/src/npm/index.ts +++ b/packages/opencode/src/npm/index.ts @@ -124,8 +124,17 @@ export async function install(dir: string) { return } - const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({})) - const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({})) + type PackageDeps = Record + type PackageJson = { + dependencies?: PackageDeps + devDependencies?: PackageDeps + peerDependencies?: PackageDeps + optionalDependencies?: PackageDeps + } + const pkg: PackageJson = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({})) + const lock: { packages?: Record } = await Filesystem.readJson<{ + packages?: Record + }>(path.join(dir, "package-lock.json")).catch(() => ({})) const declared = new Set([ ...Object.keys(pkg.dependencies || {}), diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index cec24614d8..19e1d7555b 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -1 +1,680 @@ -export * as Patch from "./patch" +import z from "zod" +import * as path from "path" +import * as fs from "fs/promises" +import { readFileSync } from "fs" +import { Log } from "../util" + +const log = Log.create({ service: "patch" }) + +// Schema definitions +export const PatchSchema = z.object({ + patchText: z.string().describe("The full patch text that describes all changes to be made"), +}) + +export type PatchParams = z.infer + +// Core types matching the Rust implementation +export interface ApplyPatchArgs { + patch: string + hunks: Hunk[] + workdir?: string +} + +export type Hunk = + | { type: "add"; path: string; contents: string } + | { type: "delete"; path: string } + | { type: "update"; path: string; move_path?: string; chunks: UpdateFileChunk[] } + +export interface UpdateFileChunk { + old_lines: string[] + new_lines: string[] + change_context?: string + is_end_of_file?: boolean +} + +export interface ApplyPatchAction { + changes: Map + patch: string + cwd: string +} + +export type ApplyPatchFileChange = + | { type: "add"; content: string } + | { type: "delete"; content: string } + | { type: "update"; unified_diff: string; move_path?: string; new_content: string } + +export interface AffectedPaths { + added: string[] + modified: string[] + deleted: string[] +} + +export enum ApplyPatchError { + ParseError = "ParseError", + IoError = "IoError", + ComputeReplacements = "ComputeReplacements", + ImplicitInvocation = "ImplicitInvocation", +} + +export enum MaybeApplyPatch { + Body = "Body", + ShellParseError = "ShellParseError", + PatchParseError = "PatchParseError", + NotApplyPatch = "NotApplyPatch", +} + +export enum MaybeApplyPatchVerified { + Body = "Body", + ShellParseError = "ShellParseError", + CorrectnessError = "CorrectnessError", + NotApplyPatch = "NotApplyPatch", +} + +// Parser implementation +function parsePatchHeader( + lines: string[], + startIdx: number, +): { filePath: string; movePath?: string; nextIdx: number } | null { + const line = lines[startIdx] + + if (line.startsWith("*** Add File:")) { + const filePath = line.slice("*** Add File:".length).trim() + return filePath ? { filePath, nextIdx: startIdx + 1 } : null + } + + if (line.startsWith("*** Delete File:")) { + const filePath = line.slice("*** Delete File:".length).trim() + return filePath ? { filePath, nextIdx: startIdx + 1 } : null + } + + if (line.startsWith("*** Update File:")) { + const filePath = line.slice("*** Update File:".length).trim() + let movePath: string | undefined + let nextIdx = startIdx + 1 + + // Check for move directive + if (nextIdx < lines.length && lines[nextIdx].startsWith("*** Move to:")) { + movePath = lines[nextIdx].slice("*** Move to:".length).trim() + nextIdx++ + } + + return filePath ? { filePath, movePath, nextIdx } : null + } + + return null +} + +function parseUpdateFileChunks(lines: string[], startIdx: number): { chunks: UpdateFileChunk[]; nextIdx: number } { + const chunks: UpdateFileChunk[] = [] + let i = startIdx + + while (i < lines.length && !lines[i].startsWith("***")) { + if (lines[i].startsWith("@@")) { + // Parse context line + const contextLine = lines[i].substring(2).trim() + i++ + + const oldLines: string[] = [] + const newLines: string[] = [] + let isEndOfFile = false + + // Parse change lines + while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) { + const changeLine = lines[i] + + if (changeLine === "*** End of File") { + isEndOfFile = true + i++ + break + } + + if (changeLine.startsWith(" ")) { + // Keep line - appears in both old and new + const content = changeLine.substring(1) + oldLines.push(content) + newLines.push(content) + } else if (changeLine.startsWith("-")) { + // Remove line - only in old + oldLines.push(changeLine.substring(1)) + } else if (changeLine.startsWith("+")) { + // Add line - only in new + newLines.push(changeLine.substring(1)) + } + + i++ + } + + chunks.push({ + old_lines: oldLines, + new_lines: newLines, + change_context: contextLine || undefined, + is_end_of_file: isEndOfFile || undefined, + }) + } else { + i++ + } + } + + return { chunks, nextIdx: i } +} + +function parseAddFileContent(lines: string[], startIdx: number): { content: string; nextIdx: number } { + let content = "" + let i = startIdx + + while (i < lines.length && !lines[i].startsWith("***")) { + if (lines[i].startsWith("+")) { + content += lines[i].substring(1) + "\n" + } + i++ + } + + // Remove trailing newline + if (content.endsWith("\n")) { + content = content.slice(0, -1) + } + + return { content, nextIdx: i } +} + +function stripHeredoc(input: string): string { + // Match heredoc patterns like: cat <<'EOF'\n...\nEOF or < line.trim() === beginMarker) + const endIdx = lines.findIndex((line) => line.trim() === endMarker) + + if (beginIdx === -1 || endIdx === -1 || beginIdx >= endIdx) { + throw new Error("Invalid patch format: missing Begin/End markers") + } + + // Parse content between markers + i = beginIdx + 1 + + while (i < endIdx) { + const header = parsePatchHeader(lines, i) + if (!header) { + i++ + continue + } + + if (lines[i].startsWith("*** Add File:")) { + const { content, nextIdx } = parseAddFileContent(lines, header.nextIdx) + hunks.push({ + type: "add", + path: header.filePath, + contents: content, + }) + i = nextIdx + } else if (lines[i].startsWith("*** Delete File:")) { + hunks.push({ + type: "delete", + path: header.filePath, + }) + i = header.nextIdx + } else if (lines[i].startsWith("*** Update File:")) { + const { chunks, nextIdx } = parseUpdateFileChunks(lines, header.nextIdx) + hunks.push({ + type: "update", + path: header.filePath, + move_path: header.movePath, + chunks, + }) + i = nextIdx + } else { + i++ + } + } + + return { hunks } +} + +// Apply patch functionality +export function maybeParseApplyPatch( + argv: string[], +): + | { type: MaybeApplyPatch.Body; args: ApplyPatchArgs } + | { type: MaybeApplyPatch.PatchParseError; error: Error } + | { type: MaybeApplyPatch.NotApplyPatch } { + const APPLY_PATCH_COMMANDS = ["apply_patch", "applypatch"] + + // Direct invocation: apply_patch + if (argv.length === 2 && APPLY_PATCH_COMMANDS.includes(argv[0])) { + try { + const { hunks } = parsePatch(argv[1]) + return { + type: MaybeApplyPatch.Body, + args: { + patch: argv[1], + hunks, + }, + } + } catch (error) { + return { + type: MaybeApplyPatch.PatchParseError, + error: error as Error, + } + } + } + + // Bash heredoc form: bash -lc 'apply_patch <<"EOF" ...' + if (argv.length === 3 && argv[0] === "bash" && argv[1] === "-lc") { + // Simple extraction - in real implementation would need proper bash parsing + const script = argv[2] + const heredocMatch = script.match(/apply_patch\s*<<['"](\w+)['"]\s*\n([\s\S]*?)\n\1/) + + if (heredocMatch) { + const patchContent = heredocMatch[2] + try { + const { hunks } = parsePatch(patchContent) + return { + type: MaybeApplyPatch.Body, + args: { + patch: patchContent, + hunks, + }, + } + } catch (error) { + return { + type: MaybeApplyPatch.PatchParseError, + error: error as Error, + } + } + } + } + + return { type: MaybeApplyPatch.NotApplyPatch } +} + +// File content manipulation +interface ApplyPatchFileUpdate { + unified_diff: string + content: string +} + +export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate { + // Read original file content + let originalContent: string + try { + originalContent = readFileSync(filePath, "utf-8") + } catch (error) { + throw new Error(`Failed to read file ${filePath}: ${error}`, { cause: error }) + } + + let originalLines = originalContent.split("\n") + + // Drop trailing empty element for consistent line counting + if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") { + originalLines.pop() + } + + const replacements = computeReplacements(originalLines, filePath, chunks) + let newLines = applyReplacements(originalLines, replacements) + + // Ensure trailing newline + if (newLines.length === 0 || newLines[newLines.length - 1] !== "") { + newLines.push("") + } + + const newContent = newLines.join("\n") + + // Generate unified diff + const unifiedDiff = generateUnifiedDiff(originalContent, newContent) + + return { + unified_diff: unifiedDiff, + content: newContent, + } +} + +function computeReplacements( + originalLines: string[], + filePath: string, + chunks: UpdateFileChunk[], +): Array<[number, number, string[]]> { + const replacements: Array<[number, number, string[]]> = [] + let lineIndex = 0 + + for (const chunk of chunks) { + // Handle context-based seeking + if (chunk.change_context) { + const contextIdx = seekSequence(originalLines, [chunk.change_context], lineIndex) + if (contextIdx === -1) { + throw new Error(`Failed to find context '${chunk.change_context}' in ${filePath}`) + } + lineIndex = contextIdx + 1 + } + + // Handle pure addition (no old lines) + if (chunk.old_lines.length === 0) { + const insertionIdx = + originalLines.length > 0 && originalLines[originalLines.length - 1] === "" + ? originalLines.length - 1 + : originalLines.length + replacements.push([insertionIdx, 0, chunk.new_lines]) + continue + } + + // Try to match old lines in the file + let pattern = chunk.old_lines + let newSlice = chunk.new_lines + let found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file) + + // Retry without trailing empty line if not found + if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") { + pattern = pattern.slice(0, -1) + if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") { + newSlice = newSlice.slice(0, -1) + } + found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file) + } + + if (found !== -1) { + replacements.push([found, pattern.length, newSlice]) + lineIndex = found + pattern.length + } else { + throw new Error(`Failed to find expected lines in ${filePath}:\n${chunk.old_lines.join("\n")}`) + } + } + + // Sort replacements by index to apply in order + replacements.sort((a, b) => a[0] - b[0]) + + return replacements +} + +function applyReplacements(lines: string[], replacements: Array<[number, number, string[]]>): string[] { + // Apply replacements in reverse order to avoid index shifting + const result = [...lines] + + for (let i = replacements.length - 1; i >= 0; i--) { + const [startIdx, oldLen, newSegment] = replacements[i] + + // Remove old lines + result.splice(startIdx, oldLen) + + // Insert new lines + for (let j = 0; j < newSegment.length; j++) { + result.splice(startIdx + j, 0, newSegment[j]) + } + } + + return result +} + +// Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode) +function normalizeUnicode(str: string): string { + return str + .replace(/[\u2018\u2019\u201A\u201B]/g, "'") // single quotes + .replace(/[\u201C\u201D\u201E\u201F]/g, '"') // double quotes + .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-") // dashes + .replace(/\u2026/g, "...") // ellipsis + .replace(/\u00A0/g, " ") // non-breaking space +} + +type Comparator = (a: string, b: string) => boolean + +function tryMatch(lines: string[], pattern: string[], startIndex: number, compare: Comparator, eof: boolean): number { + // If EOF anchor, try matching from end of file first + if (eof) { + const fromEnd = lines.length - pattern.length + if (fromEnd >= startIndex) { + let matches = true + for (let j = 0; j < pattern.length; j++) { + if (!compare(lines[fromEnd + j], pattern[j])) { + matches = false + break + } + } + if (matches) return fromEnd + } + } + + // Forward search from startIndex + for (let i = startIndex; i <= lines.length - pattern.length; i++) { + let matches = true + for (let j = 0; j < pattern.length; j++) { + if (!compare(lines[i + j], pattern[j])) { + matches = false + break + } + } + if (matches) return i + } + + return -1 +} + +function seekSequence(lines: string[], pattern: string[], startIndex: number, eof = false): number { + if (pattern.length === 0) return -1 + + // Pass 1: exact match + const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof) + if (exact !== -1) return exact + + // Pass 2: rstrip (trim trailing whitespace) + const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof) + if (rstrip !== -1) return rstrip + + // Pass 3: trim (both ends) + const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof) + if (trim !== -1) return trim + + // Pass 4: normalized (Unicode punctuation to ASCII) + const normalized = tryMatch( + lines, + pattern, + startIndex, + (a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()), + eof, + ) + return normalized +} + +function generateUnifiedDiff(oldContent: string, newContent: string): string { + const oldLines = oldContent.split("\n") + const newLines = newContent.split("\n") + + // Simple diff generation - in a real implementation you'd use a proper diff algorithm + let diff = "@@ -1 +1 @@\n" + + // Find changes (simplified approach) + const maxLen = Math.max(oldLines.length, newLines.length) + let hasChanges = false + + for (let i = 0; i < maxLen; i++) { + const oldLine = oldLines[i] || "" + const newLine = newLines[i] || "" + + if (oldLine !== newLine) { + if (oldLine) diff += `-${oldLine}\n` + if (newLine) diff += `+${newLine}\n` + hasChanges = true + } else if (oldLine) { + diff += ` ${oldLine}\n` + } + } + + return hasChanges ? diff : "" +} + +// Apply hunks to filesystem +export async function applyHunksToFiles(hunks: Hunk[]): Promise { + if (hunks.length === 0) { + throw new Error("No files were modified.") + } + + const added: string[] = [] + const modified: string[] = [] + const deleted: string[] = [] + + for (const hunk of hunks) { + switch (hunk.type) { + case "add": + // Create parent directories + const addDir = path.dirname(hunk.path) + if (addDir !== "." && addDir !== "/") { + await fs.mkdir(addDir, { recursive: true }) + } + + await fs.writeFile(hunk.path, hunk.contents, "utf-8") + added.push(hunk.path) + log.info(`Added file: ${hunk.path}`) + break + + case "delete": + await fs.unlink(hunk.path) + deleted.push(hunk.path) + log.info(`Deleted file: ${hunk.path}`) + break + + case "update": + const fileUpdate = deriveNewContentsFromChunks(hunk.path, hunk.chunks) + + if (hunk.move_path) { + // Handle file move + const moveDir = path.dirname(hunk.move_path) + if (moveDir !== "." && moveDir !== "/") { + await fs.mkdir(moveDir, { recursive: true }) + } + + await fs.writeFile(hunk.move_path, fileUpdate.content, "utf-8") + await fs.unlink(hunk.path) + modified.push(hunk.move_path) + log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`) + } else { + // Regular update + await fs.writeFile(hunk.path, fileUpdate.content, "utf-8") + modified.push(hunk.path) + log.info(`Updated file: ${hunk.path}`) + } + break + } + } + + return { added, modified, deleted } +} + +// Main patch application function +export async function applyPatch(patchText: string): Promise { + const { hunks } = parsePatch(patchText) + return applyHunksToFiles(hunks) +} + +// Async version of maybeParseApplyPatchVerified +export async function maybeParseApplyPatchVerified( + argv: string[], + cwd: string, +): Promise< + | { type: MaybeApplyPatchVerified.Body; action: ApplyPatchAction } + | { type: MaybeApplyPatchVerified.CorrectnessError; error: Error } + | { type: MaybeApplyPatchVerified.NotApplyPatch } +> { + // Detect implicit patch invocation (raw patch without apply_patch command) + if (argv.length === 1) { + try { + parsePatch(argv[0]) + return { + type: MaybeApplyPatchVerified.CorrectnessError, + error: new Error(ApplyPatchError.ImplicitInvocation), + } + } catch { + // Not a patch, continue + } + } + + const result = maybeParseApplyPatch(argv) + + switch (result.type) { + case MaybeApplyPatch.Body: + const { args } = result + const effectiveCwd = args.workdir ? path.resolve(cwd, args.workdir) : cwd + const changes = new Map() + + for (const hunk of args.hunks) { + const resolvedPath = path.resolve( + effectiveCwd, + hunk.type === "update" && hunk.move_path ? hunk.move_path : hunk.path, + ) + + switch (hunk.type) { + case "add": + changes.set(resolvedPath, { + type: "add", + content: hunk.contents, + }) + break + + case "delete": + // For delete, we need to read the current content + const deletePath = path.resolve(effectiveCwd, hunk.path) + try { + const content = await fs.readFile(deletePath, "utf-8") + changes.set(resolvedPath, { + type: "delete", + content, + }) + } catch { + return { + type: MaybeApplyPatchVerified.CorrectnessError, + error: new Error(`Failed to read file for deletion: ${deletePath}`), + } + } + break + + case "update": + const updatePath = path.resolve(effectiveCwd, hunk.path) + try { + const fileUpdate = deriveNewContentsFromChunks(updatePath, hunk.chunks) + changes.set(resolvedPath, { + type: "update", + unified_diff: fileUpdate.unified_diff, + move_path: hunk.move_path ? path.resolve(effectiveCwd, hunk.move_path) : undefined, + new_content: fileUpdate.content, + }) + } catch (error) { + return { + type: MaybeApplyPatchVerified.CorrectnessError, + error: error as Error, + } + } + break + } + } + + return { + type: MaybeApplyPatchVerified.Body, + action: { + changes, + patch: args.patch, + cwd: effectiveCwd, + }, + } + + case MaybeApplyPatch.PatchParseError: + return { + type: MaybeApplyPatchVerified.CorrectnessError, + error: result.error, + } + + case MaybeApplyPatch.NotApplyPatch: + return { type: MaybeApplyPatchVerified.NotApplyPatch } + } +} + +export * as Patch from "." diff --git a/packages/opencode/src/patch/patch.ts b/packages/opencode/src/patch/patch.ts deleted file mode 100644 index 1dc99b4da9..0000000000 --- a/packages/opencode/src/patch/patch.ts +++ /dev/null @@ -1,678 +0,0 @@ -import z from "zod" -import * as path from "path" -import * as fs from "fs/promises" -import { readFileSync } from "fs" -import { Log } from "../util" - -const log = Log.create({ service: "patch" }) - -// Schema definitions -export const PatchSchema = z.object({ - patchText: z.string().describe("The full patch text that describes all changes to be made"), -}) - -export type PatchParams = z.infer - -// Core types matching the Rust implementation -export interface ApplyPatchArgs { - patch: string - hunks: Hunk[] - workdir?: string -} - -export type Hunk = - | { type: "add"; path: string; contents: string } - | { type: "delete"; path: string } - | { type: "update"; path: string; move_path?: string; chunks: UpdateFileChunk[] } - -export interface UpdateFileChunk { - old_lines: string[] - new_lines: string[] - change_context?: string - is_end_of_file?: boolean -} - -export interface ApplyPatchAction { - changes: Map - patch: string - cwd: string -} - -export type ApplyPatchFileChange = - | { type: "add"; content: string } - | { type: "delete"; content: string } - | { type: "update"; unified_diff: string; move_path?: string; new_content: string } - -export interface AffectedPaths { - added: string[] - modified: string[] - deleted: string[] -} - -export enum ApplyPatchError { - ParseError = "ParseError", - IoError = "IoError", - ComputeReplacements = "ComputeReplacements", - ImplicitInvocation = "ImplicitInvocation", -} - -export enum MaybeApplyPatch { - Body = "Body", - ShellParseError = "ShellParseError", - PatchParseError = "PatchParseError", - NotApplyPatch = "NotApplyPatch", -} - -export enum MaybeApplyPatchVerified { - Body = "Body", - ShellParseError = "ShellParseError", - CorrectnessError = "CorrectnessError", - NotApplyPatch = "NotApplyPatch", -} - -// Parser implementation -function parsePatchHeader( - lines: string[], - startIdx: number, -): { filePath: string; movePath?: string; nextIdx: number } | null { - const line = lines[startIdx] - - if (line.startsWith("*** Add File:")) { - const filePath = line.slice("*** Add File:".length).trim() - return filePath ? { filePath, nextIdx: startIdx + 1 } : null - } - - if (line.startsWith("*** Delete File:")) { - const filePath = line.slice("*** Delete File:".length).trim() - return filePath ? { filePath, nextIdx: startIdx + 1 } : null - } - - if (line.startsWith("*** Update File:")) { - const filePath = line.slice("*** Update File:".length).trim() - let movePath: string | undefined - let nextIdx = startIdx + 1 - - // Check for move directive - if (nextIdx < lines.length && lines[nextIdx].startsWith("*** Move to:")) { - movePath = lines[nextIdx].slice("*** Move to:".length).trim() - nextIdx++ - } - - return filePath ? { filePath, movePath, nextIdx } : null - } - - return null -} - -function parseUpdateFileChunks(lines: string[], startIdx: number): { chunks: UpdateFileChunk[]; nextIdx: number } { - const chunks: UpdateFileChunk[] = [] - let i = startIdx - - while (i < lines.length && !lines[i].startsWith("***")) { - if (lines[i].startsWith("@@")) { - // Parse context line - const contextLine = lines[i].substring(2).trim() - i++ - - const oldLines: string[] = [] - const newLines: string[] = [] - let isEndOfFile = false - - // Parse change lines - while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) { - const changeLine = lines[i] - - if (changeLine === "*** End of File") { - isEndOfFile = true - i++ - break - } - - if (changeLine.startsWith(" ")) { - // Keep line - appears in both old and new - const content = changeLine.substring(1) - oldLines.push(content) - newLines.push(content) - } else if (changeLine.startsWith("-")) { - // Remove line - only in old - oldLines.push(changeLine.substring(1)) - } else if (changeLine.startsWith("+")) { - // Add line - only in new - newLines.push(changeLine.substring(1)) - } - - i++ - } - - chunks.push({ - old_lines: oldLines, - new_lines: newLines, - change_context: contextLine || undefined, - is_end_of_file: isEndOfFile || undefined, - }) - } else { - i++ - } - } - - return { chunks, nextIdx: i } -} - -function parseAddFileContent(lines: string[], startIdx: number): { content: string; nextIdx: number } { - let content = "" - let i = startIdx - - while (i < lines.length && !lines[i].startsWith("***")) { - if (lines[i].startsWith("+")) { - content += lines[i].substring(1) + "\n" - } - i++ - } - - // Remove trailing newline - if (content.endsWith("\n")) { - content = content.slice(0, -1) - } - - return { content, nextIdx: i } -} - -function stripHeredoc(input: string): string { - // Match heredoc patterns like: cat <<'EOF'\n...\nEOF or < line.trim() === beginMarker) - const endIdx = lines.findIndex((line) => line.trim() === endMarker) - - if (beginIdx === -1 || endIdx === -1 || beginIdx >= endIdx) { - throw new Error("Invalid patch format: missing Begin/End markers") - } - - // Parse content between markers - i = beginIdx + 1 - - while (i < endIdx) { - const header = parsePatchHeader(lines, i) - if (!header) { - i++ - continue - } - - if (lines[i].startsWith("*** Add File:")) { - const { content, nextIdx } = parseAddFileContent(lines, header.nextIdx) - hunks.push({ - type: "add", - path: header.filePath, - contents: content, - }) - i = nextIdx - } else if (lines[i].startsWith("*** Delete File:")) { - hunks.push({ - type: "delete", - path: header.filePath, - }) - i = header.nextIdx - } else if (lines[i].startsWith("*** Update File:")) { - const { chunks, nextIdx } = parseUpdateFileChunks(lines, header.nextIdx) - hunks.push({ - type: "update", - path: header.filePath, - move_path: header.movePath, - chunks, - }) - i = nextIdx - } else { - i++ - } - } - - return { hunks } -} - -// Apply patch functionality -export function maybeParseApplyPatch( - argv: string[], -): - | { type: MaybeApplyPatch.Body; args: ApplyPatchArgs } - | { type: MaybeApplyPatch.PatchParseError; error: Error } - | { type: MaybeApplyPatch.NotApplyPatch } { - const APPLY_PATCH_COMMANDS = ["apply_patch", "applypatch"] - - // Direct invocation: apply_patch - if (argv.length === 2 && APPLY_PATCH_COMMANDS.includes(argv[0])) { - try { - const { hunks } = parsePatch(argv[1]) - return { - type: MaybeApplyPatch.Body, - args: { - patch: argv[1], - hunks, - }, - } - } catch (error) { - return { - type: MaybeApplyPatch.PatchParseError, - error: error as Error, - } - } - } - - // Bash heredoc form: bash -lc 'apply_patch <<"EOF" ...' - if (argv.length === 3 && argv[0] === "bash" && argv[1] === "-lc") { - // Simple extraction - in real implementation would need proper bash parsing - const script = argv[2] - const heredocMatch = script.match(/apply_patch\s*<<['"](\w+)['"]\s*\n([\s\S]*?)\n\1/) - - if (heredocMatch) { - const patchContent = heredocMatch[2] - try { - const { hunks } = parsePatch(patchContent) - return { - type: MaybeApplyPatch.Body, - args: { - patch: patchContent, - hunks, - }, - } - } catch (error) { - return { - type: MaybeApplyPatch.PatchParseError, - error: error as Error, - } - } - } - } - - return { type: MaybeApplyPatch.NotApplyPatch } -} - -// File content manipulation -interface ApplyPatchFileUpdate { - unified_diff: string - content: string -} - -export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate { - // Read original file content - let originalContent: string - try { - originalContent = readFileSync(filePath, "utf-8") - } catch (error) { - throw new Error(`Failed to read file ${filePath}: ${error}`, { cause: error }) - } - - let originalLines = originalContent.split("\n") - - // Drop trailing empty element for consistent line counting - if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") { - originalLines.pop() - } - - const replacements = computeReplacements(originalLines, filePath, chunks) - let newLines = applyReplacements(originalLines, replacements) - - // Ensure trailing newline - if (newLines.length === 0 || newLines[newLines.length - 1] !== "") { - newLines.push("") - } - - const newContent = newLines.join("\n") - - // Generate unified diff - const unifiedDiff = generateUnifiedDiff(originalContent, newContent) - - return { - unified_diff: unifiedDiff, - content: newContent, - } -} - -function computeReplacements( - originalLines: string[], - filePath: string, - chunks: UpdateFileChunk[], -): Array<[number, number, string[]]> { - const replacements: Array<[number, number, string[]]> = [] - let lineIndex = 0 - - for (const chunk of chunks) { - // Handle context-based seeking - if (chunk.change_context) { - const contextIdx = seekSequence(originalLines, [chunk.change_context], lineIndex) - if (contextIdx === -1) { - throw new Error(`Failed to find context '${chunk.change_context}' in ${filePath}`) - } - lineIndex = contextIdx + 1 - } - - // Handle pure addition (no old lines) - if (chunk.old_lines.length === 0) { - const insertionIdx = - originalLines.length > 0 && originalLines[originalLines.length - 1] === "" - ? originalLines.length - 1 - : originalLines.length - replacements.push([insertionIdx, 0, chunk.new_lines]) - continue - } - - // Try to match old lines in the file - let pattern = chunk.old_lines - let newSlice = chunk.new_lines - let found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file) - - // Retry without trailing empty line if not found - if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") { - pattern = pattern.slice(0, -1) - if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") { - newSlice = newSlice.slice(0, -1) - } - found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file) - } - - if (found !== -1) { - replacements.push([found, pattern.length, newSlice]) - lineIndex = found + pattern.length - } else { - throw new Error(`Failed to find expected lines in ${filePath}:\n${chunk.old_lines.join("\n")}`) - } - } - - // Sort replacements by index to apply in order - replacements.sort((a, b) => a[0] - b[0]) - - return replacements -} - -function applyReplacements(lines: string[], replacements: Array<[number, number, string[]]>): string[] { - // Apply replacements in reverse order to avoid index shifting - const result = [...lines] - - for (let i = replacements.length - 1; i >= 0; i--) { - const [startIdx, oldLen, newSegment] = replacements[i] - - // Remove old lines - result.splice(startIdx, oldLen) - - // Insert new lines - for (let j = 0; j < newSegment.length; j++) { - result.splice(startIdx + j, 0, newSegment[j]) - } - } - - return result -} - -// Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode) -function normalizeUnicode(str: string): string { - return str - .replace(/[\u2018\u2019\u201A\u201B]/g, "'") // single quotes - .replace(/[\u201C\u201D\u201E\u201F]/g, '"') // double quotes - .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-") // dashes - .replace(/\u2026/g, "...") // ellipsis - .replace(/\u00A0/g, " ") // non-breaking space -} - -type Comparator = (a: string, b: string) => boolean - -function tryMatch(lines: string[], pattern: string[], startIndex: number, compare: Comparator, eof: boolean): number { - // If EOF anchor, try matching from end of file first - if (eof) { - const fromEnd = lines.length - pattern.length - if (fromEnd >= startIndex) { - let matches = true - for (let j = 0; j < pattern.length; j++) { - if (!compare(lines[fromEnd + j], pattern[j])) { - matches = false - break - } - } - if (matches) return fromEnd - } - } - - // Forward search from startIndex - for (let i = startIndex; i <= lines.length - pattern.length; i++) { - let matches = true - for (let j = 0; j < pattern.length; j++) { - if (!compare(lines[i + j], pattern[j])) { - matches = false - break - } - } - if (matches) return i - } - - return -1 -} - -function seekSequence(lines: string[], pattern: string[], startIndex: number, eof = false): number { - if (pattern.length === 0) return -1 - - // Pass 1: exact match - const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof) - if (exact !== -1) return exact - - // Pass 2: rstrip (trim trailing whitespace) - const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof) - if (rstrip !== -1) return rstrip - - // Pass 3: trim (both ends) - const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof) - if (trim !== -1) return trim - - // Pass 4: normalized (Unicode punctuation to ASCII) - const normalized = tryMatch( - lines, - pattern, - startIndex, - (a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()), - eof, - ) - return normalized -} - -function generateUnifiedDiff(oldContent: string, newContent: string): string { - const oldLines = oldContent.split("\n") - const newLines = newContent.split("\n") - - // Simple diff generation - in a real implementation you'd use a proper diff algorithm - let diff = "@@ -1 +1 @@\n" - - // Find changes (simplified approach) - const maxLen = Math.max(oldLines.length, newLines.length) - let hasChanges = false - - for (let i = 0; i < maxLen; i++) { - const oldLine = oldLines[i] || "" - const newLine = newLines[i] || "" - - if (oldLine !== newLine) { - if (oldLine) diff += `-${oldLine}\n` - if (newLine) diff += `+${newLine}\n` - hasChanges = true - } else if (oldLine) { - diff += ` ${oldLine}\n` - } - } - - return hasChanges ? diff : "" -} - -// Apply hunks to filesystem -export async function applyHunksToFiles(hunks: Hunk[]): Promise { - if (hunks.length === 0) { - throw new Error("No files were modified.") - } - - const added: string[] = [] - const modified: string[] = [] - const deleted: string[] = [] - - for (const hunk of hunks) { - switch (hunk.type) { - case "add": - // Create parent directories - const addDir = path.dirname(hunk.path) - if (addDir !== "." && addDir !== "/") { - await fs.mkdir(addDir, { recursive: true }) - } - - await fs.writeFile(hunk.path, hunk.contents, "utf-8") - added.push(hunk.path) - log.info(`Added file: ${hunk.path}`) - break - - case "delete": - await fs.unlink(hunk.path) - deleted.push(hunk.path) - log.info(`Deleted file: ${hunk.path}`) - break - - case "update": - const fileUpdate = deriveNewContentsFromChunks(hunk.path, hunk.chunks) - - if (hunk.move_path) { - // Handle file move - const moveDir = path.dirname(hunk.move_path) - if (moveDir !== "." && moveDir !== "/") { - await fs.mkdir(moveDir, { recursive: true }) - } - - await fs.writeFile(hunk.move_path, fileUpdate.content, "utf-8") - await fs.unlink(hunk.path) - modified.push(hunk.move_path) - log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`) - } else { - // Regular update - await fs.writeFile(hunk.path, fileUpdate.content, "utf-8") - modified.push(hunk.path) - log.info(`Updated file: ${hunk.path}`) - } - break - } - } - - return { added, modified, deleted } -} - -// Main patch application function -export async function applyPatch(patchText: string): Promise { - const { hunks } = parsePatch(patchText) - return applyHunksToFiles(hunks) -} - -// Async version of maybeParseApplyPatchVerified -export async function maybeParseApplyPatchVerified( - argv: string[], - cwd: string, -): Promise< - | { type: MaybeApplyPatchVerified.Body; action: ApplyPatchAction } - | { type: MaybeApplyPatchVerified.CorrectnessError; error: Error } - | { type: MaybeApplyPatchVerified.NotApplyPatch } -> { - // Detect implicit patch invocation (raw patch without apply_patch command) - if (argv.length === 1) { - try { - parsePatch(argv[0]) - return { - type: MaybeApplyPatchVerified.CorrectnessError, - error: new Error(ApplyPatchError.ImplicitInvocation), - } - } catch { - // Not a patch, continue - } - } - - const result = maybeParseApplyPatch(argv) - - switch (result.type) { - case MaybeApplyPatch.Body: - const { args } = result - const effectiveCwd = args.workdir ? path.resolve(cwd, args.workdir) : cwd - const changes = new Map() - - for (const hunk of args.hunks) { - const resolvedPath = path.resolve( - effectiveCwd, - hunk.type === "update" && hunk.move_path ? hunk.move_path : hunk.path, - ) - - switch (hunk.type) { - case "add": - changes.set(resolvedPath, { - type: "add", - content: hunk.contents, - }) - break - - case "delete": - // For delete, we need to read the current content - const deletePath = path.resolve(effectiveCwd, hunk.path) - try { - const content = await fs.readFile(deletePath, "utf-8") - changes.set(resolvedPath, { - type: "delete", - content, - }) - } catch { - return { - type: MaybeApplyPatchVerified.CorrectnessError, - error: new Error(`Failed to read file for deletion: ${deletePath}`), - } - } - break - - case "update": - const updatePath = path.resolve(effectiveCwd, hunk.path) - try { - const fileUpdate = deriveNewContentsFromChunks(updatePath, hunk.chunks) - changes.set(resolvedPath, { - type: "update", - unified_diff: fileUpdate.unified_diff, - move_path: hunk.move_path ? path.resolve(effectiveCwd, hunk.move_path) : undefined, - new_content: fileUpdate.content, - }) - } catch (error) { - return { - type: MaybeApplyPatchVerified.CorrectnessError, - error: error as Error, - } - } - break - } - } - - return { - type: MaybeApplyPatchVerified.Body, - action: { - changes, - patch: args.patch, - cwd: effectiveCwd, - }, - } - - case MaybeApplyPatch.PatchParseError: - return { - type: MaybeApplyPatchVerified.CorrectnessError, - error: result.error, - } - - case MaybeApplyPatch.NotApplyPatch: - return { type: MaybeApplyPatchVerified.NotApplyPatch } - } -} diff --git a/packages/opencode/src/plugin/github-copilot/models.ts b/packages/opencode/src/plugin/github-copilot/models.ts index 2007b7136f..564bdce3db 100644 --- a/packages/opencode/src/plugin/github-copilot/models.ts +++ b/packages/opencode/src/plugin/github-copilot/models.ts @@ -1,146 +1,146 @@ import { z } from "zod" import type { Model } from "@kilocode/sdk/v2" -export namespace CopilotModels { - export const schema = z.object({ - data: z.array( - z.object({ - model_picker_enabled: z.boolean(), - id: z.string(), - name: z.string(), - // every version looks like: `{model.id}-YYYY-MM-DD` - version: z.string(), - supported_endpoints: z.array(z.string()).optional(), - capabilities: z.object({ - family: z.string(), - limits: z.object({ - max_context_window_tokens: z.number(), - max_output_tokens: z.number(), - max_prompt_tokens: z.number(), - vision: z - .object({ - max_prompt_image_size: z.number(), - max_prompt_images: z.number(), - supported_media_types: z.array(z.string()), - }) - .optional(), - }), - supports: z.object({ - adaptive_thinking: z.boolean().optional(), - max_thinking_budget: z.number().optional(), - min_thinking_budget: z.number().optional(), - reasoning_effort: z.array(z.string()).optional(), - streaming: z.boolean(), - structured_outputs: z.boolean().optional(), - tool_calls: z.boolean(), - vision: z.boolean().optional(), - }), +export const schema = z.object({ + data: z.array( + z.object({ + model_picker_enabled: z.boolean(), + id: z.string(), + name: z.string(), + // every version looks like: `{model.id}-YYYY-MM-DD` + version: z.string(), + supported_endpoints: z.array(z.string()).optional(), + capabilities: z.object({ + family: z.string(), + limits: z.object({ + max_context_window_tokens: z.number(), + max_output_tokens: z.number(), + max_prompt_tokens: z.number(), + vision: z + .object({ + max_prompt_image_size: z.number(), + max_prompt_images: z.number(), + supported_media_types: z.array(z.string()), + }) + .optional(), + }), + supports: z.object({ + adaptive_thinking: z.boolean().optional(), + max_thinking_budget: z.number().optional(), + min_thinking_budget: z.number().optional(), + reasoning_effort: z.array(z.string()).optional(), + streaming: z.boolean(), + structured_outputs: z.boolean().optional(), + tool_calls: z.boolean(), + vision: z.boolean().optional(), }), }), - ), - }) + }), + ), +}) - type Item = z.infer["data"][number] +type Item = z.infer["data"][number] - function build(key: string, remote: Item, url: string, prev?: Model): Model { - const reasoning = - !!remote.capabilities.supports.adaptive_thinking || - !!remote.capabilities.supports.reasoning_effort?.length || - remote.capabilities.supports.max_thinking_budget !== undefined || - remote.capabilities.supports.min_thinking_budget !== undefined - const image = - (remote.capabilities.supports.vision ?? false) || - (remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/")) +function build(key: string, remote: Item, url: string, prev?: Model): Model { + const reasoning = + !!remote.capabilities.supports.adaptive_thinking || + !!remote.capabilities.supports.reasoning_effort?.length || + remote.capabilities.supports.max_thinking_budget !== undefined || + remote.capabilities.supports.min_thinking_budget !== undefined + const image = + (remote.capabilities.supports.vision ?? false) || + (remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/")) - const isMsgApi = remote.supported_endpoints?.includes("/v1/messages") + const isMsgApi = remote.supported_endpoints?.includes("/v1/messages") - return { - id: key, - providerID: "github-copilot", - api: { - id: remote.id, - url: isMsgApi ? `${url}/v1` : url, - npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot", + return { + id: key, + providerID: "github-copilot", + api: { + id: remote.id, + url: isMsgApi ? `${url}/v1` : url, + npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot", + }, + // API response wins + status: "active", + limit: { + context: remote.capabilities.limits.max_context_window_tokens, + input: remote.capabilities.limits.max_prompt_tokens, + output: remote.capabilities.limits.max_output_tokens, + }, + capabilities: { + temperature: prev?.capabilities.temperature ?? true, + reasoning: prev?.capabilities.reasoning ?? reasoning, + attachment: prev?.capabilities.attachment ?? true, + toolcall: remote.capabilities.supports.tool_calls, + input: { + text: true, + audio: false, + image, + video: false, + pdf: false, }, - // API response wins - status: "active", - limit: { - context: remote.capabilities.limits.max_context_window_tokens, - input: remote.capabilities.limits.max_prompt_tokens, - output: remote.capabilities.limits.max_output_tokens, + output: { + text: true, + audio: false, + image: false, + video: false, + pdf: false, }, - capabilities: { - temperature: prev?.capabilities.temperature ?? true, - reasoning: prev?.capabilities.reasoning ?? reasoning, - attachment: prev?.capabilities.attachment ?? true, - toolcall: remote.capabilities.supports.tool_calls, - input: { - text: true, - audio: false, - image, - video: false, - pdf: false, - }, - output: { - text: true, - audio: false, - image: false, - video: false, - pdf: false, - }, - interleaved: false, - }, - // existing wins - family: prev?.family ?? remote.capabilities.family, - name: prev?.name ?? remote.name, - cost: { - input: 0, - output: 0, - cache: { read: 0, write: 0 }, - }, - options: prev?.options ?? {}, - headers: prev?.headers ?? {}, - release_date: - prev?.release_date ?? - (remote.version.startsWith(`${remote.id}-`) ? remote.version.slice(remote.id.length + 1) : remote.version), - variants: prev?.variants ?? {}, - } - } - - export async function get( - baseURL: string, - headers: HeadersInit = {}, - existing: Record = {}, - ): Promise> { - const data = await fetch(`${baseURL}/models`, { - headers, - signal: AbortSignal.timeout(5_000), - }).then(async (res) => { - if (!res.ok) { - throw new Error(`Failed to fetch models: ${res.status}`) - } - return schema.parse(await res.json()) - }) - - const result = { ...existing } - const remote = new Map(data.data.filter((m) => m.model_picker_enabled).map((m) => [m.id, m] as const)) - - // prune existing models whose api.id isn't in the endpoint response - for (const [key, model] of Object.entries(result)) { - const m = remote.get(model.api.id) - if (!m) { - delete result[key] - continue - } - result[key] = build(key, m, baseURL, model) - } - - // add new endpoint models not already keyed in result - for (const [id, m] of remote) { - if (id in result) continue - result[id] = build(id, m, baseURL) - } - - return result + interleaved: false, + }, + // existing wins + family: prev?.family ?? remote.capabilities.family, + name: prev?.name ?? remote.name, + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + options: prev?.options ?? {}, + headers: prev?.headers ?? {}, + release_date: + prev?.release_date ?? + (remote.version.startsWith(`${remote.id}-`) ? remote.version.slice(remote.id.length + 1) : remote.version), + variants: prev?.variants ?? {}, } } + +export async function get( + baseURL: string, + headers: HeadersInit = {}, + existing: Record = {}, +): Promise> { + const data = await fetch(`${baseURL}/models`, { + headers, + signal: AbortSignal.timeout(5_000), + }).then(async (res) => { + if (!res.ok) { + throw new Error(`Failed to fetch models: ${res.status}`) + } + return schema.parse(await res.json()) + }) + + const result = { ...existing } + const remote = new Map(data.data.filter((m) => m.model_picker_enabled).map((m) => [m.id, m] as const)) + + // prune existing models whose api.id isn't in the endpoint response + for (const [key, model] of Object.entries(result)) { + const m = remote.get(model.api.id) + if (!m) { + delete result[key] + continue + } + result[key] = build(key, m, baseURL, model) + } + + // add new endpoint models not already keyed in result + for (const [id, m] of remote) { + if (id in result) continue + result[id] = build(id, m, baseURL) + } + + return result +} + +export * as CopilotModels from "./models" diff --git a/packages/opencode/src/plugin/meta.ts b/packages/opencode/src/plugin/meta.ts index 2109f187bc..6eb59bd9da 100644 --- a/packages/opencode/src/plugin/meta.ts +++ b/packages/opencode/src/plugin/meta.ts @@ -8,181 +8,181 @@ import { Flock } from "@opencode-ai/shared/util/flock" import { parsePluginSpecifier, pluginSource } from "./shared" -export namespace PluginMeta { - type Source = "file" | "npm" +type Source = "file" | "npm" - export type Theme = { - src: string - dest: string - mtime?: number - size?: number - } +export type Theme = { + src: string + dest: string + mtime?: number + size?: number +} - export type Entry = { - id: string - source: Source - spec: string - target: string - requested?: string - version?: string - modified?: number - first_time: number - last_time: number - time_changed: number - load_count: number - fingerprint: string - themes?: Record - } +export type Entry = { + id: string + source: Source + spec: string + target: string + requested?: string + version?: string + modified?: number + first_time: number + last_time: number + time_changed: number + load_count: number + fingerprint: string + themes?: Record +} - export type State = "first" | "updated" | "same" +export type State = "first" | "updated" | "same" - export type Touch = { - spec: string - target: string - id: string - } +export type Touch = { + spec: string + target: string + id: string +} - type Store = Record - type Core = Omit - type Row = Touch & { core: Core } +type Store = Record +type Core = Omit +type Row = Touch & { core: Core } - function storePath() { - return Flag.KILO_PLUGIN_META_FILE ?? path.join(Global.Path.state, "plugin-meta.json") - } +function storePath() { + return Flag.KILO_PLUGIN_META_FILE ?? path.join(Global.Path.state, "plugin-meta.json") +} - function lock(file: string) { - return `plugin-meta:${file}` - } +function lock(file: string) { + return `plugin-meta:${file}` +} - function fileTarget(spec: string, target: string) { - if (spec.startsWith("file://")) return fileURLToPath(spec) - if (target.startsWith("file://")) return fileURLToPath(target) - return - } +function fileTarget(spec: string, target: string) { + if (spec.startsWith("file://")) return fileURLToPath(spec) + if (target.startsWith("file://")) return fileURLToPath(target) + return +} - async function modifiedAt(file: string) { - const stat = await Filesystem.statAsync(file) - if (!stat) return - const mtime = stat.mtimeMs - return Math.floor(typeof mtime === "bigint" ? Number(mtime) : mtime) - } +async function modifiedAt(file: string) { + const stat = await Filesystem.statAsync(file) + if (!stat) return + const mtime = stat.mtimeMs + return Math.floor(typeof mtime === "bigint" ? Number(mtime) : mtime) +} - function resolvedTarget(target: string) { - if (target.startsWith("file://")) return fileURLToPath(target) - return target - } +function resolvedTarget(target: string) { + if (target.startsWith("file://")) return fileURLToPath(target) + return target +} - async function npmVersion(target: string) { - const resolved = resolvedTarget(target) - const stat = await Filesystem.statAsync(resolved) - const dir = stat?.isDirectory() ? resolved : path.dirname(resolved) - return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json")) - .then((item) => item.version) - .catch(() => undefined) - } - - async function entryCore(item: Touch): Promise { - const spec = item.spec - const target = item.target - const source = pluginSource(spec) - if (source === "file") { - const file = fileTarget(spec, target) - return { - id: item.id, - source, - spec, - target, - modified: file ? await modifiedAt(file) : undefined, - } - } +async function npmVersion(target: string) { + const resolved = resolvedTarget(target) + const stat = await Filesystem.statAsync(resolved) + const dir = stat?.isDirectory() ? resolved : path.dirname(resolved) + return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json")) + .then((item) => item.version) + .catch(() => undefined) +} +async function entryCore(item: Touch): Promise { + const spec = item.spec + const target = item.target + const source = pluginSource(spec) + if (source === "file") { + const file = fileTarget(spec, target) return { id: item.id, source, spec, target, - requested: parsePluginSpecifier(spec).version, - version: await npmVersion(target), + modified: file ? await modifiedAt(file) : undefined, } } - function fingerprint(value: Core) { - if (value.source === "file") return [value.target, value.modified ?? ""].join("|") - return [value.target, value.requested ?? "", value.version ?? ""].join("|") - } - - async function read(file: string): Promise { - return Filesystem.readJson(file).catch(() => ({}) as Store) - } - - async function row(item: Touch): Promise { - return { - ...item, - core: await entryCore(item), - } - } - - function next(prev: Entry | undefined, core: Core, now: number): { state: State; entry: Entry } { - const entry: Entry = { - ...core, - first_time: prev?.first_time ?? now, - last_time: now, - time_changed: prev?.time_changed ?? now, - load_count: (prev?.load_count ?? 0) + 1, - fingerprint: fingerprint(core), - themes: prev?.themes, - } - const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated" - if (state === "updated") entry.time_changed = now - return { - state, - entry, - } - } - - export async function touchMany(items: Touch[]): Promise> { - if (!items.length) return [] - const file = storePath() - const rows = await Promise.all(items.map((item) => row(item))) - - return Flock.withLock(lock(file), async () => { - const store = await read(file) - const now = Date.now() - const out: Array<{ state: State; entry: Entry }> = [] - for (const item of rows) { - const hit = next(store[item.id], item.core, now) - store[item.id] = hit.entry - out.push(hit) - } - await Filesystem.writeJson(file, store) - return out - }) - } - - export async function touch(spec: string, target: string, id: string): Promise<{ state: State; entry: Entry }> { - return touchMany([{ spec, target, id }]).then((item) => { - const hit = item[0] - if (hit) return hit - throw new Error("Failed to touch plugin metadata.") - }) - } - - export async function setTheme(id: string, name: string, theme: Theme): Promise { - const file = storePath() - await Flock.withLock(lock(file), async () => { - const store = await read(file) - const entry = store[id] - if (!entry) return - entry.themes = { - ...entry.themes, - [name]: theme, - } - await Filesystem.writeJson(file, store) - }) - } - - export async function list(): Promise { - const file = storePath() - return Flock.withLock(lock(file), async () => read(file)) + return { + id: item.id, + source, + spec, + target, + requested: parsePluginSpecifier(spec).version, + version: await npmVersion(target), } } + +function fingerprint(value: Core) { + if (value.source === "file") return [value.target, value.modified ?? ""].join("|") + return [value.target, value.requested ?? "", value.version ?? ""].join("|") +} + +async function read(file: string): Promise { + return Filesystem.readJson(file).catch(() => ({}) as Store) +} + +async function row(item: Touch): Promise { + return { + ...item, + core: await entryCore(item), + } +} + +function next(prev: Entry | undefined, core: Core, now: number): { state: State; entry: Entry } { + const entry: Entry = { + ...core, + first_time: prev?.first_time ?? now, + last_time: now, + time_changed: prev?.time_changed ?? now, + load_count: (prev?.load_count ?? 0) + 1, + fingerprint: fingerprint(core), + themes: prev?.themes, + } + const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated" + if (state === "updated") entry.time_changed = now + return { + state, + entry, + } +} + +export async function touchMany(items: Touch[]): Promise> { + if (!items.length) return [] + const file = storePath() + const rows = await Promise.all(items.map((item) => row(item))) + + return Flock.withLock(lock(file), async () => { + const store = await read(file) + const now = Date.now() + const out: Array<{ state: State; entry: Entry }> = [] + for (const item of rows) { + const hit = next(store[item.id], item.core, now) + store[item.id] = hit.entry + out.push(hit) + } + await Filesystem.writeJson(file, store) + return out + }) +} + +export async function touch(spec: string, target: string, id: string): Promise<{ state: State; entry: Entry }> { + return touchMany([{ spec, target, id }]).then((item) => { + const hit = item[0] + if (hit) return hit + throw new Error("Failed to touch plugin metadata.") + }) +} + +export async function setTheme(id: string, name: string, theme: Theme): Promise { + const file = storePath() + await Flock.withLock(lock(file), async () => { + const store = await read(file) + const entry = store[id] + if (!entry) return + entry.themes = { + ...entry.themes, + [name]: theme, + } + await Filesystem.writeJson(file, store) + }) +} + +export async function list(): Promise { + const file = storePath() + return Flock.withLock(lock(file), async () => read(file)) +} + +export * as PluginMeta from "./meta" diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index bfdb8e522e..3ebf791925 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -9,46 +9,52 @@ import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { which } from "../util/which" import { ProjectID } from "./schema" -import { Effect, Layer, Path, Scope, Context, Stream } from "effect" +import { Effect, Layer, Path, Scope, Context, Stream, Types, Schema } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" const log = Log.create({ service: "project" }) -export const Info = z - .object({ - id: ProjectID.zod, - worktree: z.string(), - vcs: z.literal("git").optional(), - name: z.string().optional(), - icon: z - .object({ - url: z.string().optional(), - override: z.string().optional(), - color: z.string().optional(), - }) - .optional(), - commands: z - .object({ - start: z.string().optional().describe("Startup script to run when creating a new workspace (worktree)"), - }) - .optional(), - time: z.object({ - created: z.number(), - updated: z.number(), - initialized: z.number().optional(), - }), - sandboxes: z.array(z.string()), - }) - .meta({ - ref: "Project", - }) -export type Info = z.infer +const ProjectVcs = Schema.Literal("git") + +const ProjectIcon = Schema.Struct({ + url: Schema.optional(Schema.String), + override: Schema.optional(Schema.String), + color: Schema.optional(Schema.String), +}) + +const ProjectCommands = Schema.Struct({ + start: Schema.optional( + Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }), + ), +}) + +const ProjectTime = Schema.Struct({ + created: Schema.Number, + updated: Schema.Number, + initialized: Schema.optional(Schema.Number), +}) + +export const Info = Schema.Struct({ + id: ProjectID, + worktree: Schema.String, + vcs: Schema.optional(ProjectVcs), + name: Schema.optional(Schema.String), + icon: Schema.optional(ProjectIcon), + commands: Schema.optional(ProjectCommands), + time: ProjectTime, + sandboxes: Schema.Array(Schema.String), +}) + .annotate({ identifier: "Project" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = Types.DeepMutable> export const Event = { - Updated: BusEvent.define("project.updated", Info), + Updated: BusEvent.define("project.updated", Info.zod), } type Row = typeof ProjectTable.$inferSelect @@ -59,7 +65,7 @@ export function fromRow(row: Row): Info { return { id: row.id, worktree: row.worktree, - vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined, + vcs: row.vcs ? Schema.decodeUnknownSync(ProjectVcs)(row.vcs) : undefined, name: row.name ?? undefined, icon, time: { @@ -75,8 +81,8 @@ export function fromRow(row: Row): Info { export const UpdateInput = z.object({ projectID: ProjectID.zod, name: z.string().optional(), - icon: Info.shape.icon.optional(), - commands: Info.shape.commands.optional(), + icon: zod(ProjectIcon).optional(), + commands: zod(ProjectCommands).optional(), }) export type UpdateInput = z.infer @@ -140,7 +146,7 @@ export const layer: Layer.Layer< }), ) - const fakeVcs = Info.shape.vcs.parse(Flag.KILO_FAKE_VCS) + const fakeVcs = Schema.decodeUnknownSync(Schema.optional(ProjectVcs))(Flag.KILO_FAKE_VCS) const resolveGitPath = (cwd: string, name: string) => { if (!name) return cwd diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 99b326c237..1f9f6bf62f 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -64,6 +64,18 @@ export class Authorization extends Schema.Class("ProviderAuthAuth static readonly zod = zod(this) } +export const AuthorizeInput = Schema.Struct({ + method: Schema.Number.annotate({ description: "Auth method index" }), + inputs: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({ description: "Prompt inputs" }), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type AuthorizeInput = Schema.Schema.Type + +export const CallbackInput = Schema.Struct({ + method: Schema.Number.annotate({ description: "Auth method index" }), + code: Schema.optional(Schema.String).annotate({ description: "OAuth authorization code" }), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type CallbackInput = Schema.Schema.Type + export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod })) export const OauthCodeMissing = NamedError.create( @@ -92,12 +104,12 @@ type Hook = NonNullable export interface Interface { readonly methods: () => Effect.Effect - readonly authorize: (input: { - providerID: ProviderID - method: number - inputs?: Record - }) => Effect.Effect - readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect + readonly authorize: ( + input: { + providerID: ProviderID + } & AuthorizeInput, + ) => Effect.Effect + readonly callback: (input: { providerID: ProviderID } & CallbackInput) => Effect.Effect } interface State { @@ -159,11 +171,9 @@ export const layer: Layer.Layer = ) }) - const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: { - providerID: ProviderID - method: number - inputs?: Record - }) { + const authorize = Effect.fn("ProviderAuth.authorize")(function* ( + input: { providerID: ProviderID } & AuthorizeInput, + ) { const { hooks, pending } = yield* InstanceState.get(state) const method = hooks[input.providerID].methods[input.method] if (method.type !== "oauth") return @@ -186,11 +196,7 @@ export const layer: Layer.Layer = } }) - const callback = Effect.fn("ProviderAuth.callback")(function* (input: { - providerID: ProviderID - method: number - code?: string - }) { + const callback = Effect.fn("ProviderAuth.callback")(function* (input: { providerID: ProviderID } & CallbackInput) { const pending = (yield* InstanceState.get(state)).pending const match = pending.get(input.providerID) if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID })) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 397c48e407..23cef204dc 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -17,14 +17,16 @@ import { Env } from "../env" import { Instance } from "../project/instance" import { InstallationVersion } from "../installation/version" import { Flag } from "../flag/flag" +import { zod } from "@/util/effect-zod" import { iife } from "@/util/iife" import { Global } from "../global" import path from "path" -import { Effect, Layer, Context } from "effect" +import { Effect, Layer, Context, Schema, Types } from "effect" import { EffectBridge } from "@/effect" import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { isRecord } from "@/util/record" +import { withStatics } from "@/util/schema" import * as ProviderTransform from "./transform" import { ModelID, ProviderID } from "./schema" @@ -403,6 +405,17 @@ function custom(dep: CustomDep): Record { }, } }), + llmgateway: () => + Effect.succeed({ + autoload: false, + options: { + headers: { + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + "X-Source": "opencode", + }, + }, + }), openrouter: () => Effect.succeed({ autoload: false, @@ -562,12 +575,14 @@ function custom(dep: CustomDep): Record { }, async getModel(sdk: any, modelID: string, options?: Record) { if (modelID.startsWith("duo-workflow-")) { - const workflowRef = options?.workflowRef as string | undefined + const workflowRef = typeof options?.workflowRef === "string" ? options.workflowRef : undefined // Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow" + const workflowDefinition = + typeof options?.workflowDefinition === "string" ? options.workflowDefinition : undefined const model = sdk.workflowChat(sdkModelID, { featureFlags, - workflowDefinition: options?.workflowDefinition as string | undefined, + workflowDefinition, }) if (workflowRef) { model.selectedModelRef = workflowRef @@ -809,92 +824,112 @@ function custom(dep: CustomDep): Record { } } -export const Model = z - .object({ - id: ModelID.zod, - providerID: ProviderID.zod, - api: z.object({ - id: z.string(), - url: z.string(), - npm: z.string(), - }), - name: z.string(), - family: z.string().optional(), - capabilities: z.object({ - temperature: z.boolean(), - reasoning: z.boolean(), - attachment: z.boolean(), - toolcall: z.boolean(), - input: z.object({ - text: z.boolean(), - audio: z.boolean(), - image: z.boolean(), - video: z.boolean(), - pdf: z.boolean(), - }), - output: z.object({ - text: z.boolean(), - audio: z.boolean(), - image: z.boolean(), - video: z.boolean(), - pdf: z.boolean(), - }), - interleaved: z.union([ - z.boolean(), - z.object({ - field: z.enum(["reasoning_content", "reasoning_details"]), - }), - ]), - }), - cost: z.object({ - input: z.number(), - output: z.number(), - cache: z.object({ - read: z.number(), - write: z.number(), - }), - experimentalOver200K: z - .object({ - input: z.number(), - output: z.number(), - cache: z.object({ - read: z.number(), - write: z.number(), - }), - }) - .optional(), - }), - limit: z.object({ - context: z.number(), - input: z.number().optional(), - output: z.number(), - }), - status: z.enum(["alpha", "beta", "deprecated", "active"]), - options: z.record(z.string(), z.any()), - headers: z.record(z.string(), z.string()), - release_date: z.string(), - variants: z.record(z.string(), z.record(z.string(), z.any())).optional(), - }) - .extend(KILO_MODEL_SCHEMA_EXTENSIONS) // kilocode_change - .meta({ - ref: "Model", - }) -export type Model = z.infer +const ProviderApiInfo = Schema.Struct({ + id: Schema.String, + url: Schema.String, + npm: Schema.String, +}) -export const Info = z - .object({ - id: ProviderID.zod, - name: z.string(), - source: z.enum(["env", "config", "custom", "api"]), - env: z.string().array(), - key: z.string().optional(), - options: z.record(z.string(), z.any()), - models: z.record(z.string(), Model), - }) - .meta({ - ref: "Provider", - }) -export type Info = z.infer +const ProviderModalities = Schema.Struct({ + text: Schema.Boolean, + audio: Schema.Boolean, + image: Schema.Boolean, + video: Schema.Boolean, + pdf: Schema.Boolean, +}) + +const ProviderInterleaved = Schema.Union([ + Schema.Boolean, + Schema.Struct({ + field: Schema.Literals(["reasoning_content", "reasoning_details"]), + }), +]) + +const ProviderCapabilities = Schema.Struct({ + temperature: Schema.Boolean, + reasoning: Schema.Boolean, + attachment: Schema.Boolean, + toolcall: Schema.Boolean, + input: ProviderModalities, + output: ProviderModalities, + interleaved: ProviderInterleaved, +}) + +const ProviderCacheCost = Schema.Struct({ + read: Schema.Number, + write: Schema.Number, +}) + +const ProviderCost = Schema.Struct({ + input: Schema.Number, + output: Schema.Number, + cache: ProviderCacheCost, + experimentalOver200K: Schema.optional( + Schema.Struct({ + input: Schema.Number, + output: Schema.Number, + cache: ProviderCacheCost, + }), + ), +}) + +const ProviderLimit = Schema.Struct({ + context: Schema.Number, + input: Schema.optional(Schema.Number), + output: Schema.Number, +}) + +export const Model = Schema.Struct({ + id: ModelID, + providerID: ProviderID, + api: ProviderApiInfo, + name: Schema.String, + family: Schema.optional(Schema.String), + capabilities: ProviderCapabilities, + cost: ProviderCost, + limit: ProviderLimit, + status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + options: Schema.Record(Schema.String, Schema.Any), + headers: Schema.Record(Schema.String, Schema.String), + release_date: Schema.String, + variants: Schema.optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))), + ...KILO_MODEL_SCHEMA_EXTENSIONS, // kilocode_change +}) + .annotate({ identifier: "Model" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Model = Types.DeepMutable> + +export const Info = Schema.Struct({ + id: ProviderID, + name: Schema.String, + source: Schema.Literals(["env", "config", "custom", "api"]), + env: Schema.Array(Schema.String), + key: Schema.optional(Schema.String), + options: Schema.Record(Schema.String, Schema.Any), + models: Schema.Record(Schema.String, Model), +}) + .annotate({ identifier: "Provider" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = Types.DeepMutable> + +const DefaultModelIDs = Schema.Record(Schema.String, Schema.String) + +export const ListResult = Schema.Struct({ + all: Schema.Array(Info), + default: DefaultModelIDs, + connected: Schema.Array(Schema.String), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ListResult = Types.DeepMutable> + +export const ConfigProvidersResult = Schema.Struct({ + providers: Schema.Array(Info), + default: DefaultModelIDs, +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ConfigProvidersResult = Types.DeepMutable> + +export function defaultModelIDs }>(providers: Record) { + return mapValues(providers, (item) => sort(Object.values(item.models))[0].id) +} export interface Interface { readonly list: () => Effect.Effect> @@ -942,7 +977,7 @@ function cost(c: ModelsDev.Model["cost"]): Model["cost"] { } function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model { - const m: Model = { + const base: Model = { id: ModelID.make(model.id), providerID: ProviderID.make(provider.id), name: model.name, @@ -985,11 +1020,12 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model release_date: model.release_date, variants: {}, } - Object.assign(m, patchKiloModel(provider.id, model)) // kilocode_change + Object.assign(base, patchKiloModel(provider.id, model)) // kilocode_change - m.variants = mapValues(ProviderTransform.variants(m), (v) => v) - - return m + return { + ...base, + variants: mapValues(ProviderTransform.variants(base), (v) => v), + } } export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { @@ -998,17 +1034,22 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { models[key] = fromModelsDevModel(provider, model) for (const [mode, opts] of Object.entries(model.experimental?.modes ?? {})) { const id = `${model.id}-${mode}` - const m = fromModelsDevModel(provider, model) - m.id = ModelID.make(id) - m.name = `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}` - if (opts.cost) m.cost = mergeDeep(m.cost, cost(opts.cost)) - // convert body params to camelCase for ai sdk compatibility - if (opts.provider?.body) - m.options = Object.fromEntries( - Object.entries(opts.provider.body).map(([k, v]) => [k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()), v]), - ) - if (opts.provider?.headers) m.headers = opts.provider.headers - models[id] = m + const base = fromModelsDevModel(provider, model) + models[id] = { + ...base, + id: ModelID.make(id), + name: `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`, + cost: opts.cost ? mergeDeep(base.cost, cost(opts.cost)) : base.cost, + options: opts.provider?.body + ? Object.fromEntries( + Object.entries(opts.provider.body).map(([k, v]) => [ + k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()), + v, + ]), + ) + : base.options, + headers: opts.provider?.headers ?? base.headers, + } } } return { diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index a452e6c596..67627b2607 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -631,6 +631,12 @@ export function variants(model: Provider.Model): Record [effort, { reasoningEffort: effort }])) + } + } + if (adaptiveEfforts) { return Object.fromEntries( adaptiveEfforts.map((effort) => [ @@ -842,11 +848,14 @@ export function options(input: { if (input.model.api.npm === "@ai-sdk/azure") { result["store"] = true + result["promptCacheKey"] = input.sessionID } - // kilocode_change start - if (input.model.api.npm === "@openrouter/ai-sdk-provider" || input.model.api.npm === "@kilocode/kilo-gateway") { - // kilocode_change end + if ( + input.model.api.npm === "@openrouter/ai-sdk-provider" || + input.model.api.npm === "@llmgateway/ai-sdk-provider" || + input.model.api.npm === "@kilocode/kilo-gateway" // kilocode_change + ) { result["usage"] = { include: true, } @@ -983,8 +992,11 @@ export function smallOptions(model: Provider.Model) { } return { thinkingConfig: { thinkingBudget: 0 } } } - // kilocode_change - add Kilo Gateway support - if (model.providerID === "openrouter" || model.api.npm === "@kilocode/kilo-gateway") { + if ( + model.providerID === "openrouter" || + model.providerID === "llmgateway" || + model.api.npm === "@kilocode/kilo-gateway" // kilocode_change + ) { if (model.api.id.includes("google")) { return { reasoning: { enabled: false } } } diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 4303da1aba..4fd6501e90 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -10,268 +10,270 @@ import { QuestionID } from "./schema" import { makeRuntime } from "@/effect/run-service" // kilocode_change import { KiloQuestion } from "@/kilocode/question" // kilocode_change -export namespace Question { - const log = Log.create({ service: "question" }) +const log = Log.create({ service: "question" }) - // Schemas +// Schemas - export class Option extends Schema.Class(file: string, fn: () => Effect.Effect) => FileTime.Service.use((svc) => svc.withLock(file, fn)) - -const fail = Effect.fn("FileTimeTest.fail")(function* (self: Effect.Effect) { - const exit = yield* self.pipe(Effect.exit) - if (Exit.isFailure(exit)) { - const err = Cause.squash(exit.cause) - return err instanceof Error ? err : new Error(String(err)) - } - throw new Error("expected file time effect to fail") -}) - -describe("file/time", () => { - describe("read() and get()", () => { - it.live("stores read timestamp", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - yield* put(file, "content") - - const before = yield* get(id, file) - expect(before).toBeUndefined() - - yield* read(id, file) - - const after = yield* get(id, file) - expect(after).toBeInstanceOf(Date) - expect(after!.getTime()).toBeGreaterThan(0) - }), - ), - ) - - it.live("tracks separate timestamps per session", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - yield* put(file, "content") - - const one = SessionID.make("ses_00000000000000000000000002") - const two = SessionID.make("ses_00000000000000000000000003") - yield* read(one, file) - yield* read(two, file) - - const first = yield* get(one, file) - const second = yield* get(two, file) - - expect(first).toBeDefined() - expect(second).toBeDefined() - }), - ), - ) - - it.live("updates timestamp on subsequent reads", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - yield* put(file, "content") - - yield* read(id, file) - const first = yield* get(id, file) - - yield* read(id, file) - const second = yield* get(id, file) - - expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime()) - }), - ), - ) - - it.live("isolates reads by directory", () => - Effect.gen(function* () { - const one = yield* tmpdirScoped() - const two = yield* tmpdirScoped() - const shared = yield* tmpdirScoped() - const file = path.join(shared, "file.txt") - yield* put(file, "content") - - yield* provideInstance(one)(read(id, file)) - const result = yield* provideInstance(two)(get(id, file)) - expect(result).toBeUndefined() - }), - ) - }) - - describe("assert()", () => { - it.live("passes when file has not been modified", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - yield* put(file, "content") - yield* touch(file, 1_000) - - yield* read(id, file) - yield* check(id, file) - }), - ), - ) - - it.live("throws when file was not read first", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - yield* put(file, "content") - - const err = yield* fail(check(id, file)) - expect(err.message).toContain("You must read file") - }), - ), - ) - - it.live("throws when file was modified after read", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - yield* put(file, "content") - yield* touch(file, 1_000) - - yield* read(id, file) - yield* put(file, "modified content") - yield* touch(file, 2_000) - - const err = yield* fail(check(id, file)) - expect(err.message).toContain("modified since it was last read") - }), - ), - ) - - it.live("includes timestamps in error message", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - yield* put(file, "content") - yield* touch(file, 1_000) - - yield* read(id, file) - yield* put(file, "modified") - yield* touch(file, 2_000) - - const err = yield* fail(check(id, file)) - expect(err.message).toContain("Last modification:") - expect(err.message).toContain("Last read:") - }), - ), - ) - }) - - describe("withLock()", () => { - it.live("executes function within lock", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - let hit = false - - yield* lock(file, () => - Effect.sync(() => { - hit = true - return "result" - }), - ) - - expect(hit).toBe(true) - }), - ), - ) - - it.live("returns function result", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - const result = yield* lock(file, () => Effect.succeed("success")) - expect(result).toBe("success") - }), - ), - ) - - it.live("serializes concurrent operations on same file", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - const order: number[] = [] - const hold = yield* Deferred.make() - const ready = yield* Deferred.make() - - const one = yield* lock(file, () => - Effect.gen(function* () { - order.push(1) - yield* Deferred.succeed(ready, void 0) - yield* Deferred.await(hold) - order.push(2) - }), - ).pipe(Effect.forkScoped) - - yield* Deferred.await(ready) - - const two = yield* lock(file, () => - Effect.sync(() => { - order.push(3) - order.push(4) - }), - ).pipe(Effect.forkScoped) - - yield* Deferred.succeed(hold, void 0) - yield* Fiber.join(one) - yield* Fiber.join(two) - - expect(order).toEqual([1, 2, 3, 4]) - }), - ), - ) - - it.live("allows concurrent operations on different files", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const onefile = path.join(dir, "file1.txt") - const twofile = path.join(dir, "file2.txt") - let one = false - let two = false - const hold = yield* Deferred.make() - const ready = yield* Deferred.make() - - const a = yield* lock(onefile, () => - Effect.gen(function* () { - one = true - yield* Deferred.succeed(ready, void 0) - yield* Deferred.await(hold) - expect(two).toBe(true) - }), - ).pipe(Effect.forkScoped) - - yield* Deferred.await(ready) - - const b = yield* lock(twofile, () => - Effect.sync(() => { - two = true - }), - ).pipe(Effect.forkScoped) - - yield* Fiber.join(b) - yield* Deferred.succeed(hold, void 0) - yield* Fiber.join(a) - - expect(one).toBe(true) - expect(two).toBe(true) - }), - ), - ) - - it.live("releases lock even if function throws", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - const err = yield* fail(lock(file, () => Effect.die(new Error("Test error")))) - expect(err.message).toContain("Test error") - - let hit = false - yield* lock(file, () => - Effect.sync(() => { - hit = true - }), - ) - expect(hit).toBe(true) - }), - ), - ) - }) - - describe("path normalization", () => { - it.live("read with forward slashes, assert with backslashes", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - yield* put(file, "content") - yield* touch(file, 1_000) - - const forward = file.replaceAll("\\", "/") - yield* read(id, forward) - yield* check(id, file) - }), - ), - ) - - it.live("read with backslashes, assert with forward slashes", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - yield* put(file, "content") - yield* touch(file, 1_000) - - const forward = file.replaceAll("\\", "/") - yield* read(id, file) - yield* check(id, forward) - }), - ), - ) - - it.live("get returns timestamp regardless of slash direction", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - yield* put(file, "content") - - const forward = file.replaceAll("\\", "/") - yield* read(id, forward) - - const result = yield* get(id, file) - expect(result).toBeInstanceOf(Date) - }), - ), - ) - - it.live("withLock serializes regardless of slash direction", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - const forward = file.replaceAll("\\", "/") - const order: number[] = [] - const hold = yield* Deferred.make() - const ready = yield* Deferred.make() - - const one = yield* lock(file, () => - Effect.gen(function* () { - order.push(1) - yield* Deferred.succeed(ready, void 0) - yield* Deferred.await(hold) - order.push(2) - }), - ).pipe(Effect.forkScoped) - - yield* Deferred.await(ready) - - const two = yield* lock(forward, () => - Effect.sync(() => { - order.push(3) - order.push(4) - }), - ).pipe(Effect.forkScoped) - - yield* Deferred.succeed(hold, void 0) - yield* Fiber.join(one) - yield* Fiber.join(two) - - expect(order).toEqual([1, 2, 3, 4]) - }), - ), - ) - }) - - describe("stat() Filesystem.stat pattern", () => { - it.live("reads file modification time via Filesystem.stat()", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - yield* put(file, "content") - yield* touch(file, 1_000) - - yield* read(id, file) - - const stat = Filesystem.stat(file) - expect(stat?.mtime).toBeInstanceOf(Date) - expect(stat!.mtime.getTime()).toBeGreaterThan(0) - - yield* check(id, file) - }), - ), - ) - - it.live("detects modification via stat mtime", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "file.txt") - yield* put(file, "original") - yield* touch(file, 1_000) - - yield* read(id, file) - - const first = Filesystem.stat(file) - - yield* put(file, "modified") - yield* touch(file, 2_000) - - const second = Filesystem.stat(file) - expect(second!.mtime.getTime()).toBeGreaterThan(first!.mtime.getTime()) - - yield* fail(check(id, file)) - }), - ), - ) - }) -}) diff --git a/packages/opencode/test/fixture/log-init-worker.ts b/packages/opencode/test/fixture/log-init-worker.ts new file mode 100644 index 0000000000..c50eaf329f --- /dev/null +++ b/packages/opencode/test/fixture/log-init-worker.ts @@ -0,0 +1,63 @@ +// kilocode_change - new file + +import fs from "fs/promises" +import path from "path" + +const dir = process.argv[2] +const mode = process.argv[3] + +if (!dir) { + throw new Error("missing temp dir") +} + +process.env.XDG_DATA_HOME = path.join(dir, "share") +process.env.XDG_CACHE_HOME = path.join(dir, "cache") +process.env.XDG_CONFIG_HOME = path.join(dir, "config") +process.env.XDG_STATE_HOME = path.join(dir, "state") +process.env.KILO_TEST_HOME = path.join(dir, "home") + +const Log = await import("../../src/util/log") + +async function bytes(file: string) { + return fs + .stat(file) + .then((stat) => stat.size) + .catch(() => 0) +} + +async function wait(file: string, need: number) { + for (let i = 0; i < 500; i++) { + if ((await bytes(file)) >= need) return + await Bun.sleep(10) + } + + throw new Error(`log file did not reach ${need} bytes before missing-file test`) +} + +await Log.init({ + print: false, + dev: true, + level: "DEBUG", +}) + +const log = Log.create({ service: "test" }) +const msg = "x".repeat(1024 * 1024) +const first = mode === "missing" ? 10 : 55 + +await fs.mkdir(path.join(dir, "share", "kilo", "log"), { recursive: true }) +for (const _ of Array.from({ length: first })) { + log.info(msg) +} + +if (mode === "missing") { + await wait(Log.file(), first * msg.length) + await fs.unlink(Log.file()) + + for (const _ of Array.from({ length: 45 })) { + log.info(msg) + } +} + +await Bun.sleep(300) + +process.stdout.write(Log.file()) diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts index 39826aad16..5530e195b2 100644 --- a/packages/opencode/test/format/format.test.ts +++ b/packages/opencode/test/format/format.test.ts @@ -10,37 +10,55 @@ import * as Formatter from "../../src/format/formatter" const it = testEffect(Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer)) describe("Format", () => { - it.live("status() returns built-in formatters when no config overrides", () => + it.live("status() returns empty list when no formatters are configured", () => provideTmpdirInstance(() => Format.Service.use((fmt) => Effect.gen(function* () { - const statuses = yield* fmt.status() - expect(Array.isArray(statuses)).toBe(true) - expect(statuses.length).toBeGreaterThan(0) - - for (const item of statuses) { - expect(typeof item.name).toBe("string") - expect(Array.isArray(item.extensions)).toBe(true) - expect(typeof item.enabled).toBe("boolean") - } - - const gofmt = statuses.find((item) => item.name === "gofmt") - expect(gofmt).toBeDefined() - expect(gofmt!.extensions).toContain(".go") + expect(yield* fmt.status()).toEqual([]) }), ), ), ) - it.live("status() returns empty list when formatter is disabled", () => + it.live("status() returns built-in formatters when formatter is true", () => provideTmpdirInstance( () => Format.Service.use((fmt) => Effect.gen(function* () { - expect(yield* fmt.status()).toEqual([]) + const statuses = yield* fmt.status() + const gofmt = statuses.find((item) => item.name === "gofmt") + expect(gofmt).toBeDefined() + expect(gofmt!.extensions).toContain(".go") }), ), - { config: { formatter: false } }, + { + config: { + formatter: true, + }, + }, + ), + ) + + it.live("status() keeps built-in formatters when config object is provided", () => + provideTmpdirInstance( + () => + Format.Service.use((fmt) => + Effect.gen(function* () { + const statuses = yield* fmt.status() + const gofmt = statuses.find((item) => item.name === "gofmt") + const mix = statuses.find((item) => item.name === "mix") + expect(gofmt).toBeDefined() + expect(gofmt!.extensions).toContain(".go") + expect(mix).toBeDefined() + }), + ), + { + config: { + formatter: { + gofmt: {}, + }, + }, + }, ), ) @@ -51,7 +69,9 @@ describe("Format", () => { Effect.gen(function* () { const statuses = yield* fmt.status() const gofmt = statuses.find((item) => item.name === "gofmt") + const mix = statuses.find((item) => item.name === "mix") expect(gofmt).toBeUndefined() + expect(mix).toBeDefined() }), ), { @@ -111,68 +131,81 @@ describe("Format", () => { const a = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status()), { config: { formatter: false }, }) - const b = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status())) + const b = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status()), { + config: { + formatter: true, + }, + }) expect(a).toEqual([]) - expect(b.length).toBeGreaterThan(0) + expect(b.find((item) => item.name === "gofmt")).toBeDefined() }), ) it.live("runs enabled checks for matching formatters in parallel", () => - provideTmpdirInstance((path) => - Effect.gen(function* () { - const file = `${path}/test.parallel` - yield* Effect.promise(() => Bun.write(file, "x")) + provideTmpdirInstance( + (path) => + Effect.gen(function* () { + const file = `${path}/test.parallel` + yield* Effect.promise(() => Bun.write(file, "x")) - const one = { - extensions: Formatter.gofmt.extensions, - enabled: Formatter.gofmt.enabled, - } - const two = { - extensions: Formatter.mix.extensions, - enabled: Formatter.mix.enabled, - } + const one = { + extensions: Formatter.gofmt.extensions, + enabled: Formatter.gofmt.enabled, + } + const two = { + extensions: Formatter.mix.extensions, + enabled: Formatter.mix.enabled, + } - let active = 0 - let max = 0 + let active = 0 + let max = 0 - yield* Effect.acquireUseRelease( - Effect.sync(() => { - Formatter.gofmt.extensions = [".parallel"] - Formatter.mix.extensions = [".parallel"] - Formatter.gofmt.enabled = async () => { - active++ - max = Math.max(max, active) - await Bun.sleep(20) - active-- - return ["sh", "-c", "true"] - } - Formatter.mix.enabled = async () => { - active++ - max = Math.max(max, active) - await Bun.sleep(20) - active-- - return ["sh", "-c", "true"] - } - }), - () => - Format.Service.use((fmt) => - Effect.gen(function* () { - yield* fmt.init() - yield* fmt.file(file) - }), - ), - () => + yield* Effect.acquireUseRelease( Effect.sync(() => { - Formatter.gofmt.extensions = one.extensions - Formatter.gofmt.enabled = one.enabled - Formatter.mix.extensions = two.extensions - Formatter.mix.enabled = two.enabled + Formatter.gofmt.extensions = [".parallel"] + Formatter.mix.extensions = [".parallel"] + Formatter.gofmt.enabled = async () => { + active++ + max = Math.max(max, active) + await Bun.sleep(20) + active-- + return ["sh", "-c", "true"] + } + Formatter.mix.enabled = async () => { + active++ + max = Math.max(max, active) + await Bun.sleep(20) + active-- + return ["sh", "-c", "true"] + } }), - ) + () => + Format.Service.use((fmt) => + Effect.gen(function* () { + yield* fmt.init() + yield* fmt.file(file) + }), + ), + () => + Effect.sync(() => { + Formatter.gofmt.extensions = one.extensions + Formatter.gofmt.enabled = one.enabled + Formatter.mix.extensions = two.extensions + Formatter.mix.enabled = two.enabled + }), + ) - expect(max).toBe(2) - }), + expect(max).toBe(2) + }), + { + config: { + formatter: { + gofmt: {}, + mix: {}, + }, + }, + }, ), ) diff --git a/packages/opencode/test/kilocode/config-gitignore.test.ts b/packages/opencode/test/kilocode/config-gitignore.test.ts index c957f93a5f..9ed587e0cf 100644 --- a/packages/opencode/test/kilocode/config-gitignore.test.ts +++ b/packages/opencode/test/kilocode/config-gitignore.test.ts @@ -17,7 +17,7 @@ import { Npm } from "@opencode-ai/shared/npm" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Env } from "../../src/env" import { Auth } from "../../src/auth" -import { Account } from "../../src/account" +import { Account } from "../../src/account/account" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" diff --git a/packages/opencode/test/kilocode/edit-permission-filediff.test.ts b/packages/opencode/test/kilocode/edit-permission-filediff.test.ts new file mode 100644 index 0000000000..3ac560b566 --- /dev/null +++ b/packages/opencode/test/kilocode/edit-permission-filediff.test.ts @@ -0,0 +1,224 @@ +// kilocode_change - new file +// +// Ensure the edit tool always includes `filediff` in its +// permission-ask metadata. Without `filediff`, the VS Code extension's +// PermissionDock cannot render the inline diff preview. + +import { afterAll, afterEach, describe, test, expect } from "bun:test" +import path from "path" +import { Effect, Layer, ManagedRuntime } from "effect" +import { EditTool } from "../../src/tool/edit" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import { LSP } from "../../src/lsp" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { Format } from "../../src/format" +import { Agent } from "../../src/agent/agent" +import { Bus } from "../../src/bus" +import { Truncate } from "../../src/tool" +import { SessionID, MessageID } from "../../src/session/schema" + +const runtime = ManagedRuntime.make( + Layer.mergeAll( + LSP.defaultLayer, + AppFileSystem.defaultLayer, + Format.defaultLayer, + Bus.layer, + Truncate.defaultLayer, + Agent.defaultLayer, + ), +) + +afterAll(async () => { + await runtime.dispose() +}) + +afterEach(async () => { + await Instance.disposeAll() +}) + +const resolve = () => + runtime.runPromise( + Effect.gen(function* () { + const info = yield* EditTool + return yield* info.init() + }), + ) + +function capture() { + const requests: Array<{ permission: string; metadata: Record }> = [] + const ctx = { + sessionID: SessionID.make("ses_test-edit-filediff"), + messageID: MessageID.make(""), + callID: "", + agent: "code", + abort: AbortSignal.any([]), + messages: [] as any[], + metadata: () => Effect.void, + ask: (req: any) => + Effect.sync(() => { + requests.push(req) + }), + } + return { requests, ctx } +} + +describe("edit tool permission filediff metadata", () => { + describe("new file creation (empty oldString)", () => { + test("ctx.ask() includes filediff in metadata", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "new.txt") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const edit = await resolve() + const { requests, ctx } = capture() + + await Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "", + newString: "hello world\nline two\n", + }, + ctx, + ), + ) + + const req = requests.find((r) => r.permission === "edit") + expect(req).toBeDefined() + expect(req!.metadata.filediff).toBeDefined() + expect(req!.metadata.filediff.file).toBe(filepath) + expect(req!.metadata.filediff.additions).toBeGreaterThan(0) + expect(req!.metadata.filediff.patch).toContain("+hello world") + }, + }) + }) + }) + + describe("existing file edit (non-empty oldString)", () => { + test("ctx.ask() includes filediff in metadata", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "existing.txt") + await Bun.write(filepath, "line one\nline two\nline three\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const edit = await resolve() + const { requests, ctx } = capture() + + await Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "line two", + newString: "replaced line", + }, + ctx, + ), + ) + + const req = requests.find((r) => r.permission === "edit") + expect(req).toBeDefined() + expect(req!.metadata.filediff).toBeDefined() + expect(req!.metadata.filediff.file).toBe(filepath) + expect(req!.metadata.filediff.additions).toBeGreaterThan(0) + expect(req!.metadata.filediff.deletions).toBeGreaterThan(0) + expect(req!.metadata.filediff.patch).toContain("+replaced line") + expect(req!.metadata.filediff.patch).toContain("-line two") + }, + }) + }) + + test("filediff.patch contains valid unified diff", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "diff-check.txt") + await Bun.write(filepath, "alpha\nbeta\ngamma\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const edit = await resolve() + const { requests, ctx } = capture() + + await Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "beta", + newString: "BETA", + }, + ctx, + ), + ) + + const req = requests.find((r) => r.permission === "edit") + expect(req!.metadata.filediff.patch).toContain("---") + expect(req!.metadata.filediff.patch).toContain("+++") + expect(req!.metadata.filediff.patch).toContain("@@") + }, + }) + }) + }) + + describe("result metadata also includes filediff", () => { + test("new file result includes filediff", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "result-new.txt") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const edit = await resolve() + const { ctx } = capture() + + const result = await Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "", + newString: "content\n", + }, + ctx, + ), + ) + + expect(result.metadata.filediff).toBeDefined() + expect(result.metadata.filediff.file).toBe(filepath) + }, + }) + }) + + test("existing file result includes filediff", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "result-edit.txt") + await Bun.write(filepath, "before\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const edit = await resolve() + const { ctx } = capture() + + const result = await Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "before", + newString: "after", + }, + ctx, + ), + ) + + expect(result.metadata.filediff).toBeDefined() + expect(result.metadata.filediff.file).toBe(filepath) + expect(result.metadata.filediff.additions).toBeGreaterThan(0) + expect(result.metadata.filediff.deletions).toBeGreaterThan(0) + }, + }) + }) + }) +}) diff --git a/packages/opencode/test/kilocode/kilo-ripgrep-stream.test.ts b/packages/opencode/test/kilocode/kilo-ripgrep-stream.test.ts new file mode 100644 index 0000000000..6df605cb9b --- /dev/null +++ b/packages/opencode/test/kilocode/kilo-ripgrep-stream.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test" +import { KiloRipgrepStream } from "../../src/kilocode/kilo-ripgrep-stream" + +describe("KiloRipgrepStream", () => { + test("drains lines without splitting UTF-8 characters", () => { + const icon = "\u{1f600}" + const bytes = Buffer.from(`src/${icon}.ts\nnext.ts\n`) + const decoder = KiloRipgrepStream.decoder() + const lines: string[] = [] + + const first = KiloRipgrepStream.drain(decoder, "", bytes.subarray(0, 5), (line) => lines.push(line)) + const rest = KiloRipgrepStream.drain(decoder, first, bytes.subarray(5), (line) => lines.push(line)) + decoder.end() + + if (rest) lines.push(rest) + + expect(lines).toEqual([`src/${icon}.ts`, "next.ts"]) + }) +}) diff --git a/packages/opencode/test/kilocode/lsp-typescript-lightweight.test.ts b/packages/opencode/test/kilocode/lsp-typescript-lightweight.test.ts index 0f87d0afbe..72580bc49a 100644 --- a/packages/opencode/test/kilocode/lsp-typescript-lightweight.test.ts +++ b/packages/opencode/test/kilocode/lsp-typescript-lightweight.test.ts @@ -18,20 +18,17 @@ describe("typescript lightweight mode", () => { describe("spawn gate", () => { test("Typescript.spawn returns undefined when flag is off", async () => { const saved = Flag.KILO_EXPERIMENTAL_LSP_TOOL - // @ts-expect-error - override static flag Flag.KILO_EXPERIMENTAL_LSP_TOOL = false try { const result = await LSPServer.Typescript.spawn("/tmp/any") expect(result).toBeUndefined() } finally { - // @ts-expect-error Flag.KILO_EXPERIMENTAL_LSP_TOOL = saved } }) test("Typescript.spawn calls native_tsgo when flag is on", async () => { const saved = Flag.KILO_EXPERIMENTAL_LSP_TOOL - // @ts-expect-error Flag.KILO_EXPERIMENTAL_LSP_TOOL = true const spy = spyOn(TsCheck, "native_tsgo").mockResolvedValue(undefined) @@ -40,7 +37,6 @@ describe("typescript lightweight mode", () => { expect(spy).toHaveBeenCalled() expect(result).toBeUndefined() // undefined because mock returns no binary } finally { - // @ts-expect-error Flag.KILO_EXPERIMENTAL_LSP_TOOL = saved spy.mockRestore() } diff --git a/packages/opencode/test/kilocode/read-directory.test.ts b/packages/opencode/test/kilocode/read-directory.test.ts index a07ec9d502..bb722b9f74 100644 --- a/packages/opencode/test/kilocode/read-directory.test.ts +++ b/packages/opencode/test/kilocode/read-directory.test.ts @@ -5,7 +5,6 @@ import path from "path" import { Agent } from "../../src/agent/agent" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { FileTime } from "../../src/file/time" import { LSP } from "../../src/lsp" import { Instruction } from "../../src/session/instruction" import { Truncate } from "../../src/tool" @@ -33,7 +32,6 @@ const it = testEffect( Agent.defaultLayer, AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer, - FileTime.defaultLayer, Instruction.defaultLayer, LSP.defaultLayer, Truncate.defaultLayer, diff --git a/packages/opencode/test/kilocode/ripgrep-stream.test.ts b/packages/opencode/test/kilocode/ripgrep-stream.test.ts deleted file mode 100644 index 2bb61a90dc..0000000000 --- a/packages/opencode/test/kilocode/ripgrep-stream.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { RipgrepStream } from "../../src/kilocode/ripgrep-stream" - -describe("RipgrepStream", () => { - test("drains lines without splitting UTF-8 characters", () => { - const icon = "\u{1f600}" - const bytes = Buffer.from(`src/${icon}.ts\nnext.ts\n`) - const decoder = RipgrepStream.decoder() - const lines: string[] = [] - - const first = RipgrepStream.drain(decoder, "", bytes.subarray(0, 5), (line) => lines.push(line)) - const rest = RipgrepStream.drain(decoder, first, bytes.subarray(5), (line) => lines.push(line)) + decoder.end() - - if (rest) lines.push(rest) - - expect(lines).toEqual([`src/${icon}.ts`, "next.ts"]) - }) -}) diff --git a/packages/opencode/test/kilocode/session-compaction-cap.test.ts b/packages/opencode/test/kilocode/session-compaction-cap.test.ts index 7c74f2b71a..44d5d4793e 100644 --- a/packages/opencode/test/kilocode/session-compaction-cap.test.ts +++ b/packages/opencode/test/kilocode/session-compaction-cap.test.ts @@ -13,7 +13,6 @@ import { Command } from "../../src/command" import { Config } from "../../src/config" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Env } from "../../src/env" -import { FileTime } from "../../src/file/time" import { Ripgrep } from "../../src/file/ripgrep" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Format } from "../../src/format" @@ -117,16 +116,6 @@ const lsp = Layer.succeed( }), ) -const filetime = Layer.succeed( - FileTime.Service, - FileTime.Service.of({ - read: () => Effect.void, - get: () => Effect.succeed(undefined), - assert: () => Effect.void, - withLock: (_filepath, fn) => fn(), - }), -) - const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) const runState = SessionRunState.layer.pipe(Layer.provide(status)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) @@ -143,7 +132,6 @@ function makeHttp() { plugin, Config.defaultLayer, ProviderSvc.defaultLayer, - filetime, lsp, mcp, AppFileSystem.defaultLayer, diff --git a/packages/opencode/test/lsp/index.test.ts b/packages/opencode/test/lsp/index.test.ts index 6fe9e70e5c..72468bc789 100644 --- a/packages/opencode/test/lsp/index.test.ts +++ b/packages/opencode/test/lsp/index.test.ts @@ -16,15 +16,38 @@ const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.default describe("lsp.spawn", () => { it.live("does not spawn builtin LSP for files outside instance", () => + provideTmpdirInstance( + (dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) + + try { + yield* lsp.touchFile(path.join(dir, "..", "outside.ts")) + yield* lsp.hover({ + file: path.join(dir, "..", "hover.ts"), + line: 0, + character: 0, + }) + expect(spy).toHaveBeenCalledTimes(0) + } finally { + spy.mockRestore() + } + }), + ), + { config: { lsp: true } }, + ), + ) + + it.live("does not spawn builtin LSP for files inside instance when LSP is unset", () => provideTmpdirInstance((dir) => LSP.Service.use((lsp) => Effect.gen(function* () { const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) try { - yield* lsp.touchFile(path.join(dir, "..", "outside.ts")) yield* lsp.hover({ - file: path.join(dir, "..", "hover.ts"), + file: path.join(dir, "src", "inside.ts"), line: 0, character: 0, }) @@ -37,30 +60,62 @@ describe("lsp.spawn", () => { ), ) - // kilocode_change start - enable flag so spawn() is reached - it.live("would spawn builtin LSP for files inside instance", () => - provideTmpdirInstance((dir) => - LSP.Service.use((lsp) => - Effect.gen(function* () { - const saved = Flag.KILO_EXPERIMENTAL_LSP_TOOL - // @ts-expect-error - Flag.KILO_EXPERIMENTAL_LSP_TOOL = true - const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) + // kilocode_change start - enable flag so spawn() is reached past the TsClient short-circuit + it.live("would spawn builtin LSP for files inside instance when lsp is true", () => + provideTmpdirInstance( + (dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const saved = Flag.KILO_EXPERIMENTAL_LSP_TOOL + Flag.KILO_EXPERIMENTAL_LSP_TOOL = true + const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) - try { - yield* lsp.hover({ - file: path.join(dir, "src", "inside.ts"), - line: 0, - character: 0, - }) - expect(spy).toHaveBeenCalledTimes(1) - } finally { - // @ts-expect-error - Flag.KILO_EXPERIMENTAL_LSP_TOOL = saved - spy.mockRestore() - } - }), - ), + try { + yield* lsp.hover({ + file: path.join(dir, "src", "inside.ts"), + line: 0, + character: 0, + }) + expect(spy).toHaveBeenCalledTimes(1) + } finally { + Flag.KILO_EXPERIMENTAL_LSP_TOOL = saved + spy.mockRestore() + } + }), + ), + { config: { lsp: true } }, + ), + ) + + it.live("would spawn builtin LSP for files inside instance when config object is provided", () => + provideTmpdirInstance( + (dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const saved = Flag.KILO_EXPERIMENTAL_LSP_TOOL + Flag.KILO_EXPERIMENTAL_LSP_TOOL = true + const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) + + try { + yield* lsp.hover({ + file: path.join(dir, "src", "inside.ts"), + line: 0, + character: 0, + }) + expect(spy).toHaveBeenCalledTimes(1) + } finally { + Flag.KILO_EXPERIMENTAL_LSP_TOOL = saved + spy.mockRestore() + } + }), + ), + { + config: { + lsp: { + eslint: { disabled: true }, + }, + }, + }, ), ) // kilocode_change end @@ -68,7 +123,6 @@ describe("lsp.spawn", () => { // kilocode_change start - Typescript spawn is gated behind KILO_EXPERIMENTAL_LSP_TOOL. test("spawns tsgo LSP when KILO_EXPERIMENTAL_LSP_TOOL is enabled", async () => { const saved = Flag.KILO_EXPERIMENTAL_LSP_TOOL - // @ts-expect-error - override static flag for test Flag.KILO_EXPERIMENTAL_LSP_TOOL = true await using tmp = await tmpdir() @@ -91,7 +145,6 @@ describe("lsp.spawn", () => { }, }) } finally { - // @ts-expect-error Flag.KILO_EXPERIMENTAL_LSP_TOOL = saved spawnSpy.mockRestore() tsgoSpy.mockRestore() @@ -100,13 +153,11 @@ describe("lsp.spawn", () => { test("Typescript.spawn returns undefined when KILO_EXPERIMENTAL_LSP_TOOL is off", async () => { const saved = Flag.KILO_EXPERIMENTAL_LSP_TOOL - // @ts-expect-error Flag.KILO_EXPERIMENTAL_LSP_TOOL = false try { const result = await LSPServer.Typescript.spawn("/tmp/any") expect(result).toBeUndefined() } finally { - // @ts-expect-error Flag.KILO_EXPERIMENTAL_LSP_TOOL = saved } }) diff --git a/packages/opencode/test/lsp/lifecycle.test.ts b/packages/opencode/test/lsp/lifecycle.test.ts index fe14729736..13f21c93cc 100644 --- a/packages/opencode/test/lsp/lifecycle.test.ts +++ b/packages/opencode/test/lsp/lifecycle.test.ts @@ -46,17 +46,49 @@ describe("LSP service lifecycle", () => { ), ) - it.live("hasClients() returns true for .ts files in instance", () => + it.live("hasClients() returns false for .ts files in instance when LSP is unset", () => provideTmpdirInstance((dir) => LSP.Service.use((lsp) => Effect.gen(function* () { const result = yield* lsp.hasClients(path.join(dir, "test.ts")) - expect(result).toBe(true) + expect(result).toBe(false) }), ), ), ) + it.live("hasClients() returns true for .ts files in instance when lsp is true", () => + provideTmpdirInstance( + (dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.hasClients(path.join(dir, "test.ts")) + expect(result).toBe(true) + }), + ), + { config: { lsp: true } }, + ), + ) + + it.live("hasClients() keeps built-in LSPs when config object is provided", () => + provideTmpdirInstance( + (dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.hasClients(path.join(dir, "test.ts")) + expect(result).toBe(true) + }), + ), + { + config: { + lsp: { + eslint: { disabled: true }, + }, + }, + }, + ), + ) + it.live("hasClients() returns false for files outside instance", () => provideTmpdirInstance((dir) => LSP.Service.use((lsp) => diff --git a/packages/opencode/test/plugin/workspace-adaptor.test.ts b/packages/opencode/test/plugin/workspace-adaptor.test.ts index 9153901fa9..be251277e4 100644 --- a/packages/opencode/test/plugin/workspace-adaptor.test.ts +++ b/packages/opencode/test/plugin/workspace-adaptor.test.ts @@ -14,7 +14,6 @@ const { Instance } = await import("../../src/project/instance") const experimental = Flag.KILO_EXPERIMENTAL_WORKSPACES -// @ts-expect-error tests override the flag directly Flag.KILO_EXPERIMENTAL_WORKSPACES = true afterEach(async () => { @@ -28,7 +27,6 @@ afterAll(() => { process.env.KILO_DISABLE_DEFAULT_PLUGINS = disableDefault } - // @ts-expect-error restore original test flag value Flag.KILO_EXPERIMENTAL_WORKSPACES = experimental }) diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index bfe19041f1..f9a61caa9a 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -62,6 +62,7 @@ delete process.env["AWS_PROFILE"] delete process.env["AWS_REGION"] delete process.env["AWS_BEARER_TOKEN_BEDROCK"] delete process.env["OPENROUTER_API_KEY"] +delete process.env["LLM_GATEWAY_API_KEY"] delete process.env["GROQ_API_KEY"] delete process.env["MISTRAL_API_KEY"] delete process.env["PERPLEXITY_API_KEY"] diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index 222be17f58..9d0555874a 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -165,16 +165,3 @@ describe("session messages endpoint", () => { ) }) }) - -describe("session.prompt_async error handling", () => { - test("prompt_async route has error handler for detached prompt call", async () => { - const src = await Bun.file(new URL("../../src/server/instance/session.ts", import.meta.url)).text() - const start = src.indexOf('"/:sessionID/prompt_async"') - const end = src.indexOf('"/:sessionID/command"', start) - expect(start).toBeGreaterThan(-1) - expect(end).toBeGreaterThan(start) - const route = src.slice(start, end) - expect(route).toContain(".catch(") - expect(route).toContain("Bus.publish(Session.Event.Error") - }) -}) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 91338b0c7d..1cf6abb779 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -8,7 +8,6 @@ import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Command } from "../../src/command" import { Config } from "../../src/config" -import { FileTime } from "../../src/file/time" import { LSP } from "../../src/lsp" import { MCP } from "../../src/mcp" import { Permission } from "../../src/permission" @@ -149,16 +148,6 @@ const lsp = Layer.succeed( }), ) -const filetime = Layer.succeed( - FileTime.Service, - FileTime.Service.of({ - read: () => Effect.void, - get: () => Effect.succeed(undefined), - assert: () => Effect.void, - withLock: (_filepath, fn) => fn(), - }), -) - const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) const run = SessionRunState.layer.pipe(Layer.provide(status)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) @@ -174,7 +163,6 @@ function makeHttp() { Plugin.defaultLayer, Config.defaultLayer, ProviderSvc.defaultLayer, - filetime, lsp, mcp, AppFileSystem.defaultLayer, diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 1f66ccb995..6517547339 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -33,7 +33,6 @@ import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Command } from "../../src/command" import { Config } from "../../src/config" -import { FileTime } from "../../src/file/time" import { LSP } from "../../src/lsp" import { MCP } from "../../src/mcp" import { Permission } from "../../src/permission" @@ -102,16 +101,6 @@ const lsp = Layer.succeed( }), ) -const filetime = Layer.succeed( - FileTime.Service, - FileTime.Service.of({ - read: () => Effect.void, - get: () => Effect.succeed(undefined), - assert: () => Effect.void, - withLock: (_filepath, fn) => fn(), - }), -) - const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) const run = SessionRunState.layer.pipe(Layer.provide(status)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) @@ -128,7 +117,6 @@ function makeHttp() { Plugin.defaultLayer, Config.defaultLayer, ProviderSvc.defaultLayer, - filetime, lsp, mcp, AppFileSystem.defaultLayer, diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index 2359f06a31..e217300d09 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -3,8 +3,8 @@ import { beforeEach, describe, expect } from "bun:test" import { Effect, Exit, Layer, Option } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" -import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account" -import { Account } from "../../src/account" +import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema" +import { Account } from "../../src/account/account" import { AccountRepo } from "../../src/account/repo" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Bus } from "../../src/bus" @@ -72,7 +72,7 @@ const share = (id: SessionID) => Database.use((db) => db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, id)).get()) const seed = (url: string, org?: string) => - AccountRepo.use((repo) => + AccountRepo.Service.use((repo) => repo.persistAccount({ id: AccountID.make("account-1"), email: "user@example.com", diff --git a/packages/opencode/test/sync/index.test.ts b/packages/opencode/test/sync/index.test.ts index 26612c9b94..fd43b543b8 100644 --- a/packages/opencode/test/sync/index.test.ts +++ b/packages/opencode/test/sync/index.test.ts @@ -15,12 +15,10 @@ const original = Flag.KILO_EXPERIMENTAL_WORKSPACES beforeEach(() => { Database.close() - // @ts-expect-error don't do this normally, but it works Flag.KILO_EXPERIMENTAL_WORKSPACES = true }) afterEach(() => { - // @ts-expect-error don't do this normally, but it works Flag.KILO_EXPERIMENTAL_WORKSPACES = original }) diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 2e3dfa8a69..4759b8be36 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -5,7 +5,6 @@ import { Effect, Layer, ManagedRuntime } from "effect" import { EditTool } from "../../src/tool/edit" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" -import { FileTime } from "../../src/file/time" import { LSP } from "../../src/lsp" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Format } from "../../src/format" @@ -38,7 +37,6 @@ async function touch(file: string, time: number) { const runtime = ManagedRuntime.make( Layer.mergeAll( LSP.defaultLayer, - FileTime.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer, @@ -59,9 +57,6 @@ const resolve = () => }), ) -const readFileTime = (sessionID: SessionID, filepath: string) => - runtime.runPromise(FileTime.Service.use((ft) => ft.read(sessionID, filepath))) - const subscribeBus = (def: D, callback: () => unknown) => runtime.runPromise(Bus.Service.use((bus) => bus.subscribeCallback(def, callback))) @@ -173,8 +168,6 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await readFileTime(ctx.sessionID, filepath) - const edit = await resolve() const result = await Effect.runPromise( edit.execute( @@ -202,8 +195,6 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await readFileTime(ctx.sessionID, filepath) - const edit = await resolve() await expect( Effect.runPromise( @@ -254,8 +245,6 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await readFileTime(ctx.sessionID, filepath) - const edit = await resolve() await expect( Effect.runPromise( @@ -273,65 +262,6 @@ describe("tool.edit", () => { }) }) - test("throws error when file was not read first (FileTime)", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - await expect( - Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "content", - newString: "modified", - }, - ctx, - ), - ), - ).rejects.toThrow("You must read file") - }, - }) - }) - - test("throws error when file has been modified since read", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "original content", "utf-8") - await touch(filepath, 1_000) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - // Read first - await readFileTime(ctx.sessionID, filepath) - - // Simulate external modification - await fs.writeFile(filepath, "modified externally", "utf-8") - await touch(filepath, 2_000) - - // Try to edit with the new content - const edit = await resolve() - await expect( - Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "modified externally", - newString: "edited", - }, - ctx, - ), - ), - ).rejects.toThrow("modified since it was last read") - }, - }) - }) - test("replaces all occurrences with replaceAll option", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "file.txt") @@ -340,8 +270,6 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await readFileTime(ctx.sessionID, filepath) - const edit = await resolve() await Effect.runPromise( edit.execute( @@ -369,8 +297,6 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await readFileTime(ctx.sessionID, filepath) - const { FileWatcher } = await import("../../src/file/watcher") const updated = await onceBus(FileWatcher.Event.Updated) @@ -406,8 +332,6 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await readFileTime(ctx.sessionID, filepath) - const edit = await resolve() await Effect.runPromise( edit.execute( @@ -434,8 +358,6 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await readFileTime(ctx.sessionID, filepath) - const edit = await resolve() await Effect.runPromise( edit.execute( @@ -487,8 +409,6 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await readFileTime(ctx.sessionID, dirpath) - const edit = await resolve() await expect( Effect.runPromise( @@ -514,8 +434,6 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await readFileTime(ctx.sessionID, filepath) - const edit = await resolve() const result = await Effect.runPromise( edit.execute( @@ -587,7 +505,6 @@ describe("tool.edit", () => { fn: async () => { const edit = await resolve() const filePath = path.join(tmp.path, "test.txt") - await readFileTime(ctx.sessionID, filePath) await Effect.runPromise( edit.execute( { @@ -730,8 +647,6 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await readFileTime(ctx.sessionID, filepath) - const edit = await resolve() // Two concurrent edits @@ -746,9 +661,6 @@ describe("tool.edit", () => { ), ) - // Need to read again since FileTime tracks per-session - await readFileTime(ctx.sessionID, filepath) - const promise2 = Effect.runPromise( edit.execute( { diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index c3f65ab46f..ba3de2a34d 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -4,7 +4,6 @@ import path from "path" import { Agent } from "../../src/agent/agent" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { FileTime } from "../../src/file/time" import { LSP } from "../../src/lsp" import { Permission } from "../../src/permission" import { Instance } from "../../src/project/instance" @@ -40,7 +39,6 @@ const it = testEffect( Agent.defaultLayer, AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer, - FileTime.defaultLayer, Instruction.defaultLayer, LSP.defaultLayer, Truncate.defaultLayer, diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 46bbe2e401..50d3b57527 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -6,7 +6,6 @@ import { WriteTool } from "../../src/tool/write" import { Instance } from "../../src/project/instance" import { LSP } from "../../src/lsp" import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { FileTime } from "../../src/file/time" import { Bus } from "../../src/bus" import { Format } from "../../src/format" import { Truncate } from "../../src/tool" @@ -36,7 +35,6 @@ const it = testEffect( Layer.mergeAll( LSP.defaultLayer, AppFileSystem.defaultLayer, - FileTime.defaultLayer, Bus.layer, Format.defaultLayer, CrossSpawnSpawner.defaultLayer, @@ -58,11 +56,6 @@ const run = Effect.fn("WriteToolTest.run")(function* ( return yield* tool.execute(args, next) }) -const markRead = Effect.fn("WriteToolTest.markRead")(function* (sessionID: string, filepath: string) { - const ft = yield* FileTime.Service - yield* ft.read(sessionID as any, filepath) -}) - describe("tool.write", () => { describe("new file creation", () => { it.live("writes content to new file", () => @@ -110,8 +103,6 @@ describe("tool.write", () => { Effect.gen(function* () { const filepath = path.join(dir, "existing.txt") yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8")) - yield* markRead(ctx.sessionID, filepath) - const result = yield* run({ filePath: filepath, content: "new content" }) expect(result.output).toContain("Wrote file successfully") @@ -128,8 +119,6 @@ describe("tool.write", () => { Effect.gen(function* () { const filepath = path.join(dir, "file.txt") yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8")) - yield* markRead(ctx.sessionID, filepath) - const result = yield* run({ filePath: filepath, content: "new" }) expect(result.metadata).toHaveProperty("filepath", filepath) @@ -231,8 +220,6 @@ describe("tool.write", () => { const readonlyPath = path.join(dir, "readonly.txt") yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8")) yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444)) - yield* markRead(ctx.sessionID, readonlyPath) - const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit) expect(exit._tag).toBe("Failure") }), diff --git a/packages/opencode/test/util/log.test.ts b/packages/opencode/test/util/log.test.ts index 336b16a17b..c5135a0d20 100644 --- a/packages/opencode/test/util/log.test.ts +++ b/packages/opencode/test/util/log.test.ts @@ -3,6 +3,7 @@ import fs from "fs/promises" import path from "path" import { Global } from "../../src/global" import { Log } from "../../src/util" +import * as Process from "../../src/util/process" // kilocode_change import { tmpdir } from "../fixture/fixture" const log = Global.Path.log @@ -42,3 +43,47 @@ test("init cleanup keeps the newest timestamped logs", async () => { expect(next).not.toContain(list[0]!) expect(next).toContain(list.at(-1)!) }) + +// kilocode_change start +const root = path.join(import.meta.dir, "../..") +const worker = path.join(import.meta.dir, "../fixture/log-init-worker.ts") + +test("uses single log directory for rotation history", async () => { + await using tmp = await tmpdir() + + const out = await Process.run([process.execPath, "--conditions=browser", worker, tmp.path], { + cwd: root, + nothrow: true, + }) + + const dir = path.join(tmp.path, "share", "kilo", "log") + const history = path.join(dir, ".log-history") + + expect(out.code).toBe(0) + expect(out.stderr.toString()).not.toContain("log stream error:") + expect(out.stdout.toString()).toBe(path.join(dir, "dev.log")) + + const stat = await fs.stat(history) + expect(stat.isFile()).toBe(true) +}) + +test("skips rotation rename when active log file is missing", async () => { + await using tmp = await tmpdir() + + const out = await Process.run([process.execPath, "--conditions=browser", worker, tmp.path, "missing"], { + cwd: root, + nothrow: true, + }) + + const dir = path.join(tmp.path, "share", "kilo", "log") + const list = (await fs.readdir(dir)).sort() + const next = list.filter((file) => /^\d{8}-\d{4}-\d{2}-dev\.log$/.test(file)) + const stat = await fs.stat(path.join(dir, next[0]!)) + + expect(out.code).toBe(0) + expect(out.stderr.toString()).not.toContain("log stream error:") + expect(list).toContain("dev.log") + expect(next).toHaveLength(1) + expect(stat.size).toBe(0) +}) +// kilocode_change end diff --git a/packages/opencode/test/workspace/workspace-restore.test.ts b/packages/opencode/test/workspace/workspace-restore.test.ts index e8da317f40..db43dded9d 100644 --- a/packages/opencode/test/workspace/workspace-restore.test.ts +++ b/packages/opencode/test/workspace/workspace-restore.test.ts @@ -25,14 +25,12 @@ const original = Flag.KILO_EXPERIMENTAL_WORKSPACES beforeEach(() => { Database.close() - // @ts-expect-error test override Flag.KILO_EXPERIMENTAL_WORKSPACES = true }) afterEach(async () => { mock.restore() await Instance.disposeAll() - // @ts-expect-error test override Flag.KILO_EXPERIMENTAL_WORKSPACES = original await resetDatabase() }) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index d22e349657..6ada5323f7 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -22,8 +22,8 @@ "zod": "catalog:" }, "peerDependencies": { - "@opentui/core": ">=0.1.99", - "@opentui/solid": ">=0.1.99" + "@opentui/core": ">=0.1.100", + "@opentui/solid": ">=0.1.100" }, "peerDependenciesMeta": { "@opentui/core": { @@ -34,8 +34,8 @@ } }, "devDependencies": { - "@opentui/core": "0.1.99", - "@opentui/solid": "0.1.99", + "@opentui/core": "catalog:", + "@opentui/solid": "catalog:", "@tsconfig/node22": "catalog:", "@types/node": "catalog:", "typescript": "catalog:", diff --git a/packages/plugin/src/tool.ts b/packages/plugin/src/tool.ts index b568d03713..3105bf534b 100644 --- a/packages/plugin/src/tool.ts +++ b/packages/plugin/src/tool.ts @@ -27,10 +27,12 @@ type AskInput = { metadata: { [key: string]: any } } +export type ToolResult = string | { output: string; metadata?: { [key: string]: any } } + export function tool(input: { description: string args: Args - execute(args: z.infer>, context: ToolContext): Promise + execute(args: z.infer>, context: ToolContext): Promise }) { return input } diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 544daf2847..aea2929e7b 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -29,7 +29,7 @@ export type TuiRouteCurrent = name: "session" params: { sessionID: string - initialPrompt?: unknown + prompt?: unknown } } | { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 2a6ac95686..469eca379e 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -572,6 +572,434 @@ export class App extends HeyApiClient { } } +export class Adaptor extends HeyApiClient { + /** + * List workspace adaptors + * + * List all available workspace adaptors for the current project. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/workspace/adaptor", + ...options, + ...params, + }) + } +} + +export class Workspace extends HeyApiClient { + /** + * List workspaces + * + * List all workspaces. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/workspace", + ...options, + ...params, + }) + } + + /** + * Create workspace + * + * Create a workspace for the current project. + */ + public create( + parameters?: { + directory?: string + workspace?: string + id?: string + type?: string + branch?: string | null + extra?: unknown | null + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "id" }, + { in: "body", key: "type" }, + { in: "body", key: "branch" }, + { in: "body", key: "extra" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ExperimentalWorkspaceCreateResponses, + ExperimentalWorkspaceCreateErrors, + ThrowOnError + >({ + url: "/experimental/workspace", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Workspace status + * + * Get connection status for workspaces in the current project. + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/workspace/status", + ...options, + ...params, + }) + } + + /** + * Remove workspace + * + * Remove an existing workspace. + */ + public remove( + parameters: { + id: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "id" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete< + ExperimentalWorkspaceRemoveResponses, + ExperimentalWorkspaceRemoveErrors, + ThrowOnError + >({ + url: "/experimental/workspace/{id}", + ...options, + ...params, + }) + } + + /** + * Restore session into workspace + * + * Replay a session's sync events into the target workspace in batches. + */ + public sessionRestore( + parameters: { + id: string + directory?: string + workspace?: string + sessionID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "id" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "sessionID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ExperimentalWorkspaceSessionRestoreResponses, + ExperimentalWorkspaceSessionRestoreErrors, + ThrowOnError + >({ + url: "/experimental/workspace/{id}/session-restore", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + private _adaptor?: Adaptor + get adaptor(): Adaptor { + return (this._adaptor ??= new Adaptor({ client: this.client })) + } +} + +export class Console extends HeyApiClient { + /** + * Get active Console provider metadata + * + * Get the active Console org name and the set of provider IDs managed by that Console org. + */ + public get( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/console", + ...options, + ...params, + }) + } + + /** + * List switchable Console orgs + * + * Get the available Console orgs across logged-in accounts, including the current active org. + */ + public listOrgs( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/console/orgs", + ...options, + ...params, + }) + } + + /** + * Switch active Console org + * + * Persist a new active Console account/org selection for the current local Kilo state. + */ + public switchOrg( + parameters?: { + directory?: string + workspace?: string + accountID?: string + orgID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "accountID" }, + { in: "body", key: "orgID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/experimental/console/switch", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Session extends HeyApiClient { + /** + * List sessions + * + * Get a list of all Kilo sessions across projects, sorted by most recently updated. Archived sessions are excluded by default. + */ + public list( + parameters?: { + directory?: string + workspace?: string + projectID?: string + worktrees?: boolean + roots?: boolean + start?: number + cursor?: number + search?: string + limit?: number + archived?: boolean + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "projectID" }, + { in: "query", key: "worktrees" }, + { in: "query", key: "roots" }, + { in: "query", key: "start" }, + { in: "query", key: "cursor" }, + { in: "query", key: "search" }, + { in: "query", key: "limit" }, + { in: "query", key: "archived" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/session", + ...options, + ...params, + }) + } +} + +export class Resource extends HeyApiClient { + /** + * Get MCP resources + * + * Get all available MCP resources from connected servers. Optionally filter by name. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/resource", + ...options, + ...params, + }) + } +} + +export class Experimental extends HeyApiClient { + private _workspace?: Workspace + get workspace(): Workspace { + return (this._workspace ??= new Workspace({ client: this.client })) + } + + private _console?: Console + get console(): Console { + return (this._console ??= new Console({ client: this.client })) + } + + private _session?: Session + get session(): Session { + return (this._session ??= new Session({ client: this.client })) + } + + private _resource?: Resource + get resource(): Resource { + return (this._resource ??= new Resource({ client: this.client })) + } +} + export class Project extends HeyApiClient { /** * List all projects @@ -1064,434 +1492,6 @@ export class Config2 extends HeyApiClient { } } -export class Console extends HeyApiClient { - /** - * Get active Console provider metadata - * - * Get the active Console org name and the set of provider IDs managed by that Console org. - */ - public get( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/experimental/console", - ...options, - ...params, - }) - } - - /** - * List switchable Console orgs - * - * Get the available Console orgs across logged-in accounts, including the current active org. - */ - public listOrgs( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/experimental/console/orgs", - ...options, - ...params, - }) - } - - /** - * Switch active Console org - * - * Persist a new active Console account/org selection for the current local Kilo state. - */ - public switchOrg( - parameters?: { - directory?: string - workspace?: string - accountID?: string - orgID?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "accountID" }, - { in: "body", key: "orgID" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/experimental/console/switch", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } -} - -export class Adaptor extends HeyApiClient { - /** - * List workspace adaptors - * - * List all available workspace adaptors for the current project. - */ - public list( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/experimental/workspace/adaptor", - ...options, - ...params, - }) - } -} - -export class Workspace extends HeyApiClient { - /** - * List workspaces - * - * List all workspaces. - */ - public list( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/experimental/workspace", - ...options, - ...params, - }) - } - - /** - * Create workspace - * - * Create a workspace for the current project. - */ - public create( - parameters?: { - directory?: string - workspace?: string - id?: string - type?: string - branch?: string | null - extra?: unknown | null - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "id" }, - { in: "body", key: "type" }, - { in: "body", key: "branch" }, - { in: "body", key: "extra" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post< - ExperimentalWorkspaceCreateResponses, - ExperimentalWorkspaceCreateErrors, - ThrowOnError - >({ - url: "/experimental/workspace", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Workspace status - * - * Get connection status for workspaces in the current project. - */ - public status( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/experimental/workspace/status", - ...options, - ...params, - }) - } - - /** - * Remove workspace - * - * Remove an existing workspace. - */ - public remove( - parameters: { - id: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "id" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).delete< - ExperimentalWorkspaceRemoveResponses, - ExperimentalWorkspaceRemoveErrors, - ThrowOnError - >({ - url: "/experimental/workspace/{id}", - ...options, - ...params, - }) - } - - /** - * Restore session into workspace - * - * Replay a session's sync events into the target workspace in batches. - */ - public sessionRestore( - parameters: { - id: string - directory?: string - workspace?: string - sessionID?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "id" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "sessionID" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post< - ExperimentalWorkspaceSessionRestoreResponses, - ExperimentalWorkspaceSessionRestoreErrors, - ThrowOnError - >({ - url: "/experimental/workspace/{id}/session-restore", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - private _adaptor?: Adaptor - get adaptor(): Adaptor { - return (this._adaptor ??= new Adaptor({ client: this.client })) - } -} - -export class Session extends HeyApiClient { - /** - * List sessions - * - * Get a list of all Kilo sessions across projects, sorted by most recently updated. Archived sessions are excluded by default. - */ - public list( - parameters?: { - directory?: string - workspace?: string - projectID?: string - worktrees?: boolean - roots?: boolean - start?: number - cursor?: number - search?: string - limit?: number - archived?: boolean - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "projectID" }, - { in: "query", key: "worktrees" }, - { in: "query", key: "roots" }, - { in: "query", key: "start" }, - { in: "query", key: "cursor" }, - { in: "query", key: "search" }, - { in: "query", key: "limit" }, - { in: "query", key: "archived" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/experimental/session", - ...options, - ...params, - }) - } -} - -export class Resource extends HeyApiClient { - /** - * Get MCP resources - * - * Get all available MCP resources from connected servers. Optionally filter by name. - */ - public list( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/experimental/resource", - ...options, - ...params, - }) - } -} - -export class Experimental extends HeyApiClient { - private _console?: Console - get console(): Console { - return (this._console ??= new Console({ client: this.client })) - } - - private _workspace?: Workspace - get workspace(): Workspace { - return (this._workspace ??= new Workspace({ client: this.client })) - } - - private _session?: Session - get session(): Session { - return (this._session ??= new Session({ client: this.client })) - } - - private _resource?: Resource - get resource(): Resource { - return (this._resource ??= new Resource({ client: this.client })) - } -} - export class Tool extends HeyApiClient { /** * List tool IDs @@ -5960,6 +5960,11 @@ export class KiloClient extends HeyApiClient { return (this._app ??= new App({ client: this.client })) } + private _experimental?: Experimental + get experimental(): Experimental { + return (this._experimental ??= new Experimental({ client: this.client })) + } + private _project?: Project get project(): Project { return (this._project ??= new Project({ client: this.client })) @@ -5975,11 +5980,6 @@ export class KiloClient extends HeyApiClient { return (this._config ??= new Config2({ client: this.client })) } - private _experimental?: Experimental - get experimental(): Experimental { - return (this._experimental ??= new Experimental({ client: this.client })) - } - private _tool?: Tool get tool(): Tool { return (this._tool ??= new Tool({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index d0767d7d71..fbecf838a9 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -688,7 +688,6 @@ export type EventWorkspaceStatus = { properties: { workspaceID: string status: "connected" | "connecting" | "disconnected" | "error" - error?: string } } @@ -1497,7 +1496,7 @@ export type ProviderConfig = { */ setCacheKey?: boolean /** - * Timeout in milliseconds for requests to this provider. Default is 120000 (2 minutes). Set to false to disable timeout. + * Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout. */ timeout?: number | false /** @@ -1778,7 +1777,7 @@ export type Config = { } } formatter?: - | false + | boolean | { [key: string]: { disabled?: boolean @@ -1790,7 +1789,7 @@ export type Config = { } } lsp?: - | false + | boolean | { [key: string]: | { @@ -1908,6 +1907,16 @@ export type WellKnownAuth = { export type Auth = OAuth | ApiAuth | WellKnownAuth +export type Workspace = { + id: string + type: string + name: string + branch: string | null + directory: string | null + extra: unknown | null + projectID: string +} + export type NotFoundError = { name: "NotFoundError" data: { @@ -2014,16 +2023,6 @@ export type ToolListItem = { export type ToolList = Array -export type Workspace = { - id: string - type: string - name: string - branch: string | null - directory: string | null - extra: unknown | null - projectID: string -} - export type Worktree = { name: string branch: string @@ -2633,6 +2632,176 @@ export type AppLogResponses = { export type AppLogResponse = AppLogResponses[keyof AppLogResponses] +export type ExperimentalWorkspaceAdaptorListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/adaptor" +} + +export type ExperimentalWorkspaceAdaptorListResponses = { + /** + * Workspace adaptors + */ + 200: Array<{ + type: string + name: string + description: string + }> +} + +export type ExperimentalWorkspaceAdaptorListResponse = + ExperimentalWorkspaceAdaptorListResponses[keyof ExperimentalWorkspaceAdaptorListResponses] + +export type ExperimentalWorkspaceListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace" +} + +export type ExperimentalWorkspaceListResponses = { + /** + * Workspaces + */ + 200: Array +} + +export type ExperimentalWorkspaceListResponse = + ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses] + +export type ExperimentalWorkspaceCreateData = { + body?: { + id?: string + type: string + branch: string | null + extra: unknown | null + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace" +} + +export type ExperimentalWorkspaceCreateErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceCreateError = + ExperimentalWorkspaceCreateErrors[keyof ExperimentalWorkspaceCreateErrors] + +export type ExperimentalWorkspaceCreateResponses = { + /** + * Workspace created + */ + 200: Workspace +} + +export type ExperimentalWorkspaceCreateResponse = + ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses] + +export type ExperimentalWorkspaceStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/status" +} + +export type ExperimentalWorkspaceStatusResponses = { + /** + * Workspace status + */ + 200: Array<{ + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + }> +} + +export type ExperimentalWorkspaceStatusResponse = + ExperimentalWorkspaceStatusResponses[keyof ExperimentalWorkspaceStatusResponses] + +export type ExperimentalWorkspaceRemoveData = { + body?: never + path: { + id: string + } + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/{id}" +} + +export type ExperimentalWorkspaceRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceRemoveError = + ExperimentalWorkspaceRemoveErrors[keyof ExperimentalWorkspaceRemoveErrors] + +export type ExperimentalWorkspaceRemoveResponses = { + /** + * Workspace removed + */ + 200: Workspace +} + +export type ExperimentalWorkspaceRemoveResponse = + ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses] + +export type ExperimentalWorkspaceSessionRestoreData = { + body?: { + sessionID: string + } + path: { + id: string + } + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/{id}/session-restore" +} + +export type ExperimentalWorkspaceSessionRestoreErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceSessionRestoreError = + ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors] + +export type ExperimentalWorkspaceSessionRestoreResponses = { + /** + * Session replay started + */ + 200: { + total: number + } +} + +export type ExperimentalWorkspaceSessionRestoreResponse = + ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses] + export type ProjectListData = { body?: never path?: never @@ -3145,177 +3314,6 @@ export type ToolListResponses = { export type ToolListResponse = ToolListResponses[keyof ToolListResponses] -export type ExperimentalWorkspaceAdaptorListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace/adaptor" -} - -export type ExperimentalWorkspaceAdaptorListResponses = { - /** - * Workspace adaptors - */ - 200: Array<{ - type: string - name: string - description: string - }> -} - -export type ExperimentalWorkspaceAdaptorListResponse = - ExperimentalWorkspaceAdaptorListResponses[keyof ExperimentalWorkspaceAdaptorListResponses] - -export type ExperimentalWorkspaceListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace" -} - -export type ExperimentalWorkspaceListResponses = { - /** - * Workspaces - */ - 200: Array -} - -export type ExperimentalWorkspaceListResponse = - ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses] - -export type ExperimentalWorkspaceCreateData = { - body?: { - id?: string - type: string - branch: string | null - extra: unknown | null - } - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace" -} - -export type ExperimentalWorkspaceCreateErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalWorkspaceCreateError = - ExperimentalWorkspaceCreateErrors[keyof ExperimentalWorkspaceCreateErrors] - -export type ExperimentalWorkspaceCreateResponses = { - /** - * Workspace created - */ - 200: Workspace -} - -export type ExperimentalWorkspaceCreateResponse = - ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses] - -export type ExperimentalWorkspaceStatusData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace/status" -} - -export type ExperimentalWorkspaceStatusResponses = { - /** - * Workspace status - */ - 200: Array<{ - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" - error?: string - }> -} - -export type ExperimentalWorkspaceStatusResponse = - ExperimentalWorkspaceStatusResponses[keyof ExperimentalWorkspaceStatusResponses] - -export type ExperimentalWorkspaceRemoveData = { - body?: never - path: { - id: string - } - query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace/{id}" -} - -export type ExperimentalWorkspaceRemoveErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalWorkspaceRemoveError = - ExperimentalWorkspaceRemoveErrors[keyof ExperimentalWorkspaceRemoveErrors] - -export type ExperimentalWorkspaceRemoveResponses = { - /** - * Workspace removed - */ - 200: Workspace -} - -export type ExperimentalWorkspaceRemoveResponse = - ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses] - -export type ExperimentalWorkspaceSessionRestoreData = { - body?: { - sessionID: string - } - path: { - id: string - } - query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace/{id}/session-restore" -} - -export type ExperimentalWorkspaceSessionRestoreErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalWorkspaceSessionRestoreError = - ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors] - -export type ExperimentalWorkspaceSessionRestoreResponses = { - /** - * Session replay started - */ - 200: { - total: number - } -} - -export type ExperimentalWorkspaceSessionRestoreResponse = - ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses] - export type WorktreeRemoveData = { body?: WorktreeRemoveInput path?: never diff --git a/packages/shared/package.json b/packages/shared/package.json index ae66d818b2..1c31b908bd 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.14", + "version": "7.2.20", "name": "@opencode-ai/shared", "type": "module", "license": "MIT", @@ -35,5 +35,6 @@ }, "overrides": { "drizzle-orm": "catalog:" - } + }, + "peerDependencies": {} } diff --git a/packages/shared/sst-env.d.ts b/packages/shared/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/shared/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/ui/src/components/provider-icons/types.ts b/packages/ui/src/components/provider-icons/types.ts index 32fff1f13a..7e3b256f71 100644 --- a/packages/ui/src/components/provider-icons/types.ts +++ b/packages/ui/src/components/provider-icons/types.ts @@ -34,6 +34,7 @@ export const iconNames = [ "perplexity-agent", "ovhcloud", "openrouter", + "llmgateway", "opencode", "opencode-go", "openai",