Compare commits

...

29 Commits

Author SHA1 Message Date
Kit Langton 68409c9471 revert unrelated bus type tightening from command PR 2026-03-21 20:11:01 -04:00
Kit Langton d1f7df0b97 refactor(command): initialize state on first access 2026-03-21 20:09:44 -04:00
Kit Langton b1e6ea3e88 refactor(command): track lazy init with fiber 2026-03-21 20:01:17 -04:00
Kit Langton addc33212b docs(command): restore zod async comments 2026-03-21 19:58:38 -04:00
Kit Langton 7053c58d35 fix(command): resolve rebase artifacts 2026-03-21 19:54:09 -04:00
Kit Langton a277fb4049 log errors in catchCause instead of silently swallowing 2026-03-21 14:26:01 -04:00
Kit Langton 59cc1ebc27 use forkScoped + Fiber.join for lazy init (match old Instance.state behavior) 2026-03-21 14:25:57 -04:00
Kit Langton df260fee45 handle undefined command in session prompt 2026-03-21 14:25:50 -04:00
Kit Langton 3b2a1e8415 effectify Command service: migrate from Instance.state to Effect service pattern 2026-03-21 14:25:50 -04:00
Kit Langton 6f9c7812d3 fix(bus): tighten GlobalBus payload and BusEvent.define types
Constrain BusEvent.define to ZodObject instead of ZodType so TS knows
event properties are always a record. Type GlobalBus payload as
{ type: string; properties: Record<string, unknown> } instead of any.

Refactor watcher test to use Bus.subscribe instead of raw GlobalBus
listener, removing hand-rolled event types and unnecessary casts.
2026-03-21 14:24:51 -04:00
Protocol Zero cc818f8032 fix(provider): only set thinkingConfig for models with reasoning capability (#18283) 2026-03-21 11:57:52 -05:00
opencode-agent[bot] d5337b41f4 chore: update nix node_modules hashes 2026-03-21 15:47:15 +00:00
opencode-agent[bot] 9f7a76d6c0 chore: generate 2026-03-21 15:34:05 +00:00
Brendan Allan 6a16db4b92 app: manage mutation loading states with tanstack query (#18501) 2026-03-21 23:33:04 +08:00
Brendan Allan 9ad6588f3e app: allow navigating projects with keybinds (#18502) 2026-03-21 22:13:09 +08:00
opencode-agent[bot] fb6bf0b35e chore: generate 2026-03-21 13:12:54 +00:00
Dax Raad f80343b875 fix annotation 2026-03-21 09:11:15 -04:00
Frank 9b805e1cc4 wip: zen 2026-03-21 04:07:51 -04:00
opencode-agent[bot] 2e0d5d2308 chore: generate 2026-03-21 04:52:23 +00:00
Kit Langton 38e0dc9ccd Move service state into InstanceState, flatten service facades (#18483) 2026-03-21 04:51:35 +00:00
opencode-agent[bot] 40aeaa120d chore: generate 2026-03-21 03:11:28 +00:00
Kit Langton 6a64177589 fix(zen): emit cost chunk in client-facing format, not upstream format (#16817) 2026-03-20 23:10:34 -04:00
Dax Raad 5dc47905a9 allow customizing DB location 2026-03-20 22:49:55 -04:00
Aiden Cline dc0044882c ignore: add danieljoshuanazareth to disavow list (#18476) 2026-03-20 21:34:07 -04:00
github-actions[bot] 45ae7dc653 Update VOUCHED list
https://github.com/anomalyco/opencode/issues/18464#issuecomment-4101766628
2026-03-21 01:28:40 +00:00
Dax Raad 129fe1e350 ci 2026-03-20 17:00:05 -04:00
Kit Langton 214a6c6cf1 fix: switch consumers to service imports to break bundle cycles (#18438) 2026-03-20 20:55:46 +00:00
Dax Raad 3f249aba6d commit and push 2026-03-20 16:36:19 -04:00
Dax Raad 5c6ec1caac fix question cross out 2026-03-20 15:50:04 -04:00
114 changed files with 5766 additions and 4759 deletions
+3 -1
View File
@@ -10,6 +10,7 @@
adamdotdevin adamdotdevin
-agusbasari29 AI PR slop -agusbasari29 AI PR slop
ariane-emory ariane-emory
-danieljoshuanazareth
edemaine edemaine
-florianleibert -florianleibert
fwang fwang
@@ -17,8 +18,9 @@ iamdavidhill
jayair jayair
kitlangton kitlangton
kommander kommander
-opencode2026
r44vc0rp r44vc0rp
rekram1-node rekram1-node
-spider-yamet clawdbot/llm psychosis, spam pinging the team -spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr thdxr
-OpenCode2026 -danieljoshuanazareth
+5
View File
@@ -44,6 +44,7 @@
"@solid-primitives/websocket": "1.3.1", "@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4",
"@thisbeyond/solid-dnd": "0.7.5", "@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -1966,10 +1967,14 @@
"@tanstack/directive-functions-plugin": ["@tanstack/directive-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/router-utils": "1.133.19", "babel-dead-code-elimination": "^1.0.10", "pathe": "^2.0.3", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "vite": ">=6.0.0 || >=7.0.0" } }, "sha512-J3oawV8uBRBbPoLgMdyHt+LxzTNuWRKNJJuCLWsm/yq6v0IQSvIVCgfD2+liIiSnDPxGZ8ExduPXy8IzS70eXw=="], "@tanstack/directive-functions-plugin": ["@tanstack/directive-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/router-utils": "1.133.19", "babel-dead-code-elimination": "^1.0.10", "pathe": "^2.0.3", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "vite": ">=6.0.0 || >=7.0.0" } }, "sha512-J3oawV8uBRBbPoLgMdyHt+LxzTNuWRKNJJuCLWsm/yq6v0IQSvIVCgfD2+liIiSnDPxGZ8ExduPXy8IzS70eXw=="],
"@tanstack/query-core": ["@tanstack/query-core@5.91.2", "", {}, "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw=="],
"@tanstack/router-utils": ["@tanstack/router-utils@1.133.19", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.5", "@babel/preset-typescript": "^7.27.1", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-WEp5D2gPxvlLDRXwD/fV7RXjYtqaqJNXKB/L6OyZEbT+9BG/Ib2d7oG9GSUZNNMGPGYAlhBUOi3xutySsk6rxA=="], "@tanstack/router-utils": ["@tanstack/router-utils@1.133.19", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.5", "@babel/preset-typescript": "^7.27.1", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-WEp5D2gPxvlLDRXwD/fV7RXjYtqaqJNXKB/L6OyZEbT+9BG/Ib2d7oG9GSUZNNMGPGYAlhBUOi3xutySsk6rxA=="],
"@tanstack/server-functions-plugin": ["@tanstack/server-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/directive-functions-plugin": "1.134.5", "babel-dead-code-elimination": "^1.0.9", "tiny-invariant": "^1.3.3" } }, "sha512-2sWxq70T+dOEUlE3sHlXjEPhaFZfdPYlWTSkHchWXrFGw2YOAa+hzD6L9wHMjGDQezYd03ue8tQlHG+9Jzbzgw=="], "@tanstack/server-functions-plugin": ["@tanstack/server-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/directive-functions-plugin": "1.134.5", "babel-dead-code-elimination": "^1.0.9", "tiny-invariant": "^1.3.3" } }, "sha512-2sWxq70T+dOEUlE3sHlXjEPhaFZfdPYlWTSkHchWXrFGw2YOAa+hzD6L9wHMjGDQezYd03ue8tQlHG+9Jzbzgw=="],
"@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
"@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="], "@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
"@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="], "@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-P0RJfQF8APTYVGP6hLJRrOkRSl5nVDNxdcGcZECPPJE=", "x86_64-linux": "sha256-WXQ4b1hHFk2vDWz41fJmj+z0twee6r0YR0JGH0iw0ZI=",
"aarch64-linux": "sha256-ZtMjTcd35X3JhJIdn3DilFsp7i/IZIcNaKZFnSzW/nk=", "aarch64-linux": "sha256-YIdnfkHGLfUq3cZkycvL7DQ8BvC5X+VDia7UTLgJBx8=",
"aarch64-darwin": "sha256-Uw/okFDRxxKQMfEsj8MXuHyhpugxZGgIKtu89Getlz8=", "aarch64-darwin": "sha256-bMUeI1LcBYgKBwG92WazTgxNryZF2Gv9iQgK46Pd+3A=",
"x86_64-darwin": "sha256-ZySIgT1HbWZWnaQ0W0eURKC43BTupRmmply92JDFPWA=" "x86_64-darwin": "sha256-fJbEd1j8ObZ2OMykYVU6v0uI1gy2eoCFIZ9ovuiNeLY="
} }
} }
+1
View File
@@ -54,6 +54,7 @@
"@solid-primitives/websocket": "1.3.1", "@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4",
"@thisbeyond/solid-dnd": "0.7.5", "@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
+13 -5
View File
@@ -9,6 +9,7 @@ import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme" import { ThemeProvider } from "@opencode-ai/ui/theme"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { type Duration, Effect } from "effect" import { type Duration, Effect } from "effect"
import { import {
type Component, type Component,
@@ -81,6 +82,11 @@ function MarkedProviderWithNativeParser(props: ParentProps) {
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider> return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
} }
function QueryProvider(props: ParentProps) {
const client = new QueryClient()
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
}
function AppShellProviders(props: ParentProps) { function AppShellProviders(props: ParentProps) {
return ( return (
<SettingsProvider> <SettingsProvider>
@@ -136,11 +142,13 @@ export function AppBaseProviders(props: ParentProps) {
<LanguageProvider> <LanguageProvider>
<UiI18nBridge> <UiI18nBridge>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}> <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<DialogProvider> <QueryProvider>
<MarkedProviderWithNativeParser> <DialogProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider> <MarkedProviderWithNativeParser>
</MarkedProviderWithNativeParser> <FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</DialogProvider> </MarkedProviderWithNativeParser>
</DialogProvider>
</QueryProvider>
</ErrorBoundary> </ErrorBoundary>
</UiI18nBridge> </UiI18nBridge>
</LanguageProvider> </LanguageProvider>
@@ -12,10 +12,9 @@ import { showToast } from "@opencode-ai/ui/toast"
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js" import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link" import { Link } from "@/components/link"
import { useLanguage } from "@/context/language"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { DialogSelectModel } from "./dialog-select-model" import { useLanguage } from "@/context/language"
import { DialogSelectProvider } from "./dialog-select-provider" import { DialogSelectProvider } from "./dialog-select-provider"
export function DialogConnectProvider(props: { provider: string }) { export function DialogConnectProvider(props: { provider: string }) {
@@ -34,7 +34,6 @@ export type FormState = {
apiKey: string apiKey: string
models: ModelRow[] models: ModelRow[]
headers: HeaderRow[] headers: HeaderRow[]
saving: boolean
err: { err: {
providerID?: string providerID?: string
name?: string name?: string
@@ -16,7 +16,6 @@ describe("validateCustomProvider", () => {
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} }, { row: "h0", key: " X-Test ", value: " enabled ", err: {} },
{ row: "h1", key: "", value: "", err: {} }, { row: "h1", key: "", value: "", err: {} },
], ],
saving: false,
err: {}, err: {},
}, },
t, t,
@@ -60,7 +59,6 @@ describe("validateCustomProvider", () => {
{ row: "h0", key: "Authorization", value: "one", err: {} }, { row: "h0", key: "Authorization", value: "one", err: {} },
{ row: "h1", key: "authorization", value: "two", err: {} }, { row: "h1", key: "authorization", value: "two", err: {} },
], ],
saving: false,
err: {}, err: {},
}, },
t, t,
@@ -3,6 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useMutation } from "@tanstack/solid-query"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { batch, For } from "solid-js" import { batch, For } from "solid-js"
@@ -31,7 +32,6 @@ export function DialogCustomProvider(props: Props) {
apiKey: "", apiKey: "",
models: [modelRow()], models: [modelRow()],
headers: [headerRow()], headers: [headerRow()],
saving: false,
err: {}, err: {},
}) })
@@ -116,48 +116,49 @@ export function DialogCustomProvider(props: Props) {
return output.result return output.result
} }
const save = async (e: SubmitEvent) => { const saveMutation = useMutation(() => ({
e.preventDefault() mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
if (form.saving) return const disabledProviders = globalSync.data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
const result = validate() if (result.key) {
if (!result) return await globalSDK.client.auth.set({
setForm("saving", true)
const disabledProviders = globalSync.data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
const auth = result.key
? globalSDK.client.auth.set({
providerID: result.providerID, providerID: result.providerID,
auth: { auth: {
type: "api", type: "api",
key: result.key, key: result.key,
}, },
}) })
: Promise.resolve() }
auth await globalSync.updateConfig({
.then(() => provider: { [result.providerID]: result.config },
globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }), disabled_providers: nextDisabled,
)
.then(() => {
dialog.close()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
})
}) })
.catch((err: unknown) => { return result
const message = err instanceof Error ? err.message : String(err) },
showToast({ title: language.t("common.requestFailed"), description: message }) onSuccess: (result) => {
}) dialog.close()
.finally(() => { showToast({
setForm("saving", false) variant: "success",
icon: "circle-check",
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
}) })
},
onError: (err) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
},
}))
const save = (e: SubmitEvent) => {
e.preventDefault()
if (saveMutation.isPending) return
const result = validate()
if (!result) return
saveMutation.mutate(result)
} }
return ( return (
@@ -312,8 +313,14 @@ export function DialogCustomProvider(props: Props) {
</Button> </Button>
</div> </div>
<Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}> <Button
{form.saving ? language.t("common.saving") : language.t("common.submit")} class="w-auto self-start"
type="submit"
size="large"
variant="primary"
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
</Button> </Button>
</form> </form>
</div> </div>
@@ -2,6 +2,7 @@ import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { createMemo, For, Show } from "solid-js" import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
@@ -28,7 +29,6 @@ export function DialogEditProject(props: { project: LocalProject }) {
color: props.project.icon?.color || "pink", color: props.project.icon?.color || "pink",
iconUrl: props.project.icon?.override || "", iconUrl: props.project.icon?.override || "",
startup: props.project.commands?.start ?? "", startup: props.project.commands?.start ?? "",
saving: false,
dragOver: false, dragOver: false,
iconHover: false, iconHover: false,
}) })
@@ -71,38 +71,37 @@ export function DialogEditProject(props: { project: LocalProject }) {
setStore("iconUrl", "") setStore("iconUrl", "")
} }
async function handleSubmit(e: SubmitEvent) { const saveMutation = useMutation(() => ({
e.preventDefault() mutationFn: async () => {
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
await Promise.resolve() if (props.project.id && props.project.id !== "global") {
.then(async () => { await globalSDK.client.project.update({
setStore("saving", true) projectID: props.project.id,
const name = store.name.trim() === folderName() ? "" : store.name.trim() directory: props.project.worktree,
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await globalSDK.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color, override: store.iconUrl },
commands: { start },
})
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
dialog.close()
return
}
globalSync.project.meta(props.project.worktree, {
name, name,
icon: { color: store.color, override: store.iconUrl || undefined }, icon: { color: store.color, override: store.iconUrl },
commands: { start: start || undefined }, commands: { start },
}) })
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
dialog.close() dialog.close()
return
}
globalSync.project.meta(props.project.worktree, {
name,
icon: { color: store.color, override: store.iconUrl || undefined },
commands: { start: start || undefined },
}) })
.finally(() => { dialog.close()
setStore("saving", false) },
}) }))
function handleSubmit(e: SubmitEvent) {
e.preventDefault()
if (saveMutation.isPending) return
saveMutation.mutate()
} }
return ( return (
@@ -246,8 +245,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}> <Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")} {language.t("common.cancel")}
</Button> </Button>
<Button type="submit" variant="primary" size="large" disabled={store.saving}> <Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
{store.saving ? language.t("common.saving") : language.t("common.save")} {saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
</Button> </Button>
</div> </div>
</form> </form>
@@ -1,4 +1,5 @@
import { Component, createMemo, createSignal, Show } from "solid-js" import { useMutation } from "@tanstack/solid-query"
import { Component, createMemo, Show } from "solid-js"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
@@ -17,7 +18,6 @@ export const DialogSelectMcp: Component = () => {
const sync = useSync() const sync = useSync()
const sdk = useSDK() const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
const [loading, setLoading] = createSignal<string | null>(null)
const items = createMemo(() => const items = createMemo(() =>
Object.entries(sync.data.mcp ?? {}) Object.entries(sync.data.mcp ?? {})
@@ -25,10 +25,8 @@ export const DialogSelectMcp: Component = () => {
.sort((a, b) => a.name.localeCompare(b.name)), .sort((a, b) => a.name.localeCompare(b.name)),
) )
const toggle = async (name: string) => { const toggle = useMutation(() => ({
if (loading()) return mutationFn: async (name: string) => {
setLoading(name)
try {
const status = sync.data.mcp[name] const status = sync.data.mcp[name]
if (status?.status === "connected") { if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name }) await sdk.client.mcp.disconnect({ name })
@@ -38,10 +36,8 @@ export const DialogSelectMcp: Component = () => {
const result = await sdk.client.mcp.status() const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data) if (result.data) sync.set("mcp", result.data)
} finally { },
setLoading(null) }))
}
}
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
const totalCount = createMemo(() => items().length) const totalCount = createMemo(() => items().length)
@@ -59,7 +55,8 @@ export const DialogSelectMcp: Component = () => {
filterKeys={["name", "status"]} filterKeys={["name", "status"]}
sortBy={(a, b) => a.name.localeCompare(b.name)} sortBy={(a, b) => a.name.localeCompare(b.name)}
onSelect={(x) => { onSelect={(x) => {
if (x) toggle(x.name) if (!x || toggle.isPending) return
toggle.mutate(x.name)
}} }}
> >
{(i) => { {(i) => {
@@ -83,7 +80,7 @@ export const DialogSelectMcp: Component = () => {
<Show when={statusLabel()}> <Show when={statusLabel()}>
<span class="text-11-regular text-text-weaker">{statusLabel()}</span> <span class="text-11-regular text-text-weaker">{statusLabel()}</span>
</Show> </Show>
<Show when={loading() === i.name}> <Show when={toggle.isPending && toggle.variables === i.name}>
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span> <span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
</Show> </Show>
</div> </div>
@@ -92,7 +89,14 @@ export const DialogSelectMcp: Component = () => {
</Show> </Show>
</div> </div>
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
<Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} /> <Switch
checked={enabled()}
disabled={toggle.isPending && toggle.variables === i.name}
onChange={() => {
if (toggle.isPending) return
toggle.mutate(i.name)
}}
/>
</div> </div>
</div> </div>
) )
@@ -6,6 +6,7 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
@@ -186,7 +187,6 @@ export function DialogSelectServer() {
name: "", name: "",
username: DEFAULT_USERNAME, username: DEFAULT_USERNAME,
password: "", password: "",
adding: false,
error: "", error: "",
showForm: false, showForm: false,
status: undefined as boolean | undefined, status: undefined as boolean | undefined,
@@ -198,7 +198,6 @@ export function DialogSelectServer() {
username: "", username: "",
password: "", password: "",
error: "", error: "",
busy: false,
status: undefined as boolean | undefined, status: undefined as boolean | undefined,
}, },
}) })
@@ -209,7 +208,6 @@ export function DialogSelectServer() {
name: "", name: "",
username: DEFAULT_USERNAME, username: DEFAULT_USERNAME,
password: "", password: "",
adding: false,
error: "", error: "",
showForm: false, showForm: false,
status: undefined, status: undefined,
@@ -224,10 +222,78 @@ export function DialogSelectServer() {
password: "", password: "",
error: "", error: "",
status: undefined, status: undefined,
busy: false,
}) })
} }
const addMutation = useMutation(() => ({
mutationFn: async (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetAdd()
return
}
const conn: ServerConnection.Http = {
type: "http",
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.password) conn.http.password = store.addServer.password
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
await select(conn, true)
},
}))
const editMutation = useMutation(() => ({
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
if (input.original.type !== "http") return
const normalized = normalizeServerUrl(input.value)
if (!normalized) {
resetEdit()
return
}
const name = store.editServer.name.trim() || undefined
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
const existingName = input.original.displayName
if (
normalized === input.original.http.url &&
name === existingName &&
username === input.original.http.username &&
password === input.original.http.password
) {
resetEdit()
return
}
const conn: ServerConnection.Http = {
type: "http",
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === input.original.http.url) {
server.add(conn)
} else {
replaceServer(input.original, conn)
}
resetEdit()
},
}))
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => { const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
const active = server.key const active = server.key
const newConn = server.add(next) const newConn = server.add(next)
@@ -296,7 +362,7 @@ export function DialogSelectServer() {
} }
const handleAddChange = (value: string) => { const handleAddChange = (value: string) => {
if (store.addServer.adding) return if (addMutation.isPending) return
setStore("addServer", { url: value, error: "" }) setStore("addServer", { url: value, error: "" })
void previewStatus(value, store.addServer.username, store.addServer.password, (next) => void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
setStore("addServer", { status: next }), setStore("addServer", { status: next }),
@@ -304,12 +370,12 @@ export function DialogSelectServer() {
} }
const handleAddNameChange = (value: string) => { const handleAddNameChange = (value: string) => {
if (store.addServer.adding) return if (addMutation.isPending) return
setStore("addServer", { name: value, error: "" }) setStore("addServer", { name: value, error: "" })
} }
const handleAddUsernameChange = (value: string) => { const handleAddUsernameChange = (value: string) => {
if (store.addServer.adding) return if (addMutation.isPending) return
setStore("addServer", { username: value, error: "" }) setStore("addServer", { username: value, error: "" })
void previewStatus(store.addServer.url, value, store.addServer.password, (next) => void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
setStore("addServer", { status: next }), setStore("addServer", { status: next }),
@@ -317,7 +383,7 @@ export function DialogSelectServer() {
} }
const handleAddPasswordChange = (value: string) => { const handleAddPasswordChange = (value: string) => {
if (store.addServer.adding) return if (addMutation.isPending) return
setStore("addServer", { password: value, error: "" }) setStore("addServer", { password: value, error: "" })
void previewStatus(store.addServer.url, store.addServer.username, value, (next) => void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
setStore("addServer", { status: next }), setStore("addServer", { status: next }),
@@ -325,7 +391,7 @@ export function DialogSelectServer() {
} }
const handleEditChange = (value: string) => { const handleEditChange = (value: string) => {
if (store.editServer.busy) return if (editMutation.isPending) return
setStore("editServer", { value, error: "" }) setStore("editServer", { value, error: "" })
void previewStatus(value, store.editServer.username, store.editServer.password, (next) => void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
setStore("editServer", { status: next }), setStore("editServer", { status: next }),
@@ -333,12 +399,12 @@ export function DialogSelectServer() {
} }
const handleEditNameChange = (value: string) => { const handleEditNameChange = (value: string) => {
if (store.editServer.busy) return if (editMutation.isPending) return
setStore("editServer", { name: value, error: "" }) setStore("editServer", { name: value, error: "" })
} }
const handleEditUsernameChange = (value: string) => { const handleEditUsernameChange = (value: string) => {
if (store.editServer.busy) return if (editMutation.isPending) return
setStore("editServer", { username: value, error: "" }) setStore("editServer", { username: value, error: "" })
void previewStatus(store.editServer.value, value, store.editServer.password, (next) => void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
setStore("editServer", { status: next }), setStore("editServer", { status: next }),
@@ -346,85 +412,13 @@ export function DialogSelectServer() {
} }
const handleEditPasswordChange = (value: string) => { const handleEditPasswordChange = (value: string) => {
if (store.editServer.busy) return if (editMutation.isPending) return
setStore("editServer", { password: value, error: "" }) setStore("editServer", { password: value, error: "" })
void previewStatus(store.editServer.value, store.editServer.username, value, (next) => void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
setStore("editServer", { status: next }), setStore("editServer", { status: next }),
) )
} }
async function handleAdd(value: string) {
if (store.addServer.adding) return
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetAdd()
return
}
setStore("addServer", { adding: true, error: "" })
const conn: ServerConnection.Http = {
type: "http",
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.password) conn.http.password = store.addServer.password
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
const result = await checkServerHealth(conn.http)
setStore("addServer", { adding: false })
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
await select(conn, true)
}
async function handleEdit(original: ServerConnection.Any, value: string) {
if (store.editServer.busy || original.type !== "http") return
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetEdit()
return
}
const name = store.editServer.name.trim() || undefined
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
const existingName = original.displayName
if (
normalized === original.http.url &&
name === existingName &&
username === original.http.username &&
password === original.http.password
) {
resetEdit()
return
}
setStore("editServer", { busy: true, error: "" })
const conn: ServerConnection.Http = {
type: "http",
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http)
setStore("editServer", { busy: false })
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === original.http.url) {
server.add(conn)
} else {
replaceServer(original, conn)
}
resetEdit()
}
const mode = createMemo<"list" | "add" | "edit">(() => { const mode = createMemo<"list" | "add" | "edit">(() => {
if (store.editServer.id) return "edit" if (store.editServer.id) return "edit"
if (store.addServer.showForm) return "add" if (store.addServer.showForm) return "add"
@@ -464,23 +458,26 @@ export function DialogSelectServer() {
password: conn.http.password ?? "", password: conn.http.password ?? "",
error: "", error: "",
status: store.status[ServerConnection.key(conn)]?.healthy, status: store.status[ServerConnection.key(conn)]?.healthy,
busy: false,
}) })
} }
const submitForm = () => { const submitForm = () => {
if (mode() === "add") { if (mode() === "add") {
void handleAdd(store.addServer.url) if (addMutation.isPending) return
setStore("addServer", { error: "" })
addMutation.mutate(store.addServer.url)
return return
} }
const original = editing() const original = editing()
if (!original) return if (!original) return
void handleEdit(original, store.editServer.value) if (editMutation.isPending) return
setStore("editServer", { error: "" })
editMutation.mutate({ original, value: store.editServer.value })
} }
const isFormMode = createMemo(() => mode() !== "list") const isFormMode = createMemo(() => mode() !== "list")
const isAddMode = createMemo(() => mode() === "add") const isAddMode = createMemo(() => mode() === "add")
const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy)) const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
const formTitle = createMemo(() => { const formTitle = createMemo(() => {
if (!isFormMode()) return language.t("dialog.server.title") if (!isFormMode()) return language.t("dialog.server.title")
+27 -31
View File
@@ -4,6 +4,7 @@ import { Icon } from "@opencode-ai/ui/icon"
import { Popover } from "@opencode-ai/ui/popover" import { Popover } from "@opencode-ai/ui/popover"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js" import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
@@ -130,41 +131,30 @@ const useDefaultServerKey = (
} }
} }
const useMcpToggle = (input: { const useMcpToggleMutation = () => {
sync: ReturnType<typeof useSync> const sync = useSync()
sdk: ReturnType<typeof useSDK> const sdk = useSDK()
language: ReturnType<typeof useLanguage> const language = useLanguage()
}) => {
const [loading, setLoading] = createSignal<string | null>(null)
const toggle = async (name: string) => { return useMutation(() => ({
if (loading()) return mutationFn: async (name: string) => {
setLoading(name) const status = sync.data.mcp[name]
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
try { const result = await sdk.client.mcp.status()
const status = input.sync.data.mcp[name] if (result.data) sync.set("mcp", result.data)
await (status?.status === "connected" },
? input.sdk.client.mcp.disconnect({ name }) onError: (err) => {
: input.sdk.client.mcp.connect({ name }))
const result = await input.sdk.client.mcp.status()
if (result.data) input.sync.set("mcp", result.data)
} catch (err) {
showToast({ showToast({
variant: "error", variant: "error",
title: input.language.t("common.requestFailed"), title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err), description: err instanceof Error ? err.message : String(err),
}) })
} finally { },
setLoading(null) }))
}
}
return { loading, toggle }
} }
export function StatusPopover() { export function StatusPopover() {
const sync = useSync() const sync = useSync()
const sdk = useSDK()
const server = useServer() const server = useServer()
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog() const dialog = useDialog()
@@ -181,7 +171,7 @@ export function StatusPopover() {
}) })
const health = useServerHealth(servers) const health = useServerHealth(servers)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const mcp = useMcpToggle({ sync, sdk, language }) const toggleMcp = useMcpToggleMutation()
const defaultServer = useDefaultServerKey(platform.getDefaultServer) const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
@@ -337,8 +327,11 @@ export function StatusPopover() {
<button <button
type="button" type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left" class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => mcp.toggle(name)} onClick={() => {
disabled={mcp.loading() === name} if (toggleMcp.isPending) return
toggleMcp.mutate(name)
}}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
> >
<div <div
classList={{ classList={{
@@ -354,8 +347,11 @@ export function StatusPopover() {
<div onClick={(event) => event.stopPropagation()}> <div onClick={(event) => event.stopPropagation()}>
<Switch <Switch
checked={enabled()} checked={enabled()}
disabled={mcp.loading() === name} disabled={toggleMcp.isPending && toggleMcp.variables === name}
onChange={() => mcp.toggle(name)} onChange={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
}}
/> />
</div> </div>
</button> </button>
+2
View File
@@ -23,6 +23,8 @@ export const dict = {
"command.sidebar.toggle": "Toggle sidebar", "command.sidebar.toggle": "Toggle sidebar",
"command.project.open": "Open project", "command.project.open": "Open project",
"command.project.previous": "Previous project",
"command.project.next": "Next project",
"command.provider.connect": "Connect provider", "command.provider.connect": "Connect provider",
"command.server.switch": "Switch server", "command.server.switch": "Switch server",
"command.settings.open": "Open settings", "command.settings.open": "Open settings",
+34
View File
@@ -936,6 +936,26 @@ export default function Layout(props: ParentProps) {
navigateToSession(session) navigateToSession(session)
} }
function navigateProjectByOffset(offset: number) {
const projects = layout.projects.list()
if (projects.length === 0) return
const current = currentProject()?.worktree
const fallback = currentDir() ? projectRoot(currentDir()) : undefined
const active = current ?? fallback
const index = active ? projects.findIndex((project) => project.worktree === active) : -1
const target =
index === -1
? offset > 0
? projects[0]
: projects[projects.length - 1]
: projects[(index + offset + projects.length) % projects.length]
if (!target) return
openProject(target.worktree)
}
function navigateSessionByUnseen(offset: number) { function navigateSessionByUnseen(offset: number) {
const sessions = currentSessions() const sessions = currentSessions()
if (sessions.length === 0) return if (sessions.length === 0) return
@@ -1002,6 +1022,20 @@ export default function Layout(props: ParentProps) {
keybind: "mod+o", keybind: "mod+o",
onSelect: () => chooseProject(), onSelect: () => chooseProject(),
}, },
{
id: "project.previous",
title: language.t("command.project.previous"),
category: language.t("command.category.project"),
keybind: "mod+alt+arrowup",
onSelect: () => navigateProjectByOffset(-1),
},
{
id: "project.next",
title: language.t("command.project.next"),
category: language.t("command.category.project"),
keybind: "mod+alt+arrowdown",
onSelect: () => navigateProjectByOffset(1),
},
{ {
id: "provider.connect", id: "provider.connect",
title: language.t("command.provider.connect"), title: language.t("command.provider.connect"),
+129 -121
View File
@@ -1,5 +1,6 @@
import type { Project, UserMessage } from "@opencode-ai/sdk/v2" import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import { import {
batch, batch,
onCleanup, onCleanup,
@@ -327,10 +328,7 @@ export default function Page() {
}) })
const [ui, setUi] = createStore({ const [ui, setUi] = createStore({
git: false,
pendingMessage: undefined as string | undefined, pendingMessage: undefined as string | undefined,
restoring: undefined as string | undefined,
reverting: false,
reviewSnap: false, reviewSnap: false,
scrollGesture: 0, scrollGesture: 0,
scroll: { scroll: {
@@ -506,7 +504,6 @@ export default function Page() {
const [followup, setFollowup] = createStore({ const [followup, setFollowup] = createStore({
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>, items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
sending: {} as Record<string, string | undefined>,
failed: {} as Record<string, string | undefined>, failed: {} as Record<string, string | undefined>,
paused: {} as Record<string, boolean | undefined>, paused: {} as Record<string, boolean | undefined>,
edit: {} as Record< edit: {} as Record<
@@ -644,25 +641,24 @@ export default function Page() {
globalSync.set("project", [...list, next]) globalSync.set("project", [...list, next])
} }
const gitMutation = useMutation(() => ({
mutationFn: () => sdk.client.project.initGit(),
onSuccess: (x) => {
if (!x.data) return
upsert(x.data)
},
onError: (err) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: formatServerError(err, language.t),
})
},
}))
function initGit() { function initGit() {
if (ui.git) return if (gitMutation.isPending) return
setUi("git", true) gitMutation.mutate()
void sdk.client.project
.initGit()
.then((x) => {
if (!x.data) return
upsert(x.data)
})
.catch((err) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: formatServerError(err, language.t),
})
})
.finally(() => {
setUi("git", false)
})
} }
let inputRef!: HTMLDivElement let inputRef!: HTMLDivElement
@@ -961,8 +957,8 @@ export default function Page() {
{language.t("session.review.noVcs.createGit.description")} {language.t("session.review.noVcs.createGit.description")}
</div> </div>
</div> </div>
<Button size="large" disabled={ui.git} onClick={initGit}> <Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
{ui.git {gitMutation.isPending
? language.t("session.review.noVcs.createGit.actionLoading") ? language.t("session.review.noVcs.createGit.actionLoading")
: language.t("session.review.noVcs.createGit.action")} : language.t("session.review.noVcs.createGit.action")}
</Button> </Button>
@@ -1379,10 +1375,40 @@ export default function Page() {
return followup.edit[id] return followup.edit[id]
}) })
const followupMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
if (!item) return
if (input.manual) setFollowup("paused", input.sessionID, undefined)
setFollowup("failed", input.sessionID, undefined)
const ok = await sendFollowupDraft({
client: sdk.client,
sync,
globalSync,
draft: item,
optimisticBusy: item.sessionDirectory === sdk.directory,
}).catch((err) => {
setFollowup("failed", input.sessionID, input.id)
fail(err)
return false
})
if (!ok) return
setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id))
if (input.manual) resumeScroll()
},
}))
const followupBusy = (sessionID: string) =>
followupMutation.isPending && followupMutation.variables?.sessionID === sessionID
const sendingFollowup = createMemo(() => { const sendingFollowup = createMemo(() => {
const id = params.id const id = params.id
if (!id) return if (!id) return
return followup.sending[id] if (!followupBusy(id)) return
return followupMutation.variables?.id
}) })
const queueEnabled = createMemo(() => { const queueEnabled = createMemo(() => {
@@ -1422,37 +1448,15 @@ export default function Page() {
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => { const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id) const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
if (!item) return Promise.resolve() if (!item) return Promise.resolve()
if (followup.sending[sessionID]) return Promise.resolve() if (followupBusy(sessionID)) return Promise.resolve()
if (opts?.manual) setFollowup("paused", sessionID, undefined) return followupMutation.mutateAsync({ sessionID, id, manual: opts?.manual })
setFollowup("sending", sessionID, id)
setFollowup("failed", sessionID, undefined)
return sendFollowupDraft({
client: sdk.client,
sync,
globalSync,
draft: item,
optimisticBusy: item.sessionDirectory === sdk.directory,
})
.then((ok) => {
if (ok === false) return
setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id))
if (opts?.manual) resumeScroll()
})
.catch((err) => {
setFollowup("failed", sessionID, id)
fail(err)
})
.finally(() => {
setFollowup("sending", sessionID, (value) => (value === id ? undefined : value))
})
} }
const editFollowup = (id: string) => { const editFollowup = (id: string) => {
const sessionID = params.id const sessionID = params.id
if (!sessionID) return if (!sessionID) return
if (followup.sending[sessionID]) return if (followupBusy(sessionID)) return
const item = queuedFollowups().find((entry) => entry.id === id) const item = queuedFollowups().find((entry) => entry.id === id)
if (!item) return if (!item) return
@@ -1475,6 +1479,74 @@ export default function Page() {
const halt = (sessionID: string) => const halt = (sessionID: string) =>
busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve() busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve()
const revertMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; messageID: string }) => {
const prev = prompt.current().slice()
const last = info()?.revert
const value = draft(input.messageID)
batch(() => {
roll(input.sessionID, { messageID: input.messageID })
prompt.set(value)
})
await halt(input.sessionID)
.then(() => sdk.client.session.revert(input))
.then((result) => {
if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(input.sessionID, last)
prompt.set(prev)
})
fail(err)
})
},
}))
const restoreMutation = useMutation(() => ({
mutationFn: async (id: string) => {
const sessionID = params.id
if (!sessionID) return
const next = userMessages().find((item) => item.id > id)
const prev = prompt.current().slice()
const last = info()?.revert
batch(() => {
roll(sessionID, next ? { messageID: next.id } : undefined)
if (next) {
prompt.set(draft(next.id))
return
}
prompt.reset()
})
const task = !next
? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
: halt(sessionID).then(() =>
sdk.client.session.revert({
sessionID,
messageID: next.id,
}),
)
await task
.then((result) => {
if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(sessionID, last)
prompt.set(prev)
})
fail(err)
})
},
}))
const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending)
const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
const fork = (input: { sessionID: string; messageID: string }) => { const fork = (input: { sessionID: string; messageID: string }) => {
const value = draft(input.messageID) const value = draft(input.messageID)
const dir = base64Encode(sdk.directory) const dir = base64Encode(sdk.directory)
@@ -1496,77 +1568,13 @@ export default function Page() {
} }
const revert = (input: { sessionID: string; messageID: string }) => { const revert = (input: { sessionID: string; messageID: string }) => {
if (ui.reverting || ui.restoring) return if (reverting()) return
const prev = prompt.current().slice() return revertMutation.mutateAsync(input)
const last = info()?.revert
const value = draft(input.messageID)
batch(() => {
setUi("reverting", true)
roll(input.sessionID, { messageID: input.messageID })
prompt.set(value)
})
return halt(input.sessionID)
.then(() => sdk.client.session.revert(input))
.then((result) => {
if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(input.sessionID, last)
prompt.set(prev)
})
fail(err)
})
.finally(() => {
setUi("reverting", false)
})
} }
const restore = (id: string) => { const restore = (id: string) => {
const sessionID = params.id if (!params.id || reverting()) return
if (!sessionID || ui.restoring || ui.reverting) return return restoreMutation.mutateAsync(id)
const next = userMessages().find((item) => item.id > id)
const prev = prompt.current().slice()
const last = info()?.revert
batch(() => {
setUi("restoring", id)
setUi("reverting", true)
roll(sessionID, next ? { messageID: next.id } : undefined)
if (next) {
prompt.set(draft(next.id))
return
}
prompt.reset()
})
const task = !next
? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
: halt(sessionID).then(() =>
sdk.client.session.revert({
sessionID,
messageID: next.id,
}),
)
return task
.then((result) => {
if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(sessionID, last)
prompt.set(prev)
})
fail(err)
})
.finally(() => {
batch(() => {
setUi("restoring", (value) => (value === id ? undefined : value))
setUi("reverting", false)
})
})
} }
const rolled = createMemo(() => { const rolled = createMemo(() => {
@@ -1585,7 +1593,7 @@ export default function Page() {
const item = queuedFollowups()[0] const item = queuedFollowups()[0]
if (!item) return if (!item) return
if (followup.sending[sessionID]) return if (followupBusy(sessionID)) return
if (followup.failed[sessionID] === item.id) return if (followup.failed[sessionID] === item.id) return
if (followup.paused[sessionID]) return if (followup.paused[sessionID]) return
if (composer.blocked()) return if (composer.blocked()) return
@@ -1780,8 +1788,8 @@ export default function Page() {
rolled().length > 0 rolled().length > 0
? { ? {
items: rolled(), items: rolled(),
restoring: ui.restoring, restoring: restoring(),
disabled: ui.reverting, disabled: reverting(),
onRestore: restore, onRestore: restore,
} }
: undefined : undefined
@@ -1,5 +1,6 @@
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js" import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt" import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
@@ -24,7 +25,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
custom: cached?.custom ?? ([] as string[]), custom: cached?.custom ?? ([] as string[]),
customOn: cached?.customOn ?? ([] as boolean[]), customOn: cached?.customOn ?? ([] as boolean[]),
editing: false, editing: false,
sending: false,
}) })
let root: HTMLDivElement | undefined let root: HTMLDivElement | undefined
@@ -126,36 +126,40 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
showToast({ title: language.t("common.requestFailed"), description: message }) showToast({ title: language.t("common.requestFailed"), description: message })
} }
const reply = async (answers: QuestionAnswer[]) => { const replyMutation = useMutation(() => ({
if (store.sending) return mutationFn: (answers: QuestionAnswer[]) => sdk.client.question.reply({ requestID: props.request.id, answers }),
onMutate: () => {
props.onSubmit() props.onSubmit()
setStore("sending", true) },
try { onSuccess: () => {
await sdk.client.question.reply({ requestID: props.request.id, answers })
replied = true replied = true
cache.delete(props.request.id) cache.delete(props.request.id)
} catch (err) { },
fail(err) onError: fail,
} finally { }))
setStore("sending", false)
} const rejectMutation = useMutation(() => ({
mutationFn: () => sdk.client.question.reject({ requestID: props.request.id }),
onMutate: () => {
props.onSubmit()
},
onSuccess: () => {
replied = true
cache.delete(props.request.id)
},
onError: fail,
}))
const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending)
const reply = async (answers: QuestionAnswer[]) => {
if (sending()) return
await replyMutation.mutateAsync(answers)
} }
const reject = async () => { const reject = async () => {
if (store.sending) return if (sending()) return
await rejectMutation.mutateAsync()
props.onSubmit()
setStore("sending", true)
try {
await sdk.client.question.reject({ requestID: props.request.id })
replied = true
cache.delete(props.request.id)
} catch (err) {
fail(err)
} finally {
setStore("sending", false)
}
} }
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? [])) const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
@@ -175,7 +179,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
const customToggle = () => { const customToggle = () => {
if (store.sending) return if (sending()) return
if (!multi()) { if (!multi()) {
setStore("customOn", store.tab, true) setStore("customOn", store.tab, true)
@@ -198,14 +202,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
const customOpen = () => { const customOpen = () => {
if (store.sending) return if (sending()) return
if (!on()) setStore("customOn", store.tab, true) if (!on()) setStore("customOn", store.tab, true)
setStore("editing", true) setStore("editing", true)
customUpdate(input(), true) customUpdate(input(), true)
} }
const selectOption = (optIndex: number) => { const selectOption = (optIndex: number) => {
if (store.sending) return if (sending()) return
if (optIndex === options().length) { if (optIndex === options().length) {
customOpen() customOpen()
@@ -227,7 +231,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
const next = () => { const next = () => {
if (store.sending) return if (sending()) return
if (store.editing) commitCustom() if (store.editing) commitCustom()
if (store.tab >= total() - 1) { if (store.tab >= total() - 1) {
@@ -240,14 +244,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
const back = () => { const back = () => {
if (store.sending) return if (sending()) return
if (store.tab <= 0) return if (store.tab <= 0) return
setStore("tab", store.tab - 1) setStore("tab", store.tab - 1)
setStore("editing", false) setStore("editing", false)
} }
const jump = (tab: number) => { const jump = (tab: number) => {
if (store.sending) return if (sending()) return
setStore("tab", tab) setStore("tab", tab)
setStore("editing", false) setStore("editing", false)
} }
@@ -270,7 +274,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
(store.answers[i()]?.length ?? 0) > 0 || (store.answers[i()]?.length ?? 0) > 0 ||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0) (store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
} }
disabled={store.sending} disabled={sending()}
onClick={() => jump(i())} onClick={() => jump(i())}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`} aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
/> />
@@ -281,16 +285,16 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
footer={ footer={
<> <>
<Button variant="ghost" size="large" disabled={store.sending} onClick={reject}> <Button variant="ghost" size="large" disabled={sending()} onClick={reject}>
{language.t("ui.common.dismiss")} {language.t("ui.common.dismiss")}
</Button> </Button>
<div data-slot="question-footer-actions"> <div data-slot="question-footer-actions">
<Show when={store.tab > 0}> <Show when={store.tab > 0}>
<Button variant="secondary" size="large" disabled={store.sending} onClick={back}> <Button variant="secondary" size="large" disabled={sending()} onClick={back}>
{language.t("ui.common.back")} {language.t("ui.common.back")}
</Button> </Button>
</Show> </Show>
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}> <Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}>
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")} {last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
</Button> </Button>
</div> </div>
@@ -311,7 +315,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
data-picked={picked()} data-picked={picked()}
role={multi() ? "checkbox" : "radio"} role={multi() ? "checkbox" : "radio"}
aria-checked={picked()} aria-checked={picked()}
disabled={store.sending} disabled={sending()}
onClick={() => selectOption(i())} onClick={() => selectOption(i())}
> >
<span data-slot="question-option-check" aria-hidden="true"> <span data-slot="question-option-check" aria-hidden="true">
@@ -345,7 +349,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
data-picked={on()} data-picked={on()}
role={multi() ? "checkbox" : "radio"} role={multi() ? "checkbox" : "radio"}
aria-checked={on()} aria-checked={on()}
disabled={store.sending} disabled={sending()}
onClick={customOpen} onClick={customOpen}
> >
<span <span
@@ -377,7 +381,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
role={multi() ? "checkbox" : "radio"} role={multi() ? "checkbox" : "radio"}
aria-checked={on()} aria-checked={on()}
onMouseDown={(e) => { onMouseDown={(e) => {
if (store.sending) { if (sending()) {
e.preventDefault() e.preventDefault()
return return
} }
@@ -419,7 +423,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
placeholder={language.t("ui.question.custom.placeholder")} placeholder={language.t("ui.question.custom.placeholder")}
value={input()} value={input()}
rows={1} rows={1}
disabled={store.sending} disabled={sending()}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault() e.preventDefault()
@@ -1,6 +1,7 @@
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js" import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
@@ -321,7 +322,6 @@ export function MessageTimeline(props: {
const [title, setTitle] = createStore({ const [title, setTitle] = createStore({
draft: "", draft: "",
editing: false, editing: false,
saving: false,
menuOpen: false, menuOpen: false,
pendingRename: false, pendingRename: false,
pendingShare: false, pendingShare: false,
@@ -335,38 +335,6 @@ export function MessageTimeline(props: {
let more: HTMLButtonElement | undefined let more: HTMLButtonElement | undefined
const [req, setReq] = createStore({ share: false, unshare: false })
const shareSession = () => {
const id = sessionID()
if (!id || req.share) return
if (!shareEnabled()) return
setReq("share", true)
globalSDK.client.session
.share({ sessionID: id, directory: sdk.directory })
.catch((err: unknown) => {
console.error("Failed to share session", err)
})
.finally(() => {
setReq("share", false)
})
}
const unshareSession = () => {
const id = sessionID()
if (!id || req.unshare) return
if (!shareEnabled()) return
setReq("unshare", true)
globalSDK.client.session
.unshare({ sessionID: id, directory: sdk.directory })
.catch((err: unknown) => {
console.error("Failed to unshare session", err)
})
.finally(() => {
setReq("unshare", false)
})
}
const viewShare = () => { const viewShare = () => {
const url = shareUrl() const url = shareUrl()
if (!url) return if (!url) return
@@ -382,6 +350,54 @@ export function MessageTimeline(props: {
return language.t("common.requestFailed") return language.t("common.requestFailed")
} }
const shareMutation = useMutation(() => ({
mutationFn: (id: string) => globalSDK.client.session.share({ sessionID: id, directory: sdk.directory }),
onError: (err) => {
console.error("Failed to share session", err)
},
}))
const unshareMutation = useMutation(() => ({
mutationFn: (id: string) => globalSDK.client.session.unshare({ sessionID: id, directory: sdk.directory }),
onError: (err) => {
console.error("Failed to unshare session", err)
},
}))
const titleMutation = useMutation(() => ({
mutationFn: (input: { id: string; title: string }) =>
sdk.client.session.update({ sessionID: input.id, title: input.title }),
onSuccess: (_, input) => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === input.id)
if (index !== -1) draft.session[index].title = input.title
}),
)
setTitle("editing", false)
},
onError: (err) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
},
}))
const shareSession = () => {
const id = sessionID()
if (!id || shareMutation.isPending) return
if (!shareEnabled()) return
shareMutation.mutate(id)
}
const unshareSession = () => {
const id = sessionID()
if (!id || unshareMutation.isPending) return
if (!shareEnabled()) return
unshareMutation.mutate(id)
}
createEffect( createEffect(
on( on(
sessionKey, sessionKey,
@@ -389,7 +405,6 @@ export function MessageTimeline(props: {
setTitle({ setTitle({
draft: "", draft: "",
editing: false, editing: false,
saving: false,
menuOpen: false, menuOpen: false,
pendingRename: false, pendingRename: false,
pendingShare: false, pendingShare: false,
@@ -408,40 +423,22 @@ export function MessageTimeline(props: {
} }
const closeTitleEditor = () => { const closeTitleEditor = () => {
if (title.saving) return if (titleMutation.isPending) return
setTitle({ editing: false, saving: false }) setTitle("editing", false)
} }
const saveTitleEditor = async () => { const saveTitleEditor = () => {
const id = sessionID() const id = sessionID()
if (!id) return if (!id) return
if (title.saving) return if (titleMutation.isPending) return
const next = title.draft.trim() const next = title.draft.trim()
if (!next || next === (titleValue() ?? "")) { if (!next || next === (titleValue() ?? "")) {
setTitle({ editing: false, saving: false }) setTitle("editing", false)
return return
} }
setTitle("saving", true) titleMutation.mutate({ id, title: next })
await sdk.client.session
.update({ sessionID: id, title: next })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === id)
if (index !== -1) draft.session[index].title = next
}),
)
setTitle({ editing: false, saving: false })
})
.catch((err) => {
setTitle("saving", false)
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
} }
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
@@ -712,7 +709,7 @@ export function MessageTimeline(props: {
titleRef = el titleRef = el
}} }}
value={title.draft} value={title.draft}
disabled={title.saving} disabled={titleMutation.isPending}
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]" class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
onInput={(event) => setTitle("draft", event.currentTarget.value)} onInput={(event) => setTitle("draft", event.currentTarget.value)}
@@ -863,9 +860,9 @@ export function MessageTimeline(props: {
variant="primary" variant="primary"
class="w-full" class="w-full"
onClick={shareSession} onClick={shareSession}
disabled={req.share} disabled={shareMutation.isPending}
> >
{req.share {shareMutation.isPending
? language.t("session.share.action.publishing") ? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")} : language.t("session.share.action.publish")}
</Button> </Button>
@@ -886,9 +883,9 @@ export function MessageTimeline(props: {
variant="secondary" variant="secondary"
class="w-full shadow-none border border-border-weak-base" class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession} onClick={unshareSession}
disabled={req.unshare} disabled={unshareMutation.isPending}
> >
{req.unshare {unshareMutation.isPending
? language.t("session.share.action.unpublishing") ? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")} : language.t("session.share.action.unpublish")}
</Button> </Button>
@@ -897,7 +894,7 @@ export function MessageTimeline(props: {
variant="primary" variant="primary"
class="w-full" class="w-full"
onClick={viewShare} onClick={viewShare}
disabled={req.unshare} disabled={unshareMutation.isPending}
> >
{language.t("session.share.action.view")} {language.t("session.share.action.view")}
</Button> </Button>
@@ -24,7 +24,13 @@ import {
FreeUsageLimitError, FreeUsageLimitError,
SubscriptionUsageLimitError, SubscriptionUsageLimitError,
} from "./error" } from "./error"
import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider" import {
buildCostChunk,
createBodyConverter,
createStreamPartConverter,
createResponseConverter,
UsageInfo,
} from "./provider/provider"
import { anthropicHelper } from "./provider/anthropic" import { anthropicHelper } from "./provider/anthropic"
import { googleHelper } from "./provider/google" import { googleHelper } from "./provider/google"
import { openaiHelper } from "./provider/openai" import { openaiHelper } from "./provider/openai"
@@ -90,7 +96,7 @@ export async function handler(
const projectId = input.request.headers.get("x-opencode-project") ?? "" const projectId = input.request.headers.get("x-opencode-project") ?? ""
const ocClient = input.request.headers.get("x-opencode-client") ?? "" const ocClient = input.request.headers.get("x-opencode-client") ?? ""
logger.metric({ logger.metric({
is_tream: isStream, is_stream: isStream,
session: sessionId, session: sessionId,
request: requestId, request: requestId,
client: ocClient, client: ocClient,
@@ -230,7 +236,7 @@ export async function handler(
const body = JSON.stringify( const body = JSON.stringify(
responseConverter({ responseConverter({
...json, ...json,
cost: calculateOccuredCost(billingSource, costInfo), cost: calculateOccurredCost(billingSource, costInfo),
}), }),
) )
logger.metric({ response_length: body.length }) logger.metric({ response_length: body.length })
@@ -274,8 +280,8 @@ export async function handler(
await trialLimiter?.track(usageInfo) await trialLimiter?.track(usageInfo)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo) await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo) await reload(billingSource, authInfo, costInfo)
const cost = calculateOccuredCost(billingSource, costInfo) const cost = calculateOccurredCost(billingSource, costInfo)
c.enqueue(encoder.encode(usageParser.buidlCostChunk(cost))) c.enqueue(encoder.encode(buildCostChunk(opts.format, cost)))
} }
c.close() c.close()
return return
@@ -818,7 +824,7 @@ export async function handler(
} }
} }
function calculateOccuredCost(billingSource: BillingSource, costInfo: CostInfo) { function calculateOccurredCost(billingSource: BillingSource, costInfo: CostInfo) {
return billingSource === "balance" ? (costInfo.totalCostInCent / 100).toFixed(8) : "0" return billingSource === "balance" ? (costInfo.totalCostInCent / 100).toFixed(8) : "0"
} }
@@ -20,6 +20,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
const isBedrockModelArn = providerModel.startsWith("arn:aws:bedrock:") const isBedrockModelArn = providerModel.startsWith("arn:aws:bedrock:")
const isBedrockModelID = providerModel.startsWith("global.anthropic.") const isBedrockModelID = providerModel.startsWith("global.anthropic.")
const isBedrock = isBedrockModelArn || isBedrockModelID const isBedrock = isBedrockModelArn || isBedrockModelID
const isDatabricks = providerModel.startsWith("databricks-claude-")
const supports1m = reqModel.includes("sonnet") || reqModel.includes("opus-4-6") const supports1m = reqModel.includes("sonnet") || reqModel.includes("opus-4-6")
return { return {
format: "anthropic", format: "anthropic",
@@ -28,7 +29,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
? `${providerApi}/model/${isBedrockModelArn ? encodeURIComponent(providerModel) : providerModel}/${isStream ? "invoke-with-response-stream" : "invoke"}` ? `${providerApi}/model/${isBedrockModelArn ? encodeURIComponent(providerModel) : providerModel}/${isStream ? "invoke-with-response-stream" : "invoke"}`
: providerApi + "/messages", : providerApi + "/messages",
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => { modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
if (isBedrock) { if (isBedrock || isDatabricks) {
headers.set("Authorization", `Bearer ${apiKey}`) headers.set("Authorization", `Bearer ${apiKey}`)
} else { } else {
headers.set("x-api-key", apiKey) headers.set("x-api-key", apiKey)
@@ -47,9 +48,14 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
model: undefined, model: undefined,
stream: undefined, stream: undefined,
} }
: { : isDatabricks
service_tier: "standard_only", ? {
}), anthropic_version: "bedrock-2023-05-31",
anthropic_beta: supports1m ? ["context-1m-2025-08-07"] : undefined,
}
: {
service_tier: "standard_only",
}),
}), }),
createBinaryStreamDecoder: () => { createBinaryStreamDecoder: () => {
if (!isBedrock) return undefined if (!isBedrock) return undefined
@@ -167,7 +173,6 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
} }
}, },
retrieve: () => usage, retrieve: () => usage,
buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
} }
}, },
normalizeUsage: (usage: Usage) => ({ normalizeUsage: (usage: Usage) => ({
@@ -56,7 +56,6 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({
usage = json.usageMetadata usage = json.usageMetadata
}, },
retrieve: () => usage, retrieve: () => usage,
buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ type: "ping", cost })}\n\n`,
} }
}, },
normalizeUsage: (usage: Usage) => { normalizeUsage: (usage: Usage) => {
@@ -54,7 +54,6 @@ export const oaCompatHelper: ProviderHelper = () => ({
usage = json.usage usage = json.usage
}, },
retrieve: () => usage, retrieve: () => usage,
buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ choices: [], cost })}\n\n`,
} }
}, },
normalizeUsage: (usage: Usage) => { normalizeUsage: (usage: Usage) => {
@@ -44,7 +44,6 @@ export const openaiHelper: ProviderHelper = () => ({
usage = json.response.usage usage = json.response.usage
}, },
retrieve: () => usage, retrieve: () => usage,
buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
} }
}, },
normalizeUsage: (usage: Usage) => { normalizeUsage: (usage: Usage) => {
@@ -43,7 +43,6 @@ export type ProviderHelper = (input: { reqModel: string; providerModel: string }
createUsageParser: () => { createUsageParser: () => {
parse: (chunk: string) => void parse: (chunk: string) => void
retrieve: () => any retrieve: () => any
buidlCostChunk: (cost: string) => string
} }
normalizeUsage: (usage: any) => UsageInfo normalizeUsage: (usage: any) => UsageInfo
} }
@@ -162,6 +161,19 @@ export interface CommonChunk {
} }
} }
export function buildCostChunk(format: ZenData.Format, cost: string): string {
switch (format) {
case "anthropic":
return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`
case "openai":
return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`
case "oa-compat":
return `data: ${JSON.stringify({ choices: [], cost })}\n\n`
default:
return `data: ${JSON.stringify({ type: "ping", cost })}\n\n`
}
}
export function createBodyConverter(from: ZenData.Format, to: ZenData.Format) { export function createBodyConverter(from: ZenData.Format, to: ZenData.Format) {
return (body: any): any => { return (body: any): any => {
if (from === to) return body if (from === to) return body
+2 -2
View File
@@ -89,8 +89,6 @@
"@ai-sdk/xai": "2.0.51", "@ai-sdk/xai": "2.0.51",
"@aws-sdk/credential-providers": "3.993.0", "@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"gitlab-ai-provider": "5.2.2",
"opencode-gitlab-auth": "2.0.0",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:", "@hono/zod-validator": "catalog:",
@@ -123,6 +121,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "5.2.2",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
@@ -133,6 +132,7 @@
"mime-types": "3.0.2", "mime-types": "3.0.2",
"minimatch": "10.0.3", "minimatch": "10.0.3",
"open": "10.1.2", "open": "10.1.2",
"opencode-gitlab-auth": "2.0.0",
"opentui-spinner": "0.0.6", "opentui-spinner": "0.0.6",
"partial-json": "0.1.7", "partial-json": "0.1.7",
"remeda": "catalog:", "remeda": "catalog:",
+13
View File
@@ -199,6 +199,19 @@ for (const item of targets) {
}, },
}) })
// Smoke test: only run if binary is for current platform
if (item.os === process.platform && item.arch === process.arch && !item.abi) {
const binaryPath = `dist/${name}/bin/opencode`
console.log(`Running smoke test: ${binaryPath} --version`)
try {
const versionOutput = await $`${binaryPath} --version`.text()
console.log(`Smoke test passed: ${versionOutput.trim()}`)
} catch (e) {
console.error(`Smoke test failed for ${name}:`, e)
process.exit(1)
}
}
await $`rm -rf ./dist/${name}/bin/tui` await $`rm -rf ./dist/${name}/bin/tui`
await Bun.file(`dist/${name}/package.json`).write( await Bun.file(`dist/${name}/package.json`).write(
JSON.stringify( JSON.stringify(
-2
View File
@@ -11,7 +11,6 @@ const seed = async () => {
const { Instance } = await import("../src/project/instance") const { Instance } = await import("../src/project/instance")
const { InstanceBootstrap } = await import("../src/project/bootstrap") const { InstanceBootstrap } = await import("../src/project/bootstrap")
const { Config } = await import("../src/config/config") const { Config } = await import("../src/config/config")
const { disposeRuntime } = await import("../src/effect/runtime")
const { Session } = await import("../src/session") const { Session } = await import("../src/session")
const { MessageID, PartID } = await import("../src/session/schema") const { MessageID, PartID } = await import("../src/session/schema")
const { Project } = await import("../src/project/project") const { Project } = await import("../src/project/project")
@@ -55,7 +54,6 @@ const seed = async () => {
}) })
} finally { } finally {
await Instance.disposeAll().catch(() => {}) await Instance.disposeAll().catch(() => {})
await disposeRuntime().catch(() => {})
} }
} }
+53 -54
View File
@@ -4,18 +4,18 @@ Practical reference for new and migrated Effect code in `packages/opencode`.
## Choose scope ## Choose scope
Use the shared runtime for process-wide services with one lifecycle for the whole app. Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal.
Use `src/effect/instances.ts` for services that are created per directory or need `InstanceContext`, per-project state, or per-instance cleanup. Use `makeRunPromise` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`.
- Shared runtime: config readers, stateless helpers, global clients - Global services (no per-directory state): Account, Auth, Installation, Truncate
- Instance-scoped: watchers, per-project caches, session state, project-bound background work - Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
Rule of thumb: if two open directories should not share one copy of the service, it belongs in `Instances`. Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`.
## Service shape ## Service shape
For a fully migrated module, use the public namespace directly: Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions:
```ts ```ts
export namespace Foo { export namespace Foo {
@@ -28,53 +28,52 @@ export namespace Foo {
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
return Service.of({ // For instance-scoped services:
get: Effect.fn("Foo.get")(function* (id) { const state = yield* InstanceState.make<State>(
return yield* ... Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
}), )
const get = Effect.fn("Foo.get")(function* (id: FooID) {
const s = yield* InstanceState.get(state)
// ...
}) })
return Service.of({ get })
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer)) // Optional: wire dependencies
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
// Per-service runtime (inside the namespace)
const runPromise = makeRunPromise(Service, defaultLayer)
// Async facade functions
export async function get(id: FooID) {
return runPromise((svc) => svc.get(id))
}
} }
``` ```
Rules: Rules:
- Keep `Interface`, `Service`, `layer`, and `defaultLayer` on the owning namespace - Keep everything in one namespace, one file — no separate `service.ts` / `index.ts` split
- Export `defaultLayer` only when wiring dependencies is useful - `runPromise` goes inside the namespace (not exported unless tests need it)
- Use the direct namespace form once the module is fully migrated - Facade functions are plain `async function` — no `fn()` wrappers
- Use `Effect.fn("Namespace.method")` for all Effect functions (for tracing)
- No `Layer.fresh` — InstanceState handles per-directory isolation
## Temporary mixed-mode pattern ## Schema → Zod interop
Prefer a single namespace whenever possible. When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@/util/effect-zod`:
Use a `*Effect` namespace only when there is a real mixed-mode split, usually because a legacy boundary facade still exists or because merging everything immediately would create awkward cycles.
```ts ```ts
export namespace FooEffect { import { zod } from "@/util/effect-zod"
export interface Interface {
readonly get: (id: FooID) => Effect.Effect<Foo, FooError>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {} export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union
export const layer = Layer.effect(...)
}
``` ```
Then keep the old boundary thin: See `Auth.ZodInfo` for the canonical example.
```ts
export namespace Foo {
export function get(id: FooID) {
return runtime.runPromise(FooEffect.Service.use((svc) => svc.get(id)))
}
}
```
Remove the `Effect` suffix when the boundary split is gone.
## Scheduled Tasks ## Scheduled Tasks
@@ -107,22 +106,23 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
## Migration checklist ## Migration checklist
Done now: Fully migrated (single namespace, InstanceState where needed, flattened facade):
- [x] `AccountEffect` (mixed-mode) - [x] `Account``account/index.ts`
- [x] `AuthEffect` (mixed-mode) - [x] `Auth``auth/index.ts` (uses `zod()` helper for Schema→Zod interop)
- [x] `TruncateEffect` (mixed-mode) - [x] `File``file/index.ts`
- [x] `Question` - [x] `FileTime``file/time.ts`
- [x] `PermissionNext` - [x] `FileWatcher``file/watcher.ts`
- [x] `ProviderAuth` - [x] `Format``format/index.ts`
- [x] `FileWatcher` - [x] `Installation``installation/index.ts`
- [x] `FileTime` - [x] `Permission``permission/index.ts`
- [x] `Format` - [x] `ProviderAuth``provider/auth.ts`
- [x] `Vcs` - [x] `Question``question/index.ts`
- [x] `Skill` - [x] `Skill``skill/index.ts`
- [x] `Discovery` - [x] `Snapshot``snapshot/index.ts`
- [x] `File` - [x] `Truncate``tool/truncate.ts`
- [x] `Snapshot` - [x] `Vcs``project/vcs.ts`
- [x] `Discovery``skill/discovery.ts`
Still open and likely worth migrating: Still open and likely worth migrating:
@@ -130,9 +130,8 @@ Still open and likely worth migrating:
- [ ] `ToolRegistry` - [ ] `ToolRegistry`
- [ ] `Pty` - [ ] `Pty`
- [ ] `Worktree` - [ ] `Worktree`
- [ ] `Installation`
- [ ] `Bus` - [ ] `Bus`
- [ ] `Command` - [x] `Command`
- [ ] `Config` - [ ] `Config`
- [ ] `Session` - [ ] `Session`
- [ ] `SessionProcessor` - [ ] `SessionProcessor`
-380
View File
@@ -1,380 +0,0 @@
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo"
import {
type AccountError,
AccessToken,
AccountID,
DeviceCode,
Info,
RefreshToken,
AccountServiceError,
Login,
Org,
OrgID,
PollDenied,
PollError,
PollExpired,
PollPending,
type PollResult,
PollSlow,
PollSuccess,
UserCode,
} from "./schema"
export {
AccountID,
type AccountError,
AccountRepoError,
AccountServiceError,
AccessToken,
RefreshToken,
DeviceCode,
UserCode,
Info,
Org,
OrgID,
Login,
PollSuccess,
PollPending,
PollSlow,
PollExpired,
PollDenied,
PollError,
PollResult,
} from "./schema"
export type AccountOrgs = {
account: Info
orgs: readonly Org[]
}
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
config: Schema.Record(Schema.String, Schema.Json),
}) {}
const DurationFromSeconds = Schema.Number.pipe(
Schema.decodeTo(Schema.Duration, {
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
}),
)
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
access_token: AccessToken,
refresh_token: RefreshToken,
expires_in: DurationFromSeconds,
}) {}
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
device_code: DeviceCode,
user_code: UserCode,
verification_uri_complete: Schema.String,
expires_in: DurationFromSeconds,
interval: DurationFromSeconds,
}) {}
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
access_token: AccessToken,
refresh_token: RefreshToken,
token_type: Schema.Literal("Bearer"),
expires_in: DurationFromSeconds,
}) {}
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
error: Schema.String,
error_description: Schema.String,
}) {
toPollResult(): PollResult {
if (this.error === "authorization_pending") return new PollPending()
if (this.error === "slow_down") return new PollSlow()
if (this.error === "expired_token") return new PollExpired()
if (this.error === "access_denied") return new PollDenied()
return new PollError({ cause: this.error })
}
}
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
class User extends Schema.Class<User>("User")({
id: AccountID,
email: Schema.String,
}) {}
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
grant_type: Schema.String,
device_code: DeviceCode,
client_id: Schema.String,
}) {}
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
grant_type: Schema.String,
refresh_token: RefreshToken,
client_id: Schema.String,
}) {}
const clientId = "opencode-cli"
const mapAccountServiceError =
(message = "Account service operation failed") =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
effect.pipe(
Effect.mapError((cause) =>
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
),
)
export namespace Account {
export interface Interface {
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
readonly list: () => Effect.Effect<Info[], AccountError>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
readonly config: (
accountID: AccountID,
orgID: OrgID,
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
readonly login: (url: string) => Effect.Effect<Login, AccountError>
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
Service,
Effect.gen(function* () {
const repo = yield* AccountRepo
const http = yield* HttpClient.HttpClient
const httpRead = withTransientReadRetry(http)
const httpOk = HttpClient.filterStatusOk(http)
const httpReadOk = HttpClient.filterStatusOk(httpRead)
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => httpOk.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => http.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (row.token_expiry && row.token_expiry > now) return row.access_token
const response = yield* executeEffectOk(
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
new TokenRefreshRequest({
grant_type: "refresh_token",
refresh_token: row.refresh_token,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
yield* repo.persistToken({
accountID: row.id,
accessToken: parsed.access_token,
refreshToken: parsed.refresh_token,
expiry,
})
return parsed.access_token
})
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) return Option.none()
const account = maybeAccount.value
const accessToken = yield* resolveToken(account)
return Option.some({ account, accessToken })
})
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/user`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const token = Effect.fn("Account.token")((accountID: AccountID) =>
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
)
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
const accounts = yield* repo.list()
const [errors, results] = yield* Effect.partition(
accounts,
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
{ concurrency: 3 },
)
for (const error of errors) {
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
Effect.annotateLogs({ error: String(error) }),
)
}
return results
})
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return []
const { account, accessToken } = resolved.value
return yield* fetchOrgs(account.url, accessToken)
})
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none()
const { account, accessToken } = resolved.value
const response = yield* executeRead(
HttpClientRequest.get(`${account.url}/api/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
),
)
if (response.status === 404) return Option.none()
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
mapAccountServiceError("Failed to decode response"),
)
return Option.some(parsed.config)
})
const login = Effect.fn("Account.login")(function* (server: string) {
const response = yield* executeEffectOk(
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
return new Login({
code: parsed.device_code,
user: parsed.user_code,
url: `${server}${parsed.verification_uri_complete}`,
server,
expiry: parsed.expires_in,
interval: parsed.interval,
})
})
const poll = Effect.fn("Account.poll")(function* (input: Login) {
const response = yield* executeEffect(
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
new DeviceTokenRequest({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: input.code,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
const accessToken = parsed.access_token
const user = fetchUser(input.server, accessToken)
const orgs = fetchOrgs(input.server, accessToken)
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
// TODO: When there are multiple orgs, let the user choose
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
const now = yield* Clock.currentTimeMillis
const expiry = now + Duration.toMillis(parsed.expires_in)
const refreshToken = parsed.refresh_token
yield* repo.persistAccount({
id: account.id,
email: account.email,
url: input.server,
accessToken,
refreshToken,
expiry,
orgID: firstOrgID,
})
return new PollSuccess({ email: account.email })
})
return Service.of({
active: repo.active,
list: repo.list,
orgsByAccount,
remove: repo.remove,
use: repo.use,
orgs,
config,
token,
login,
poll,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
}
+380 -17
View File
@@ -1,34 +1,397 @@
import { Effect, Option } from "effect" import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Account as S, type AccountError, type AccessToken, AccountID, Info as Model, OrgID } from "./effect" import { makeRunPromise } from "@/effect/run-service"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo"
import {
type AccountError,
AccessToken,
AccountID,
DeviceCode,
Info,
RefreshToken,
AccountServiceError,
Login,
Org,
OrgID,
PollDenied,
PollError,
PollExpired,
PollPending,
type PollResult,
PollSlow,
PollSuccess,
UserCode,
} from "./schema"
export { AccessToken, AccountID, OrgID } from "./effect" export {
AccountID,
type AccountError,
AccountRepoError,
AccountServiceError,
AccessToken,
RefreshToken,
DeviceCode,
UserCode,
Info,
Org,
OrgID,
Login,
PollSuccess,
PollPending,
PollSlow,
PollExpired,
PollDenied,
PollError,
PollResult,
} from "./schema"
import { runtime } from "@/effect/runtime" export type AccountOrgs = {
account: Info
function runSync<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) { orgs: readonly Org[]
return runtime.runSync(S.Service.use(f))
} }
function runPromise<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) { class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
return runtime.runPromise(S.Service.use(f)) config: Schema.Record(Schema.String, Schema.Json),
}) {}
const DurationFromSeconds = Schema.Number.pipe(
Schema.decodeTo(Schema.Duration, {
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
}),
)
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
access_token: AccessToken,
refresh_token: RefreshToken,
expires_in: DurationFromSeconds,
}) {}
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
device_code: DeviceCode,
user_code: UserCode,
verification_uri_complete: Schema.String,
expires_in: DurationFromSeconds,
interval: DurationFromSeconds,
}) {}
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
access_token: AccessToken,
refresh_token: RefreshToken,
token_type: Schema.Literal("Bearer"),
expires_in: DurationFromSeconds,
}) {}
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
error: Schema.String,
error_description: Schema.String,
}) {
toPollResult(): PollResult {
if (this.error === "authorization_pending") return new PollPending()
if (this.error === "slow_down") return new PollSlow()
if (this.error === "expired_token") return new PollExpired()
if (this.error === "access_denied") return new PollDenied()
return new PollError({ cause: this.error })
}
} }
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
class User extends Schema.Class<User>("User")({
id: AccountID,
email: Schema.String,
}) {}
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
grant_type: Schema.String,
device_code: DeviceCode,
client_id: Schema.String,
}) {}
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
grant_type: Schema.String,
refresh_token: RefreshToken,
client_id: Schema.String,
}) {}
const clientId = "opencode-cli"
const mapAccountServiceError =
(message = "Account service operation failed") =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
effect.pipe(
Effect.mapError((cause) =>
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
),
)
export namespace Account { export namespace Account {
export const Info = Model export interface Interface {
export type Info = Model readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
readonly list: () => Effect.Effect<Info[], AccountError>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
readonly config: (
accountID: AccountID,
orgID: OrgID,
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
readonly login: (url: string) => Effect.Effect<Login, AccountError>
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
}
export function active(): Info | undefined { export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
return Option.getOrUndefined(runSync((service) => service.active()))
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
Service,
Effect.gen(function* () {
const repo = yield* AccountRepo
const http = yield* HttpClient.HttpClient
const httpRead = withTransientReadRetry(http)
const httpOk = HttpClient.filterStatusOk(http)
const httpReadOk = HttpClient.filterStatusOk(httpRead)
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => httpOk.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => http.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (row.token_expiry && row.token_expiry > now) return row.access_token
const response = yield* executeEffectOk(
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
new TokenRefreshRequest({
grant_type: "refresh_token",
refresh_token: row.refresh_token,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
yield* repo.persistToken({
accountID: row.id,
accessToken: parsed.access_token,
refreshToken: parsed.refresh_token,
expiry,
})
return parsed.access_token
})
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) return Option.none()
const account = maybeAccount.value
const accessToken = yield* resolveToken(account)
return Option.some({ account, accessToken })
})
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/user`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const token = Effect.fn("Account.token")((accountID: AccountID) =>
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
)
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
const accounts = yield* repo.list()
const [errors, results] = yield* Effect.partition(
accounts,
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
{ concurrency: 3 },
)
for (const error of errors) {
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
Effect.annotateLogs({ error: String(error) }),
)
}
return results
})
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return []
const { account, accessToken } = resolved.value
return yield* fetchOrgs(account.url, accessToken)
})
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none()
const { account, accessToken } = resolved.value
const response = yield* executeRead(
HttpClientRequest.get(`${account.url}/api/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
),
)
if (response.status === 404) return Option.none()
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
mapAccountServiceError("Failed to decode response"),
)
return Option.some(parsed.config)
})
const login = Effect.fn("Account.login")(function* (server: string) {
const response = yield* executeEffectOk(
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
return new Login({
code: parsed.device_code,
user: parsed.user_code,
url: `${server}${parsed.verification_uri_complete}`,
server,
expiry: parsed.expires_in,
interval: parsed.interval,
})
})
const poll = Effect.fn("Account.poll")(function* (input: Login) {
const response = yield* executeEffect(
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
new DeviceTokenRequest({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: input.code,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
const accessToken = parsed.access_token
const user = fetchUser(input.server, accessToken)
const orgs = fetchOrgs(input.server, accessToken)
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
// TODO: When there are multiple orgs, let the user choose
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
const now = yield* Clock.currentTimeMillis
const expiry = now + Duration.toMillis(parsed.expires_in)
const refreshToken = parsed.refresh_token
yield* repo.persistAccount({
id: account.id,
email: account.email,
url: input.server,
accessToken,
refreshToken,
expiry,
orgID: firstOrgID,
})
return new PollSuccess({ email: account.email })
})
return Service.of({
active: repo.active,
list: repo.list,
orgsByAccount,
remove: repo.remove,
use: repo.use,
orgs,
config,
token,
login,
poll,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
export const runPromise = makeRunPromise(Service, defaultLayer)
export async function active(): Promise<Info | undefined> {
return Option.getOrUndefined(await runPromise((service) => service.active()))
} }
export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> { export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> {
const config = await runPromise((service) => service.config(accountID, orgID)) const cfg = await runPromise((service) => service.config(accountID, orgID))
return Option.getOrUndefined(config) return Option.getOrUndefined(cfg)
} }
export async function token(accountID: AccountID): Promise<AccessToken | undefined> { export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
const token = await runPromise((service) => service.token(accountID)) const t = await runPromise((service) => service.token(accountID))
return Option.getOrUndefined(token) return Option.getOrUndefined(t)
} }
} }
+22 -22
View File
@@ -14,7 +14,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt"
import PROMPT_EXPLORE from "./prompt/explore.txt" import PROMPT_EXPLORE from "./prompt/explore.txt"
import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_SUMMARY from "./prompt/summary.txt"
import PROMPT_TITLE from "./prompt/title.txt" import PROMPT_TITLE from "./prompt/title.txt"
import { PermissionNext } from "@/permission" import { Permission } from "@/permission"
import { mergeDeep, pipe, sortBy, values } from "remeda" import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@/global" import { Global } from "@/global"
import path from "path" import path from "path"
@@ -32,7 +32,7 @@ export namespace Agent {
topP: z.number().optional(), topP: z.number().optional(),
temperature: z.number().optional(), temperature: z.number().optional(),
color: z.string().optional(), color: z.string().optional(),
permission: PermissionNext.Ruleset, permission: Permission.Ruleset,
model: z model: z
.object({ .object({
modelID: ModelID.zod, modelID: ModelID.zod,
@@ -54,7 +54,7 @@ export namespace Agent {
const skillDirs = await Skill.dirs() const skillDirs = await Skill.dirs()
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))] const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
const defaults = PermissionNext.fromConfig({ const defaults = Permission.fromConfig({
"*": "allow", "*": "allow",
doom_loop: "ask", doom_loop: "ask",
external_directory: { external_directory: {
@@ -72,16 +72,16 @@ export namespace Agent {
"*.env.example": "allow", "*.env.example": "allow",
}, },
}) })
const user = PermissionNext.fromConfig(cfg.permission ?? {}) const user = Permission.fromConfig(cfg.permission ?? {})
const result: Record<string, Info> = { const result: Record<string, Info> = {
build: { build: {
name: "build", name: "build",
description: "The default agent. Executes tools based on configured permissions.", description: "The default agent. Executes tools based on configured permissions.",
options: {}, options: {},
permission: PermissionNext.merge( permission: Permission.merge(
defaults, defaults,
PermissionNext.fromConfig({ Permission.fromConfig({
question: "allow", question: "allow",
plan_enter: "allow", plan_enter: "allow",
}), }),
@@ -94,9 +94,9 @@ export namespace Agent {
name: "plan", name: "plan",
description: "Plan mode. Disallows all edit tools.", description: "Plan mode. Disallows all edit tools.",
options: {}, options: {},
permission: PermissionNext.merge( permission: Permission.merge(
defaults, defaults,
PermissionNext.fromConfig({ Permission.fromConfig({
question: "allow", question: "allow",
plan_exit: "allow", plan_exit: "allow",
external_directory: { external_directory: {
@@ -116,9 +116,9 @@ export namespace Agent {
general: { general: {
name: "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.`, 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: PermissionNext.merge( permission: Permission.merge(
defaults, defaults,
PermissionNext.fromConfig({ Permission.fromConfig({
todoread: "deny", todoread: "deny",
todowrite: "deny", todowrite: "deny",
}), }),
@@ -130,9 +130,9 @@ export namespace Agent {
}, },
explore: { explore: {
name: "explore", name: "explore",
permission: PermissionNext.merge( permission: Permission.merge(
defaults, defaults,
PermissionNext.fromConfig({ Permission.fromConfig({
"*": "deny", "*": "deny",
grep: "allow", grep: "allow",
glob: "allow", glob: "allow",
@@ -161,9 +161,9 @@ export namespace Agent {
native: true, native: true,
hidden: true, hidden: true,
prompt: PROMPT_COMPACTION, prompt: PROMPT_COMPACTION,
permission: PermissionNext.merge( permission: Permission.merge(
defaults, defaults,
PermissionNext.fromConfig({ Permission.fromConfig({
"*": "deny", "*": "deny",
}), }),
user, user,
@@ -177,9 +177,9 @@ export namespace Agent {
native: true, native: true,
hidden: true, hidden: true,
temperature: 0.5, temperature: 0.5,
permission: PermissionNext.merge( permission: Permission.merge(
defaults, defaults,
PermissionNext.fromConfig({ Permission.fromConfig({
"*": "deny", "*": "deny",
}), }),
user, user,
@@ -192,9 +192,9 @@ export namespace Agent {
options: {}, options: {},
native: true, native: true,
hidden: true, hidden: true,
permission: PermissionNext.merge( permission: Permission.merge(
defaults, defaults,
PermissionNext.fromConfig({ Permission.fromConfig({
"*": "deny", "*": "deny",
}), }),
user, user,
@@ -213,7 +213,7 @@ export namespace Agent {
item = result[key] = { item = result[key] = {
name: key, name: key,
mode: "all", mode: "all",
permission: PermissionNext.merge(defaults, user), permission: Permission.merge(defaults, user),
options: {}, options: {},
native: false, native: false,
} }
@@ -229,7 +229,7 @@ export namespace Agent {
item.name = value.name ?? item.name item.name = value.name ?? item.name
item.steps = value.steps ?? item.steps item.steps = value.steps ?? item.steps
item.options = mergeDeep(item.options, value.options ?? {}) item.options = mergeDeep(item.options, value.options ?? {})
item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {})) item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
} }
// Ensure Truncate.GLOB is allowed unless explicitly configured // Ensure Truncate.GLOB is allowed unless explicitly configured
@@ -242,9 +242,9 @@ export namespace Agent {
}) })
if (explicit) continue if (explicit) continue
result[name].permission = PermissionNext.merge( result[name].permission = Permission.merge(
result[name].permission, result[name].permission,
PermissionNext.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }), Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
) )
} }
-94
View File
@@ -1,94 +0,0 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
export class Oauth extends Schema.Class<Oauth>("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<Api>("ApiAuth")({
type: Schema.Literal("api"),
key: Schema.String,
}) {}
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
type: Schema.Literal("wellknown"),
key: Schema.String,
token: Schema.String,
}) {}
export const Info = Schema.Union([Oauth, Api, WellKnown])
export type Info = Schema.Schema.Type<typeof Info>
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
const file = path.join(Global.Path.data, "auth.json")
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
export namespace Auth {
export interface Interface {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
readonly remove: (key: string) => Effect.Effect<void, AuthError>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("Auth.all")(() =>
Effect.tryPromise({
try: async () => {
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
},
catch: fail("Failed to read auth data"),
}),
)
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* Effect.tryPromise({
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
catch: 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* Effect.tryPromise({
try: () => Filesystem.writeJson(file, data, 0o600),
catch: fail("Failed to write auth data"),
})
})
return Service.of({ get, all, set, remove })
}),
)
}
+91 -33
View File
@@ -1,43 +1,101 @@
import { Effect } from "effect" import path from "path"
import z from "zod" import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
import { runtime } from "@/effect/runtime" import { makeRunPromise } from "@/effect/run-service"
import * as S from "./effect" import { zod } from "@/util/effect-zod"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
export { OAUTH_DUMMY_KEY } from "./effect" export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
function runPromise<A>(f: (service: S.Auth.Interface) => Effect.Effect<A, S.AuthError>) { const file = path.join(Global.Path.data, "auth.json")
return runtime.runPromise(S.Auth.Service.use(f))
} const fail = (message: string) => (cause: unknown) => new Auth.AuthError({ message, cause })
export namespace Auth { export namespace Auth {
export const Oauth = z export class Oauth extends Schema.Class<Oauth>("OAuth")({
.object({ type: Schema.Literal("oauth"),
type: z.literal("oauth"), refresh: Schema.String,
refresh: z.string(), access: Schema.String,
access: z.string(), expires: Schema.Number,
expires: z.number(), accountId: Schema.optional(Schema.String),
accountId: z.string().optional(), enterpriseUrl: Schema.optional(Schema.String),
enterpriseUrl: z.string().optional(), }) {}
})
.meta({ ref: "OAuth" })
export const Api = z export class Api extends Schema.Class<Api>("ApiAuth")({
.object({ type: Schema.Literal("api"),
type: z.literal("api"), key: Schema.String,
key: z.string(), }) {}
})
.meta({ ref: "ApiAuth" })
export const WellKnown = z export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
.object({ type: Schema.Literal("wellknown"),
type: z.literal("wellknown"), key: Schema.String,
key: z.string(), token: Schema.String,
token: z.string(), }) {}
})
.meta({ ref: "WellKnownAuth" })
export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" }) const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" })
export type Info = z.infer<typeof Info> export const Info = Object.assign(_Info, { zod: zod(_Info) })
export type Info = Schema.Schema.Type<typeof _Info>
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export interface Interface {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
readonly remove: (key: string) => Effect.Effect<void, AuthError>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("Auth.all")(() =>
Effect.tryPromise({
try: async () => {
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
},
catch: fail("Failed to read auth data"),
}),
)
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* Effect.tryPromise({
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
catch: 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* Effect.tryPromise({
try: () => Filesystem.writeJson(file, data, 0o600),
catch: fail("Failed to write auth data"),
})
})
return Service.of({ get, all, set, remove })
}),
)
const runPromise = makeRunPromise(Service, layer)
export async function get(providerID: string) { export async function get(providerID: string) {
return runPromise((service) => service.get(providerID)) return runPromise((service) => service.get(providerID))
+5 -6
View File
@@ -1,8 +1,7 @@
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { Duration, Effect, Match, Option } from "effect" import { Duration, Effect, Match, Option } from "effect"
import { UI } from "../ui" import { UI } from "../ui"
import { runtime } from "@/effect/runtime" import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account/effect"
import { type AccountError } from "@/account/schema" import { type AccountError } from "@/account/schema"
import * as Prompt from "../effect/prompt" import * as Prompt from "../effect/prompt"
import open from "open" import open from "open"
@@ -160,7 +159,7 @@ export const LoginCommand = cmd({
}), }),
async handler(args) { async handler(args) {
UI.empty() UI.empty()
await runtime.runPromise(loginEffect(args.url)) await Account.runPromise((_svc) => loginEffect(args.url))
}, },
}) })
@@ -174,7 +173,7 @@ export const LogoutCommand = cmd({
}), }),
async handler(args) { async handler(args) {
UI.empty() UI.empty()
await runtime.runPromise(logoutEffect(args.email)) await Account.runPromise((_svc) => logoutEffect(args.email))
}, },
}) })
@@ -183,7 +182,7 @@ export const SwitchCommand = cmd({
describe: false, describe: false,
async handler() { async handler() {
UI.empty() UI.empty()
await runtime.runPromise(switchEffect()) await Account.runPromise((_svc) => switchEffect())
}, },
}) })
@@ -192,7 +191,7 @@ export const OrgsCommand = cmd({
describe: false, describe: false,
async handler() { async handler() {
UI.empty() UI.empty()
await runtime.runPromise(orgsEffect()) await Account.runPromise((_svc) => orgsEffect())
}, },
}) })
+6 -6
View File
@@ -7,7 +7,7 @@ import type { MessageV2 } from "../../../session/message-v2"
import { MessageID, PartID } from "../../../session/schema" import { MessageID, PartID } from "../../../session/schema"
import { ToolRegistry } from "../../../tool/registry" import { ToolRegistry } from "../../../tool/registry"
import { Instance } from "../../../project/instance" import { Instance } from "../../../project/instance"
import { PermissionNext } from "../../../permission" import { Permission } from "../../../permission"
import { iife } from "../../../util/iife" import { iife } from "../../../util/iife"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd" import { cmd } from "../cmd"
@@ -75,7 +75,7 @@ async function getAvailableTools(agent: Agent.Info) {
} }
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) { async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
const disabled = PermissionNext.disabled( const disabled = Permission.disabled(
availableTools.map((tool) => tool.id), availableTools.map((tool) => tool.id),
agent.permission, agent.permission,
) )
@@ -145,7 +145,7 @@ async function createToolContext(agent: Agent.Info) {
} }
await Session.updateMessage(message) await Session.updateMessage(message)
const ruleset = PermissionNext.merge(agent.permission, session.permission ?? []) const ruleset = Permission.merge(agent.permission, session.permission ?? [])
return { return {
sessionID: session.id, sessionID: session.id,
@@ -155,11 +155,11 @@ async function createToolContext(agent: Agent.Info) {
abort: new AbortController().signal, abort: new AbortController().signal,
messages: [], messages: [],
metadata: () => {}, metadata: () => {},
async ask(req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) { async ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
for (const pattern of req.patterns) { for (const pattern of req.patterns) {
const rule = PermissionNext.evaluate(req.permission, pattern, ruleset) const rule = Permission.evaluate(req.permission, pattern, ruleset)
if (rule.action === "deny") { if (rule.action === "deny") {
throw new PermissionNext.DeniedError({ ruleset }) throw new Permission.DeniedError({ ruleset })
} }
} }
}, },
+2 -2
View File
@@ -11,7 +11,7 @@ import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart
import { Server } from "../../server/server" import { Server } from "../../server/server"
import { Provider } from "../../provider/provider" import { Provider } from "../../provider/provider"
import { Agent } from "../../agent/agent" import { Agent } from "../../agent/agent"
import { PermissionNext } from "../../permission" import { Permission } from "../../permission"
import { Tool } from "../../tool/tool" import { Tool } from "../../tool/tool"
import { GlobTool } from "../../tool/glob" import { GlobTool } from "../../tool/glob"
import { GrepTool } from "../../tool/grep" import { GrepTool } from "../../tool/grep"
@@ -354,7 +354,7 @@ export const RunCommand = cmd({
process.exit(1) process.exit(1)
} }
const rules: PermissionNext.Ruleset = [ const rules: Permission.Ruleset = [
{ {
permission: "question", permission: "question",
action: "deny", action: "deny",
@@ -1667,6 +1667,7 @@ function InlineTool(props: {
const denied = createMemo( const denied = createMemo(
() => () =>
error()?.includes("QuestionRejectedError") ||
error()?.includes("rejected permission") || error()?.includes("rejected permission") ||
error()?.includes("specified a rule") || error()?.includes("specified a rule") ||
error()?.includes("user dismissed"), error()?.includes("user dismissed"),
+120 -86
View File
@@ -1,15 +1,23 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { SessionID, MessageID } from "@/session/schema" import { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, ServiceMap } from "effect"
import z from "zod" import z from "zod"
import { Config } from "../config/config" import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { Identifier } from "../id/id"
import PROMPT_INITIALIZE from "./template/initialize.txt"
import PROMPT_REVIEW from "./template/review.txt"
import { MCP } from "../mcp" import { MCP } from "../mcp"
import { Skill } from "../skill" import { Skill } from "../skill"
import { Log } from "../util/log"
import PROMPT_INITIALIZE from "./template/initialize.txt"
import PROMPT_REVIEW from "./template/review.txt"
export namespace Command { export namespace Command {
const log = Log.create({ service: "command" })
type State = {
commands: Record<string, Info>
}
export const Event = { export const Event = {
Executed: BusEvent.define( Executed: BusEvent.define(
"command.executed", "command.executed",
@@ -42,7 +50,7 @@ export namespace Command {
// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it // for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
export type Info = Omit<z.infer<typeof Info>, "template"> & { template: Promise<string> | string } export type Info = Omit<z.infer<typeof Info>, "template"> & { template: Promise<string> | string }
export function hints(template: string): string[] { export function hints(template: string) {
const result: string[] = [] const result: string[] = []
const numbered = template.match(/\$\d+/g) const numbered = template.match(/\$\d+/g)
if (numbered) { if (numbered) {
@@ -57,95 +65,121 @@ export namespace Command {
REVIEW: "review", REVIEW: "review",
} as const } as const
const state = Instance.state(async () => { export interface Interface {
const cfg = await Config.get() readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]>
}
const result: Record<string, Info> = { export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Command") {}
[Default.INIT]: {
name: Default.INIT,
description: "create/update AGENTS.md",
source: "command",
get template() {
return PROMPT_INITIALIZE.replace("${path}", Instance.worktree)
},
hints: hints(PROMPT_INITIALIZE),
},
[Default.REVIEW]: {
name: Default.REVIEW,
description: "review changes [commit|branch|pr], defaults to uncommitted",
source: "command",
get template() {
return PROMPT_REVIEW.replace("${path}", Instance.worktree)
},
subtask: true,
hints: hints(PROMPT_REVIEW),
},
}
for (const [name, command] of Object.entries(cfg.command ?? {})) { export const layer = Layer.effect(
result[name] = { Service,
name, Effect.gen(function* () {
agent: command.agent, const init = Effect.fn("Command.state")(function* (ctx) {
model: command.model, const cfg = yield* Effect.promise(() => Config.get())
description: command.description, const commands: Record<string, Info> = {}
source: "command",
get template() {
return command.template
},
subtask: command.subtask,
hints: hints(command.template),
}
}
for (const [name, prompt] of Object.entries(await MCP.prompts())) {
result[name] = {
name,
source: "mcp",
description: prompt.description,
get template() {
// since a getter can't be async we need to manually return a promise here
return new Promise<string>(async (resolve, reject) => {
const template = await MCP.getPrompt(
prompt.client,
prompt.name,
prompt.arguments
? // substitute each argument with $1, $2, etc.
Object.fromEntries(prompt.arguments?.map((argument, i) => [argument.name, `$${i + 1}`]))
: {},
).catch(reject)
resolve(
template?.messages
.map((message) => (message.content.type === "text" ? message.content.text : ""))
.join("\n") || "",
)
})
},
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
}
}
// Add skills as invokable commands commands[Default.INIT] = {
for (const skill of await Skill.all()) { name: Default.INIT,
// Skip if a command with this name already exists description: "create/update AGENTS.md",
if (result[skill.name]) continue source: "command",
result[skill.name] = { get template() {
name: skill.name, return PROMPT_INITIALIZE.replace("${path}", ctx.worktree)
description: skill.description, },
source: "skill", hints: hints(PROMPT_INITIALIZE),
get template() { }
return skill.content commands[Default.REVIEW] = {
}, name: Default.REVIEW,
hints: [], description: "review changes [commit|branch|pr], defaults to uncommitted",
} source: "command",
} get template() {
return PROMPT_REVIEW.replace("${path}", ctx.worktree)
},
subtask: true,
hints: hints(PROMPT_REVIEW),
}
return result for (const [name, command] of Object.entries(cfg.command ?? {})) {
}) commands[name] = {
name,
agent: command.agent,
model: command.model,
description: command.description,
source: "command",
get template() {
return command.template
},
subtask: command.subtask,
hints: hints(command.template),
}
}
for (const [name, prompt] of Object.entries(yield* Effect.promise(() => MCP.prompts()))) {
commands[name] = {
name,
source: "mcp",
description: prompt.description,
get template() {
return new Promise<string>(async (resolve, reject) => {
const template = await MCP.getPrompt(
prompt.client,
prompt.name,
prompt.arguments
? Object.fromEntries(prompt.arguments.map((argument, i) => [argument.name, `$${i + 1}`]))
: {},
).catch(reject)
resolve(
template?.messages
.map((message) => (message.content.type === "text" ? message.content.text : ""))
.join("\n") || "",
)
})
},
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
}
}
for (const skill of yield* Effect.promise(() => Skill.all())) {
if (commands[skill.name]) continue
commands[skill.name] = {
name: skill.name,
description: skill.description,
source: "skill",
get template() {
return skill.content
},
hints: [],
}
}
return {
commands,
}
})
const cache = yield* InstanceState.make<State>((ctx) => init(ctx))
const get = Effect.fn("Command.get")(function* (name: string) {
const state = yield* InstanceState.get(cache)
return state.commands[name]
})
const list = Effect.fn("Command.list")(function* () {
const state = yield* InstanceState.get(cache)
return Object.values(state.commands)
})
return Service.of({ get, list })
}),
)
const runPromise = makeRunPromise(Service, layer)
export async function get(name: string) { export async function get(name: string) {
return state().then((x) => x[name]) return runPromise((svc) => svc.get(name))
} }
export async function list() { export async function list() {
return state().then((x) => Object.values(x)) return runPromise((svc) => svc.list())
} }
} }
+1 -1
View File
@@ -177,7 +177,7 @@ export namespace Config {
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
} }
const active = Account.active() const active = await Account.active()
if (active?.active_org_id) { if (active?.active_org_id) {
try { try {
const [config, token] = await Promise.all([ const [config, token] = await Promise.all([
@@ -0,0 +1,47 @@
import { Effect, ScopedCache, Scope } from "effect"
import { Instance, type Shape } from "@/project/instance"
import { registerDisposer } from "./instance-registry"
const TypeId = "~opencode/InstanceState"
export interface InstanceState<A, E = never, R = never> {
readonly [TypeId]: typeof TypeId
readonly cache: ScopedCache.ScopedCache<string, A, E, R>
}
export namespace InstanceState {
export const make = <A, E = never, R = never>(
init: (ctx: Shape) => Effect.Effect<A, E, R | Scope.Scope>,
): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
Effect.gen(function* () {
const cache = yield* ScopedCache.make<string, A, E, R>({
capacity: Number.POSITIVE_INFINITY,
lookup: () => init(Instance.current),
})
const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
yield* Effect.addFinalizer(() => Effect.sync(off))
return {
[TypeId]: TypeId,
cache,
}
})
export const get = <A, E, R>(self: InstanceState<A, E, R>) =>
Effect.suspend(() => ScopedCache.get(self.cache, Instance.directory))
export const use = <A, E, R, B>(self: InstanceState<A, E, R>, select: (value: A) => B) =>
Effect.map(get(self), select)
export const useEffect = <A, E, R, B, E2, R2>(
self: InstanceState<A, E, R>,
select: (value: A) => Effect.Effect<B, E2, R2>,
) => Effect.flatMap(get(self), select)
export const has = <A, E, R>(self: InstanceState<A, E, R>) =>
Effect.suspend(() => ScopedCache.has(self.cache, Instance.directory))
export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
Effect.suspend(() => ScopedCache.invalidate(self.cache, Instance.directory))
}
-68
View File
@@ -1,68 +0,0 @@
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
import { File } from "@/file/service"
import { FileTime } from "@/file/time-service"
import { FileWatcher } from "@/file/watcher"
import { Format } from "@/format/service"
import { Permission } from "@/permission/service"
import { Instance } from "@/project/instance"
import { Vcs } from "@/project/vcs"
import { ProviderAuth } from "@/provider/auth-service"
import { Question } from "@/question/service"
import { Skill } from "@/skill/service"
import { Snapshot } from "@/snapshot/service"
import { InstanceContext } from "./instance-context"
import { registerDisposer } from "./instance-registry"
export { InstanceContext } from "./instance-context"
export type InstanceServices =
| Question.Service
| Permission.Service
| ProviderAuth.Service
| FileWatcher.Service
| Vcs.Service
| FileTime.Service
| Format.Service
| File.Service
| Skill.Service
| Snapshot.Service
// NOTE: LayerMap only passes the key (directory string) to lookup, but we need
// the full instance context (directory, worktree, project). We read from the
// legacy Instance ALS here, which is safe because lookup is only triggered via
// runPromiseInstance -> Instances.get, which always runs inside Instance.provide.
// This should go away once the old Instance type is removed and lookup can load
// the full context directly.
function lookup(_key: string) {
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
return Layer.mergeAll(
Question.layer,
Permission.layer,
ProviderAuth.defaultLayer,
FileWatcher.layer,
Vcs.layer,
FileTime.layer,
Format.layer,
File.layer,
Skill.defaultLayer,
Snapshot.defaultLayer,
).pipe(Layer.provide(ctx))
}
export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<string, InstanceServices>>()(
"opencode/Instances",
) {
static readonly layer = Layer.effect(
Instances,
Effect.gen(function* () {
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
yield* Effect.addFinalizer(() => Effect.sync(unregister))
return Instances.of(layerMap)
}),
)
static get(directory: string): Layer.Layer<InstanceServices, never, Instances> {
return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory))))
}
}
@@ -0,0 +1,13 @@
import { Effect, Layer, ManagedRuntime } from "effect"
import * as ServiceMap from "effect/ServiceMap"
export const memoMap = Layer.makeMemoMapUnsafe()
export function makeRunPromise<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
return <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) => {
rt ??= ManagedRuntime.make(layer, { memoMap })
return rt.runPromise(service.use(fn), options)
}
}
-25
View File
@@ -1,25 +0,0 @@
import { Effect, Layer, ManagedRuntime } from "effect"
import { Account } from "@/account/effect"
import { Auth } from "@/auth/effect"
import { Instances } from "@/effect/instances"
import type { InstanceServices } from "@/effect/instances"
import { Installation } from "@/installation"
import { Truncate } from "@/tool/truncate-effect"
import { Instance } from "@/project/instance"
export const runtime = ManagedRuntime.make(
Layer.mergeAll(
Account.defaultLayer, //
Installation.defaultLayer,
Truncate.defaultLayer,
Instances.layer,
).pipe(Layer.provideMerge(Auth.layer)),
)
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
}
export function disposeRuntime() {
return runtime.dispose()
}
+689 -17
View File
@@ -1,40 +1,712 @@
import { runPromiseInstance } from "@/effect/runtime" import { BusEvent } from "@/bus/bus-event"
import { File as S } from "./service" import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { git } from "@/util/git"
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
import { formatPatch, structuredPatch } from "diff"
import fs from "fs"
import fuzzysort from "fuzzysort"
import ignore from "ignore"
import path from "path"
import z from "zod"
import { Global } from "../global"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
import { Protected } from "./protected"
import { Ripgrep } from "./ripgrep"
export namespace File { export namespace File {
export const Info = S.Info export const Info = z
export type Info = S.Info .object({
path: z.string(),
added: z.number().int(),
removed: z.number().int(),
status: z.enum(["added", "deleted", "modified"]),
})
.meta({
ref: "File",
})
export const Node = S.Node export type Info = z.infer<typeof Info>
export type Node = S.Node
export const Content = S.Content export const Node = z
export type Content = S.Content .object({
name: z.string(),
path: z.string(),
absolute: z.string(),
type: z.enum(["file", "directory"]),
ignored: z.boolean(),
})
.meta({
ref: "FileNode",
})
export type Node = z.infer<typeof Node>
export const Event = S.Event export const Content = z
.object({
type: z.enum(["text", "binary"]),
content: z.string(),
diff: z.string().optional(),
patch: z
.object({
oldFileName: z.string(),
newFileName: z.string(),
oldHeader: z.string().optional(),
newHeader: z.string().optional(),
hunks: z.array(
z.object({
oldStart: z.number(),
oldLines: z.number(),
newStart: z.number(),
newLines: z.number(),
lines: z.array(z.string()),
}),
),
index: z.string().optional(),
})
.optional(),
encoding: z.literal("base64").optional(),
mimeType: z.string().optional(),
})
.meta({
ref: "FileContent",
})
export type Content = z.infer<typeof Content>
export type Interface = S.Interface export const Event = {
Edited: BusEvent.define(
"file.edited",
z.object({
file: z.string(),
}),
),
}
export const Service = S.Service const log = Log.create({ service: "file" })
export const layer = S.layer
const binary = new Set([
"exe",
"dll",
"pdb",
"bin",
"so",
"dylib",
"o",
"a",
"lib",
"wav",
"mp3",
"ogg",
"oga",
"ogv",
"ogx",
"flac",
"aac",
"wma",
"m4a",
"weba",
"mp4",
"avi",
"mov",
"wmv",
"flv",
"webm",
"mkv",
"zip",
"tar",
"gz",
"gzip",
"bz",
"bz2",
"bzip",
"bzip2",
"7z",
"rar",
"xz",
"lz",
"z",
"pdf",
"doc",
"docx",
"ppt",
"pptx",
"xls",
"xlsx",
"dmg",
"iso",
"img",
"vmdk",
"ttf",
"otf",
"woff",
"woff2",
"eot",
"sqlite",
"db",
"mdb",
"apk",
"ipa",
"aab",
"xapk",
"app",
"pkg",
"deb",
"rpm",
"snap",
"flatpak",
"appimage",
"msi",
"msp",
"jar",
"war",
"ear",
"class",
"kotlin_module",
"dex",
"vdex",
"odex",
"oat",
"art",
"wasm",
"wat",
"bc",
"ll",
"s",
"ko",
"sys",
"drv",
"efi",
"rom",
"com",
])
const image = new Set([
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"webp",
"ico",
"tif",
"tiff",
"svg",
"svgz",
"avif",
"apng",
"jxl",
"heic",
"heif",
"raw",
"cr2",
"nef",
"arw",
"dng",
"orf",
"raf",
"pef",
"x3f",
])
const text = new Set([
"ts",
"tsx",
"mts",
"cts",
"mtsx",
"ctsx",
"js",
"jsx",
"mjs",
"cjs",
"sh",
"bash",
"zsh",
"fish",
"ps1",
"psm1",
"cmd",
"bat",
"json",
"jsonc",
"json5",
"yaml",
"yml",
"toml",
"md",
"mdx",
"txt",
"xml",
"html",
"htm",
"css",
"scss",
"sass",
"less",
"graphql",
"gql",
"sql",
"ini",
"cfg",
"conf",
"env",
])
const textName = new Set([
"dockerfile",
"makefile",
".gitignore",
".gitattributes",
".editorconfig",
".npmrc",
".nvmrc",
".prettierrc",
".eslintrc",
])
const mime: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
bmp: "image/bmp",
webp: "image/webp",
ico: "image/x-icon",
tif: "image/tiff",
tiff: "image/tiff",
svg: "image/svg+xml",
svgz: "image/svg+xml",
avif: "image/avif",
apng: "image/apng",
jxl: "image/jxl",
heic: "image/heic",
heif: "image/heif",
}
type Entry = { files: string[]; dirs: string[] }
const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
const name = (file: string) => path.basename(file).toLowerCase()
const isImageByExtension = (file: string) => image.has(ext(file))
const isTextByExtension = (file: string) => text.has(ext(file))
const isTextByName = (file: string) => textName.has(name(file))
const isBinaryByExtension = (file: string) => binary.has(ext(file))
const isImage = (mimeType: string) => mimeType.startsWith("image/")
const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
function shouldEncode(mimeType: string) {
const type = mimeType.toLowerCase()
log.debug("shouldEncode", { type })
if (!type) return false
if (type.startsWith("text/")) return false
if (type.includes("charset=")) return false
const top = type.split("/", 2)[0]
return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
}
const hidden = (item: string) => {
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
}
const sortHiddenLast = (items: string[], prefer: boolean) => {
if (prefer) return items
const visible: string[] = []
const hiddenItems: string[] = []
for (const item of items) {
if (hidden(item)) hiddenItems.push(item)
else visible.push(item)
}
return [...visible, ...hiddenItems]
}
interface State {
cache: Entry
fiber: Fiber.Fiber<void> | undefined
}
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<File.Info[]>
readonly read: (file: string) => Effect.Effect<File.Content>
readonly list: (dir?: string) => Effect.Effect<File.Node[]>
readonly search: (input: {
query: string
limit?: number
dirs?: boolean
type?: "file" | "directory"
}) => Effect.Effect<string[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("File.state")(() =>
Effect.succeed({
cache: { files: [], dirs: [] } as Entry,
fiber: undefined as Fiber.Fiber<void> | undefined,
}),
),
)
const scan = Effect.fn("File.scan")(function* () {
if (Instance.directory === path.parse(Instance.directory).root) return
const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
const next: Entry = { files: [], dirs: [] }
yield* Effect.promise(async () => {
if (isGlobalHome) {
const dirs = new Set<string>()
const protectedNames = Protected.names()
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
const top = await fs.promises
.readdir(Instance.directory, { withFileTypes: true })
.catch(() => [] as fs.Dirent[])
for (const entry of top) {
if (!entry.isDirectory()) continue
if (shouldIgnoreName(entry.name)) continue
dirs.add(entry.name + "/")
const base = path.join(Instance.directory, entry.name)
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
for (const child of children) {
if (!child.isDirectory()) continue
if (shouldIgnoreNested(child.name)) continue
dirs.add(entry.name + "/" + child.name + "/")
}
}
next.dirs = Array.from(dirs).toSorted()
} else {
const seen = new Set<string>()
for await (const file of Ripgrep.files({ cwd: Instance.directory })) {
next.files.push(file)
let current = file
while (true) {
const dir = path.dirname(current)
if (dir === ".") break
if (dir === current) break
current = dir
if (seen.has(dir)) continue
seen.add(dir)
next.dirs.push(dir + "/")
}
}
}
})
const s = yield* InstanceState.get(state)
s.cache = next
})
const scope = yield* Scope.Scope
const ensure = Effect.fn("File.ensure")(function* () {
const s = yield* InstanceState.get(state)
if (!s.fiber)
s.fiber = yield* scan().pipe(
Effect.catchCause(() => Effect.void),
Effect.ensuring(
Effect.sync(() => {
s.fiber = undefined
}),
),
Effect.forkIn(scope),
)
yield* Fiber.join(s.fiber)
})
const init = Effect.fn("File.init")(function* () {
yield* ensure()
})
const status = Effect.fn("File.status")(function* () {
if (Instance.project.vcs !== "git") return []
return yield* Effect.promise(async () => {
const diffOutput = (
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
cwd: Instance.directory,
})
).text()
const changed: File.Info[] = []
if (diffOutput.trim()) {
for (const line of diffOutput.trim().split("\n")) {
const [added, removed, file] = line.split("\t")
changed.push({
path: file,
added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified",
})
}
}
const untrackedOutput = (
await git(
[
"-c",
"core.fsmonitor=false",
"-c",
"core.quotepath=false",
"ls-files",
"--others",
"--exclude-standard",
],
{
cwd: Instance.directory,
},
)
).text()
if (untrackedOutput.trim()) {
for (const file of untrackedOutput.trim().split("\n")) {
try {
const content = await Filesystem.readText(path.join(Instance.directory, file))
changed.push({
path: file,
added: content.split("\n").length,
removed: 0,
status: "added",
})
} catch {
continue
}
}
}
const deletedOutput = (
await git(
[
"-c",
"core.fsmonitor=false",
"-c",
"core.quotepath=false",
"diff",
"--name-only",
"--diff-filter=D",
"HEAD",
],
{
cwd: Instance.directory,
},
)
).text()
if (deletedOutput.trim()) {
for (const file of deletedOutput.trim().split("\n")) {
changed.push({
path: file,
added: 0,
removed: 0,
status: "deleted",
})
}
}
return changed.map((item) => {
const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
return {
...item,
path: path.relative(Instance.directory, full),
}
})
})
})
const read = Effect.fn("File.read")(function* (file: string) {
return yield* Effect.promise(async (): Promise<File.Content> => {
using _ = log.time("read", { file })
const full = path.join(Instance.directory, file)
if (!Instance.containsPath(full)) {
throw new Error("Access denied: path escapes project directory")
}
if (isImageByExtension(file)) {
if (await Filesystem.exists(full)) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
return {
type: "text",
content: buffer.toString("base64"),
mimeType: getImageMimeType(file),
encoding: "base64",
}
}
return { type: "text", content: "" }
}
const knownText = isTextByExtension(file) || isTextByName(file)
if (isBinaryByExtension(file) && !knownText) {
return { type: "binary", content: "" }
}
if (!(await Filesystem.exists(full))) {
return { type: "text", content: "" }
}
const mimeType = Filesystem.mimeType(full)
const encode = knownText ? false : shouldEncode(mimeType)
if (encode && !isImage(mimeType)) {
return { type: "binary", content: "", mimeType }
}
if (encode) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
return {
type: "text",
content: buffer.toString("base64"),
mimeType,
encoding: "base64",
}
}
const content = (await Filesystem.readText(full).catch(() => "")).trim()
if (Instance.project.vcs === "git") {
let diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
).text()
if (!diff.trim()) {
diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
cwd: Instance.directory,
})
).text()
}
if (diff.trim()) {
const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,
})
return {
type: "text",
content,
patch,
diff: formatPatch(patch),
}
}
}
return { type: "text", content }
})
})
const list = Effect.fn("File.list")(function* (dir?: string) {
return yield* Effect.promise(async () => {
const exclude = [".git", ".DS_Store"]
let ignored = (_: string) => false
if (Instance.project.vcs === "git") {
const ig = ignore()
const gitignore = path.join(Instance.project.worktree, ".gitignore")
if (await Filesystem.exists(gitignore)) {
ig.add(await Filesystem.readText(gitignore))
}
const ignoreFile = path.join(Instance.project.worktree, ".ignore")
if (await Filesystem.exists(ignoreFile)) {
ig.add(await Filesystem.readText(ignoreFile))
}
ignored = ig.ignores.bind(ig)
}
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
if (!Instance.containsPath(resolved)) {
throw new Error("Access denied: path escapes project directory")
}
const nodes: File.Node[] = []
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
if (exclude.includes(entry.name)) continue
const absolute = path.join(resolved, entry.name)
const file = path.relative(Instance.directory, absolute)
const type = entry.isDirectory() ? "directory" : "file"
nodes.push({
name: entry.name,
path: file,
absolute,
type,
ignored: ignored(type === "directory" ? file + "/" : file),
})
}
return nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
return a.name.localeCompare(b.name)
})
})
})
const search = Effect.fn("File.search")(function* (input: {
query: string
limit?: number
dirs?: boolean
type?: "file" | "directory"
}) {
yield* ensure()
const { cache } = yield* InstanceState.get(state)
return yield* Effect.promise(async () => {
const query = input.query.trim()
const limit = input.limit ?? 100
const kind = input.type ?? (input.dirs === false ? "file" : "all")
log.info("search", { query, kind })
const result = cache
const preferHidden = query.startsWith(".") || query.includes("/.")
if (!query) {
if (kind === "file") return result.files.slice(0, limit)
return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
}
const items =
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
log.info("search", { query, kind, results: output.length })
return output
})
})
log.info("init")
return Service.of({ init, status, read, list, search })
}),
)
const runPromise = makeRunPromise(Service, layer)
export function init() { export function init() {
return runPromiseInstance(S.Service.use((svc) => svc.init())) return runPromise((svc) => svc.init())
} }
export async function status() { export async function status() {
return runPromiseInstance(S.Service.use((svc) => svc.status())) return runPromise((svc) => svc.status())
} }
export async function read(file: string): Promise<Content> { export async function read(file: string): Promise<Content> {
return runPromiseInstance(S.Service.use((svc) => svc.read(file))) return runPromise((svc) => svc.read(file))
} }
export async function list(dir?: string) { export async function list(dir?: string) {
return runPromiseInstance(S.Service.use((svc) => svc.list(dir))) return runPromise((svc) => svc.list(dir))
} }
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
return runPromiseInstance(S.Service.use((svc) => svc.search(input))) return runPromise((svc) => svc.search(input))
} }
} }
-674
View File
@@ -1,674 +0,0 @@
import { BusEvent } from "@/bus/bus-event"
import { InstanceContext } from "@/effect/instance-context"
import { git } from "@/util/git"
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
import { formatPatch, structuredPatch } from "diff"
import fs from "fs"
import fuzzysort from "fuzzysort"
import ignore from "ignore"
import path from "path"
import z from "zod"
import { Global } from "../global"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
import { Protected } from "./protected"
import { Ripgrep } from "./ripgrep"
export namespace File {
export const Info = z
.object({
path: z.string(),
added: z.number().int(),
removed: z.number().int(),
status: z.enum(["added", "deleted", "modified"]),
})
.meta({
ref: "File",
})
export type Info = z.infer<typeof Info>
export const Node = z
.object({
name: z.string(),
path: z.string(),
absolute: z.string(),
type: z.enum(["file", "directory"]),
ignored: z.boolean(),
})
.meta({
ref: "FileNode",
})
export type Node = z.infer<typeof Node>
export const Content = z
.object({
type: z.enum(["text", "binary"]),
content: z.string(),
diff: z.string().optional(),
patch: z
.object({
oldFileName: z.string(),
newFileName: z.string(),
oldHeader: z.string().optional(),
newHeader: z.string().optional(),
hunks: z.array(
z.object({
oldStart: z.number(),
oldLines: z.number(),
newStart: z.number(),
newLines: z.number(),
lines: z.array(z.string()),
}),
),
index: z.string().optional(),
})
.optional(),
encoding: z.literal("base64").optional(),
mimeType: z.string().optional(),
})
.meta({
ref: "FileContent",
})
export type Content = z.infer<typeof Content>
export const Event = {
Edited: BusEvent.define(
"file.edited",
z.object({
file: z.string(),
}),
),
}
const log = Log.create({ service: "file" })
const binary = new Set([
"exe",
"dll",
"pdb",
"bin",
"so",
"dylib",
"o",
"a",
"lib",
"wav",
"mp3",
"ogg",
"oga",
"ogv",
"ogx",
"flac",
"aac",
"wma",
"m4a",
"weba",
"mp4",
"avi",
"mov",
"wmv",
"flv",
"webm",
"mkv",
"zip",
"tar",
"gz",
"gzip",
"bz",
"bz2",
"bzip",
"bzip2",
"7z",
"rar",
"xz",
"lz",
"z",
"pdf",
"doc",
"docx",
"ppt",
"pptx",
"xls",
"xlsx",
"dmg",
"iso",
"img",
"vmdk",
"ttf",
"otf",
"woff",
"woff2",
"eot",
"sqlite",
"db",
"mdb",
"apk",
"ipa",
"aab",
"xapk",
"app",
"pkg",
"deb",
"rpm",
"snap",
"flatpak",
"appimage",
"msi",
"msp",
"jar",
"war",
"ear",
"class",
"kotlin_module",
"dex",
"vdex",
"odex",
"oat",
"art",
"wasm",
"wat",
"bc",
"ll",
"s",
"ko",
"sys",
"drv",
"efi",
"rom",
"com",
"cmd",
"ps1",
"sh",
"bash",
"zsh",
"fish",
])
const image = new Set([
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"webp",
"ico",
"tif",
"tiff",
"svg",
"svgz",
"avif",
"apng",
"jxl",
"heic",
"heif",
"raw",
"cr2",
"nef",
"arw",
"dng",
"orf",
"raf",
"pef",
"x3f",
])
const text = new Set([
"ts",
"tsx",
"mts",
"cts",
"mtsx",
"ctsx",
"js",
"jsx",
"mjs",
"cjs",
"sh",
"bash",
"zsh",
"fish",
"ps1",
"psm1",
"cmd",
"bat",
"json",
"jsonc",
"json5",
"yaml",
"yml",
"toml",
"md",
"mdx",
"txt",
"xml",
"html",
"htm",
"css",
"scss",
"sass",
"less",
"graphql",
"gql",
"sql",
"ini",
"cfg",
"conf",
"env",
])
const textName = new Set([
"dockerfile",
"makefile",
".gitignore",
".gitattributes",
".editorconfig",
".npmrc",
".nvmrc",
".prettierrc",
".eslintrc",
])
const mime: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
bmp: "image/bmp",
webp: "image/webp",
ico: "image/x-icon",
tif: "image/tiff",
tiff: "image/tiff",
svg: "image/svg+xml",
svgz: "image/svg+xml",
avif: "image/avif",
apng: "image/apng",
jxl: "image/jxl",
heic: "image/heic",
heif: "image/heif",
}
type Entry = { files: string[]; dirs: string[] }
const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
const name = (file: string) => path.basename(file).toLowerCase()
const isImageByExtension = (file: string) => image.has(ext(file))
const isTextByExtension = (file: string) => text.has(ext(file))
const isTextByName = (file: string) => textName.has(name(file))
const isBinaryByExtension = (file: string) => binary.has(ext(file))
const isImage = (mimeType: string) => mimeType.startsWith("image/")
const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
function shouldEncode(mimeType: string) {
const type = mimeType.toLowerCase()
log.info("shouldEncode", { type })
if (!type) return false
if (type.startsWith("text/")) return false
if (type.includes("charset=")) return false
const top = type.split("/", 2)[0]
return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
}
const hidden = (item: string) => {
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
}
const sortHiddenLast = (items: string[], prefer: boolean) => {
if (prefer) return items
const visible: string[] = []
const hiddenItems: string[] = []
for (const item of items) {
if (hidden(item)) hiddenItems.push(item)
else visible.push(item)
}
return [...visible, ...hiddenItems]
}
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<File.Info[]>
readonly read: (file: string) => Effect.Effect<File.Content>
readonly list: (dir?: string) => Effect.Effect<File.Node[]>
readonly search: (input: {
query: string
limit?: number
dirs?: boolean
type?: "file" | "directory"
}) => Effect.Effect<string[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
let cache: Entry = { files: [], dirs: [] }
const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
const scan = Effect.fn("File.scan")(function* () {
if (instance.directory === path.parse(instance.directory).root) return
const next: Entry = { files: [], dirs: [] }
yield* Effect.promise(async () => {
if (isGlobalHome) {
const dirs = new Set<string>()
const protectedNames = Protected.names()
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
const top = await fs.promises
.readdir(instance.directory, { withFileTypes: true })
.catch(() => [] as fs.Dirent[])
for (const entry of top) {
if (!entry.isDirectory()) continue
if (shouldIgnoreName(entry.name)) continue
dirs.add(entry.name + "/")
const base = path.join(instance.directory, entry.name)
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
for (const child of children) {
if (!child.isDirectory()) continue
if (shouldIgnoreNested(child.name)) continue
dirs.add(entry.name + "/" + child.name + "/")
}
}
next.dirs = Array.from(dirs).toSorted()
} else {
const seen = new Set<string>()
for await (const file of Ripgrep.files({ cwd: instance.directory })) {
next.files.push(file)
let current = file
while (true) {
const dir = path.dirname(current)
if (dir === ".") break
if (dir === current) break
current = dir
if (seen.has(dir)) continue
seen.add(dir)
next.dirs.push(dir + "/")
}
}
}
})
cache = next
})
const getFiles = () => cache
const scope = yield* Scope.Scope
let fiber: Fiber.Fiber<void> | undefined
const init = Effect.fn("File.init")(function* () {
if (!fiber) {
fiber = yield* scan().pipe(
Effect.catchCause(() => Effect.void),
Effect.forkIn(scope),
)
}
yield* Fiber.join(fiber)
})
const status = Effect.fn("File.status")(function* () {
if (instance.project.vcs !== "git") return []
return yield* Effect.promise(async () => {
const diffOutput = (
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
cwd: instance.directory,
})
).text()
const changed: File.Info[] = []
if (diffOutput.trim()) {
for (const line of diffOutput.trim().split("\n")) {
const [added, removed, file] = line.split("\t")
changed.push({
path: file,
added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified",
})
}
}
const untrackedOutput = (
await git(
[
"-c",
"core.fsmonitor=false",
"-c",
"core.quotepath=false",
"ls-files",
"--others",
"--exclude-standard",
],
{
cwd: instance.directory,
},
)
).text()
if (untrackedOutput.trim()) {
for (const file of untrackedOutput.trim().split("\n")) {
try {
const content = await Filesystem.readText(path.join(instance.directory, file))
changed.push({
path: file,
added: content.split("\n").length,
removed: 0,
status: "added",
})
} catch {
continue
}
}
}
const deletedOutput = (
await git(
[
"-c",
"core.fsmonitor=false",
"-c",
"core.quotepath=false",
"diff",
"--name-only",
"--diff-filter=D",
"HEAD",
],
{
cwd: instance.directory,
},
)
).text()
if (deletedOutput.trim()) {
for (const file of deletedOutput.trim().split("\n")) {
changed.push({
path: file,
added: 0,
removed: 0,
status: "deleted",
})
}
}
return changed.map((item) => {
const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
return {
...item,
path: path.relative(instance.directory, full),
}
})
})
})
const read = Effect.fn("File.read")(function* (file: string) {
return yield* Effect.promise(async (): Promise<File.Content> => {
using _ = log.time("read", { file })
const full = path.join(instance.directory, file)
if (!Instance.containsPath(full)) {
throw new Error("Access denied: path escapes project directory")
}
if (isImageByExtension(file)) {
if (await Filesystem.exists(full)) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
return {
type: "text",
content: buffer.toString("base64"),
mimeType: getImageMimeType(file),
encoding: "base64",
}
}
return { type: "text", content: "" }
}
const knownText = isTextByExtension(file) || isTextByName(file)
if (isBinaryByExtension(file) && !knownText) {
return { type: "binary", content: "" }
}
if (!(await Filesystem.exists(full))) {
return { type: "text", content: "" }
}
const mimeType = Filesystem.mimeType(full)
const encode = knownText ? false : shouldEncode(mimeType)
if (encode && !isImage(mimeType)) {
return { type: "binary", content: "", mimeType }
}
if (encode) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
return {
type: "text",
content: buffer.toString("base64"),
mimeType,
encoding: "base64",
}
}
const content = (await Filesystem.readText(full).catch(() => "")).trim()
if (instance.project.vcs === "git") {
let diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory })
).text()
if (!diff.trim()) {
diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
cwd: instance.directory,
})
).text()
}
if (diff.trim()) {
const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text()
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,
})
return {
type: "text",
content,
patch,
diff: formatPatch(patch),
}
}
}
return { type: "text", content }
})
})
const list = Effect.fn("File.list")(function* (dir?: string) {
return yield* Effect.promise(async () => {
const exclude = [".git", ".DS_Store"]
let ignored = (_: string) => false
if (instance.project.vcs === "git") {
const ig = ignore()
const gitignore = path.join(instance.project.worktree, ".gitignore")
if (await Filesystem.exists(gitignore)) {
ig.add(await Filesystem.readText(gitignore))
}
const ignoreFile = path.join(instance.project.worktree, ".ignore")
if (await Filesystem.exists(ignoreFile)) {
ig.add(await Filesystem.readText(ignoreFile))
}
ignored = ig.ignores.bind(ig)
}
const resolved = dir ? path.join(instance.directory, dir) : instance.directory
if (!Instance.containsPath(resolved)) {
throw new Error("Access denied: path escapes project directory")
}
const nodes: File.Node[] = []
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
if (exclude.includes(entry.name)) continue
const absolute = path.join(resolved, entry.name)
const file = path.relative(instance.directory, absolute)
const type = entry.isDirectory() ? "directory" : "file"
nodes.push({
name: entry.name,
path: file,
absolute,
type,
ignored: ignored(type === "directory" ? file + "/" : file),
})
}
return nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
return a.name.localeCompare(b.name)
})
})
})
const search = Effect.fn("File.search")(function* (input: {
query: string
limit?: number
dirs?: boolean
type?: "file" | "directory"
}) {
return yield* Effect.promise(async () => {
const query = input.query.trim()
const limit = input.limit ?? 100
const kind = input.type ?? (input.dirs === false ? "file" : "all")
log.info("search", { query, kind })
const result = getFiles()
const preferHidden = query.startsWith(".") || query.includes("/.")
if (!query) {
if (kind === "file") return result.files.slice(0, limit)
return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
}
const items =
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
log.info("search", { query, kind, results: output.length })
return output
})
})
log.info("init")
return Service.of({ init, status, read, list, search })
}),
).pipe(Layer.fresh)
}
@@ -1,93 +0,0 @@
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
export namespace FileTime {
const log = Log.create({ service: "file.time" })
export type Stamp = {
readonly read: Date
readonly mtime: number | undefined
readonly ctime: number | undefined
readonly size: number | undefined
}
const stamp = Effect.fnUntraced(function* (file: string) {
const stat = Filesystem.stat(file)
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
return {
read: yield* DateTime.nowAsDate,
mtime: stat?.mtime?.getTime(),
ctime: stat?.ctime?.getTime(),
size,
}
})
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
const value = reads.get(sessionID)
if (value) return value
const next = new Map<string, Stamp>()
reads.set(sessionID, next)
return next
}
export interface Interface {
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
const reads = new Map<SessionID, Map<string, Stamp>>()
const locks = new Map<string, Semaphore.Semaphore>()
const getLock = (filepath: string) => {
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) {
log.info("read", { sessionID, file })
session(reads, sessionID).set(file, yield* stamp(file))
})
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
return reads.get(sessionID)?.get(file)?.read
})
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
if (disableCheck) return
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.ctime !== time.ctime || 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* <T>(filepath: string, fn: () => Promise<T>) {
return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1))
})
return Service.of({ read, get, assert, withLock })
}),
).pipe(Layer.orDie, Layer.fresh)
}
+110 -10
View File
@@ -1,28 +1,128 @@
import { runPromiseInstance } from "@/effect/runtime" import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema" import type { SessionID } from "@/session/schema"
import { FileTime as S } from "./time-service" import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
export namespace FileTime { export namespace FileTime {
export type Stamp = S.Stamp const log = Log.create({ service: "file.time" })
export type Interface = S.Interface export type Stamp = {
readonly read: Date
readonly mtime: number | undefined
readonly ctime: number | undefined
readonly size: number | undefined
}
export const Service = S.Service const stamp = Effect.fnUntraced(function* (file: string) {
export const layer = S.layer const stat = Filesystem.stat(file)
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
return {
read: yield* DateTime.nowAsDate,
mtime: stat?.mtime?.getTime(),
ctime: stat?.ctime?.getTime(),
size,
}
})
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
const value = reads.get(sessionID)
if (value) return value
const next = new Map<string, Stamp>()
reads.set(sessionID, next)
return next
}
interface State {
reads: Map<SessionID, Map<string, Stamp>>
locks: Map<string, Semaphore.Semaphore>
}
export interface Interface {
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
const state = yield* InstanceState.make<State>(
Effect.fn("FileTime.state")(() =>
Effect.succeed({
reads: new Map<SessionID, Map<string, Stamp>>(),
locks: new Map<string, Semaphore.Semaphore>(),
}),
),
)
const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) {
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) {
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) {
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
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.ctime !== time.ctime || 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* <T>(filepath: string, fn: () => Promise<T>) {
return yield* Effect.promise(fn).pipe((yield* getLock(filepath)).withPermits(1))
})
return Service.of({ read, get, assert, withLock })
}),
).pipe(Layer.orDie)
const runPromise = makeRunPromise(Service, layer)
export function read(sessionID: SessionID, file: string) { export function read(sessionID: SessionID, file: string) {
return runPromiseInstance(S.Service.use((s) => s.read(sessionID, file))) return runPromise((s) => s.read(sessionID, file))
} }
export function get(sessionID: SessionID, file: string) { export function get(sessionID: SessionID, file: string) {
return runPromiseInstance(S.Service.use((s) => s.get(sessionID, file))) return runPromise((s) => s.get(sessionID, file))
} }
export async function assert(sessionID: SessionID, filepath: string) { export async function assert(sessionID: SessionID, filepath: string) {
return runPromiseInstance(S.Service.use((s) => s.assert(sessionID, filepath))) return runPromise((s) => s.assert(sessionID, filepath))
} }
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> { export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
return runPromiseInstance(S.Service.use((s) => s.withLock(filepath, fn))) return runPromise((s) => s.withLock(filepath, fn))
} }
} }
+89 -63
View File
@@ -1,4 +1,4 @@
import { Cause, Effect, Layer, ServiceMap } from "effect" import { Cause, Effect, Layer, Scope, ServiceMap } from "effect"
// @ts-ignore // @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper" import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher" import type ParcelWatcher from "@parcel/watcher"
@@ -7,7 +7,8 @@ import path from "path"
import z from "zod" import z from "zod"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceContext } from "@/effect/instance-context" import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { git } from "@/util/git" import { git } from "@/util/git"
@@ -60,82 +61,107 @@ export namespace FileWatcher {
export const hasNativeBinding = () => !!watcher() export const hasNativeBinding = () => !!watcher()
export class Service extends ServiceMap.Service<Service, {}>()("@opencode/FileWatcher") {} export interface Interface {
readonly init: () => Effect.Effect<void>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileWatcher") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const instance = yield* InstanceContext const state = yield* InstanceState.make(
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({}) Effect.fn("FileWatcher.state")(
function* () {
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
log.info("init", { directory: instance.directory }) log.info("init", { directory: Instance.directory })
const backend = getBackend() const backend = getBackend()
if (!backend) { if (!backend) {
log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform }) log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform })
return Service.of({}) return
} }
const w = watcher() const w = watcher()
if (!w) return Service.of({}) if (!w) return
log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend }) log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend })
const subs: ParcelWatcher.AsyncSubscription[] = [] const subs: ParcelWatcher.AsyncSubscription[] = []
yield* Effect.addFinalizer(() => Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe())))) yield* Effect.addFinalizer(() =>
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
)
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => { const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
if (err) return if (err) return
for (const evt of evts) { for (const evt of evts) {
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" }) if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" }) if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" }) if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
} }
}) })
const subscribe = (dir: string, ignore: string[]) => { const subscribe = (dir: string, ignore: string[]) => {
const pending = w.subscribe(dir, cb, { ignore, backend }) const pending = w.subscribe(dir, cb, { ignore, backend })
return Effect.gen(function* () { return Effect.gen(function* () {
const sub = yield* Effect.promise(() => pending) const sub = yield* Effect.promise(() => pending)
subs.push(sub) subs.push(sub)
}).pipe( }).pipe(
Effect.timeout(SUBSCRIBE_TIMEOUT_MS), 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* Effect.promise(() => Config.get())
const cfgIgnores = cfg.watcher?.ignore ?? []
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
yield* subscribe(Instance.directory, [
...FileIgnore.PATTERNS,
...cfgIgnores,
...protecteds(Instance.directory),
])
}
if (Instance.project.vcs === "git") {
const result = yield* Effect.promise(() =>
git(["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) => { Effect.catchCause((cause) => {
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) }) log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
pending.then((s) => s.unsubscribe()).catch(() => {})
return Effect.void return Effect.void
}), }),
) ),
} )
const cfg = yield* Effect.promise(() => Config.get()) return Service.of({
const cfgIgnores = cfg.watcher?.ignore ?? [] init: Effect.fn("FileWatcher.init")(function* () {
yield* InstanceState.get(state)
}),
})
}),
)
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { const runPromise = makeRunPromise(Service, layer)
yield* subscribe(instance.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(instance.directory)])
}
if (instance.project.vcs === "git") { export function init() {
const result = yield* Effect.promise(() => return runPromise((svc) => svc.init())
git(["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)
}
}
return Service.of({})
}).pipe(
Effect.catchCause((cause) => {
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
return Effect.succeed(Service.of({}))
}),
),
).pipe(Layer.orDie, Layer.fresh)
} }
+1
View File
@@ -69,6 +69,7 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN") export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"] export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"] export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
export const OPENCODE_DB = process.env["OPENCODE_DB"]
export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB") export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB")
export const OPENCODE_SKIP_MIGRATIONS = truthy("OPENCODE_SKIP_MIGRATIONS") export const OPENCODE_SKIP_MIGRATIONS = truthy("OPENCODE_SKIP_MIGRATIONS")
export const OPENCODE_STRICT_CONFIG_DEPS = truthy("OPENCODE_STRICT_CONFIG_DEPS") export const OPENCODE_STRICT_CONFIG_DEPS = truthy("OPENCODE_STRICT_CONFIG_DEPS")
+174 -8
View File
@@ -1,16 +1,182 @@
import { runPromiseInstance } from "@/effect/runtime" import { Effect, Layer, ServiceMap } from "effect"
import { Format as S } from "./service" import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import path from "path"
import { mergeDeep } from "remeda"
import z from "zod"
import { Bus } from "../bus"
import { Config } from "../config/config"
import { File } from "../file"
import { Instance } from "../project/instance"
import { Process } from "../util/process"
import { Log } from "../util/log"
import * as Formatter from "./formatter"
export namespace Format { export namespace Format {
export const Status = S.Status const log = Log.create({ service: "format" })
export type Status = S.Status
export type Interface = S.Interface export const Status = z
.object({
name: z.string(),
extensions: z.string().array(),
enabled: z.boolean(),
})
.meta({
ref: "FormatterStatus",
})
export type Status = z.infer<typeof Status>
export const Service = S.Service export interface Interface {
export const layer = S.layer readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<Status[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make(
Effect.fn("Format.state")(function* (_ctx) {
const enabled: Record<string, boolean> = {}
const formatters: Record<string, Formatter.Info> = {}
const cfg = yield* Effect.promise(() => 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 ?? {})) {
if (item.disabled) {
delete formatters[name]
continue
}
const info = mergeDeep(formatters[name] ?? {}, {
command: [],
extensions: [],
...item,
})
if (info.command.length === 0) continue
formatters[name] = {
...info,
name,
enabled: async () => true,
}
}
} else {
log.info("all formatters are disabled")
}
async function isEnabled(item: Formatter.Info) {
let status = enabled[item.name]
if (status === undefined) {
status = await item.enabled()
enabled[item.name] = status
}
return status
}
async function getFormatter(ext: string) {
const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext))
const checks = await Promise.all(
matching.map(async (item) => {
log.info("checking", { name: item.name, ext })
const on = await isEnabled(item)
if (on) {
log.info("enabled", { name: item.name, ext })
}
return {
item,
enabled: on,
}
}),
)
return checks.filter((x) => x.enabled).map((x) => x.item)
}
yield* Effect.acquireRelease(
Effect.sync(() =>
Bus.subscribe(
File.Event.Edited,
Instance.bind(async (payload) => {
const file = payload.properties.file
log.info("formatting", { file })
const ext = path.extname(file)
for (const item of await getFormatter(ext)) {
log.info("running", { command: item.command })
try {
const proc = Process.spawn(
item.command.map((x) => x.replace("$FILE", file)),
{
cwd: Instance.directory,
env: { ...process.env, ...item.environment },
stdout: "ignore",
stderr: "ignore",
},
)
const exit = await proc.exited
if (exit !== 0) {
log.error("failed", {
command: item.command,
...item.environment,
})
}
} catch (error) {
log.error("failed to format file", {
error,
command: item.command,
...item.environment,
file,
})
}
}
}),
),
),
(unsubscribe) => Effect.sync(unsubscribe),
)
log.info("init")
return {
formatters,
isEnabled,
}
}),
)
const init = Effect.fn("Format.init")(function* () {
yield* InstanceState.get(state)
})
const status = Effect.fn("Format.status")(function* () {
const { formatters, isEnabled } = yield* InstanceState.get(state)
const result: Status[] = []
for (const formatter of Object.values(formatters)) {
const isOn = yield* Effect.promise(() => isEnabled(formatter))
result.push({
name: formatter.name,
extensions: formatter.extensions,
enabled: isOn,
})
}
return result
})
return Service.of({ init, status })
}),
)
const runPromise = makeRunPromise(Service, layer)
export async function init() {
return runPromise((s) => s.init())
}
export async function status() { export async function status() {
return runPromiseInstance(S.Service.use((s) => s.status())) return runPromise((s) => s.status())
} }
} }
-152
View File
@@ -1,152 +0,0 @@
import { Effect, Layer, ServiceMap } from "effect"
import { InstanceContext } from "@/effect/instance-context"
import path from "path"
import { mergeDeep } from "remeda"
import z from "zod"
import { Bus } from "../bus"
import { Config } from "../config/config"
import { File } from "../file/service"
import { Instance } from "../project/instance"
import { Process } from "../util/process"
import { Log } from "../util/log"
import * as Formatter from "./formatter"
export namespace Format {
const log = Log.create({ service: "format" })
export const Status = z
.object({
name: z.string(),
extensions: z.string().array(),
enabled: z.boolean(),
})
.meta({
ref: "FormatterStatus",
})
export type Status = z.infer<typeof Status>
export interface Interface {
readonly status: () => Effect.Effect<Status[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
const enabled: Record<string, boolean> = {}
const formatters: Record<string, Formatter.Info> = {}
const cfg = yield* Effect.promise(() => 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 ?? {})) {
if (item.disabled) {
delete formatters[name]
continue
}
const info = mergeDeep(formatters[name] ?? {}, {
command: [],
extensions: [],
...item,
})
if (info.command.length === 0) continue
formatters[name] = {
...info,
name,
enabled: async () => true,
}
}
} else {
log.info("all formatters are disabled")
}
async function isEnabled(item: Formatter.Info) {
let status = enabled[item.name]
if (status === undefined) {
status = await item.enabled()
enabled[item.name] = status
}
return status
}
async function getFormatter(ext: string) {
const result = []
for (const item of Object.values(formatters)) {
log.info("checking", { name: item.name, ext })
if (!item.extensions.includes(ext)) continue
if (!(await isEnabled(item))) continue
log.info("enabled", { name: item.name, ext })
result.push(item)
}
return result
}
yield* Effect.acquireRelease(
Effect.sync(() =>
Bus.subscribe(
File.Event.Edited,
Instance.bind(async (payload) => {
const file = payload.properties.file
log.info("formatting", { file })
const ext = path.extname(file)
for (const item of await getFormatter(ext)) {
log.info("running", { command: item.command })
try {
const proc = Process.spawn(
item.command.map((x) => x.replace("$FILE", file)),
{
cwd: instance.directory,
env: { ...process.env, ...item.environment },
stdout: "ignore",
stderr: "ignore",
},
)
const exit = await proc.exited
if (exit !== 0) {
log.error("failed", {
command: item.command,
...item.environment,
})
}
} catch (error) {
log.error("failed to format file", {
error,
command: item.command,
...item.environment,
file,
})
}
}
}),
),
),
(unsubscribe) => Effect.sync(unsubscribe),
)
log.info("init")
const status = Effect.fn("Format.status")(function* () {
const result: Status[] = []
for (const formatter of Object.values(formatters)) {
const isOn = yield* Effect.promise(() => isEnabled(formatter))
result.push({
name: formatter.name,
extensions: formatter.extensions,
enabled: isOn,
})
}
return result
})
return Service.of({ status })
}),
).pipe(Layer.fresh)
}
+7 -12
View File
@@ -1,6 +1,7 @@
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect" import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { makeRunPromise } from "@/effect/run-service"
import { withTransientReadRetry } from "@/util/effect-http-client" import { withTransientReadRetry } from "@/util/effect-http-client"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path" import path from "path"
@@ -293,7 +294,7 @@ export namespace Installation {
result = yield* run(["scoop", "install", `opencode@${target}`]) result = yield* run(["scoop", "install", `opencode@${target}`])
break break
default: default:
throw new Error(`Unknown method: ${m}`) return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` })
} }
if (!result || result.code !== 0) { if (!result || result.code !== 0) {
const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || "" const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || ""
@@ -329,27 +330,21 @@ export namespace Installation {
Layer.provide(NodePath.layer), Layer.provide(NodePath.layer),
) )
// Legacy adapters — dynamic import avoids circular dependency since const runPromise = makeRunPromise(Service, defaultLayer)
// foundational modules (db.ts, provider/models.ts) import Installation
// at load time, and runtime transitively loads those same modules.
async function runPromise<A>(f: (service: Interface) => Effect.Effect<A, any>) {
const { runtime } = await import("@/effect/runtime")
return runtime.runPromise(Service.use(f))
}
export function info(): Promise<Info> { export async function info(): Promise<Info> {
return runPromise((svc) => svc.info()) return runPromise((svc) => svc.info())
} }
export function method(): Promise<Method> { export async function method(): Promise<Method> {
return runPromise((svc) => svc.method()) return runPromise((svc) => svc.method())
} }
export function latest(installMethod?: Method): Promise<string> { export async function latest(installMethod?: Method): Promise<string> {
return runPromise((svc) => svc.latest(installMethod)) return runPromise((svc) => svc.latest(installMethod))
} }
export function upgrade(m: Method, target: string): Promise<void> { export async function upgrade(m: Method, target: string): Promise<void> {
return runPromise((svc) => svc.upgrade(m, target)) return runPromise((svc) => svc.upgrade(m, target))
} }
} }
+303 -33
View File
@@ -1,52 +1,322 @@
import { runPromiseInstance } from "@/effect/runtime" import { Bus } from "@/bus"
import { fn } from "@/util/fn" import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { ProjectID } from "@/project/schema"
import { Instance } from "@/project/instance"
import { MessageID, SessionID } from "@/session/schema"
import { PermissionTable } from "@/session/session.sql"
import { Database, eq } from "@/storage/db"
import { Log } from "@/util/log"
import { Wildcard } from "@/util/wildcard"
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import os from "os"
import z from "zod" import z from "zod"
import { Permission as S } from "./service" import { evaluate as evalRule } from "./evaluate"
import { PermissionID } from "./schema"
export namespace PermissionNext { export namespace Permission {
export const Action = S.Action const log = Log.create({ service: "permission" })
export type Action = S.Action
export const Rule = S.Rule export const Action = z.enum(["allow", "deny", "ask"]).meta({
export type Rule = S.Rule ref: "PermissionAction",
})
export type Action = z.infer<typeof Action>
export const Ruleset = S.Ruleset export const Rule = z
export type Ruleset = S.Ruleset .object({
permission: z.string(),
pattern: z.string(),
action: Action,
})
.meta({
ref: "PermissionRule",
})
export type Rule = z.infer<typeof Rule>
export const Request = S.Request export const Ruleset = Rule.array().meta({
export type Request = S.Request ref: "PermissionRuleset",
})
export type Ruleset = z.infer<typeof Ruleset>
export const Reply = S.Reply export const Request = z
export type Reply = S.Reply .object({
id: PermissionID.zod,
sessionID: SessionID.zod,
permission: z.string(),
patterns: z.string().array(),
metadata: z.record(z.string(), z.any()),
always: z.string().array(),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({
ref: "PermissionRequest",
})
export type Request = z.infer<typeof Request>
export const Approval = S.Approval export const Reply = z.enum(["once", "always", "reject"])
export type Approval = z.infer<typeof S.Approval> export type Reply = z.infer<typeof Reply>
export const Event = S.Event export const Approval = z.object({
projectID: ProjectID.zod,
patterns: z.string().array(),
})
export const RejectedError = S.RejectedError export const Event = {
export const CorrectedError = S.CorrectedError Asked: BusEvent.define("permission.asked", Request),
export const DeniedError = S.DeniedError Replied: BusEvent.define(
export type Error = S.Error "permission.replied",
z.object({
sessionID: SessionID.zod,
requestID: PermissionID.zod,
reply: Reply,
}),
),
}
export const AskInput = S.AskInput export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
export const ReplyInput = S.ReplyInput override get message() {
return "The user rejected permission to use this specific tool call."
}
}
export type Interface = S.Interface export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
feedback: Schema.String,
}) {
override get message() {
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
}
}
export const Service = S.Service export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
export const layer = S.layer ruleset: Schema.Any,
}) {
override get message() {
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
}
}
export const evaluate = S.evaluate export type Error = DeniedError | RejectedError | CorrectedError
export const fromConfig = S.fromConfig
export const merge = S.merge
export const disabled = S.disabled
export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((s) => s.ask(input)))) export const AskInput = Request.partial({ id: true }).extend({
ruleset: Ruleset,
})
export const reply = fn(S.ReplyInput, async (input) => runPromiseInstance(S.Service.use((s) => s.reply(input)))) export const ReplyInput = z.object({
requestID: PermissionID.zod,
reply: Reply,
message: z.string().optional(),
})
export interface Interface {
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]>
}
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
}
interface State {
pending: Map<PermissionID, PendingEntry>
approved: Ruleset
}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
return evalRule(permission, pattern, ...rulesets)
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Permission") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("Permission.state")(function* (ctx) {
const row = Database.use((db) =>
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(),
)
const state = {
pending: new Map<PermissionID, PendingEntry>(),
approved: row?.data ?? [],
}
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const item of state.pending.values()) {
yield* Deferred.fail(item.deferred, new RejectedError())
}
state.pending.clear()
}),
)
return state
}),
)
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
const { approved, pending } = yield* InstanceState.get(state)
const { ruleset, ...request } = input
let needsAsk = false
for (const pattern of request.patterns) {
const rule = evaluate(request.permission, pattern, ruleset, approved)
log.info("evaluated", { permission: request.permission, pattern, action: rule })
if (rule.action === "deny") {
return yield* new DeniedError({
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
})
}
if (rule.action === "allow") continue
needsAsk = true
}
if (!needsAsk) return
const id = request.id ?? PermissionID.ascending()
const info: Request = {
id,
...request,
}
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
pending.set(id, { info, deferred })
void Bus.publish(Event.Asked, info)
return yield* Effect.ensuring(
Deferred.await(deferred),
Effect.sync(() => {
pending.delete(id)
}),
)
})
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
const { approved, pending } = yield* InstanceState.get(state)
const existing = pending.get(input.requestID)
if (!existing) return
pending.delete(input.requestID)
void Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
reply: input.reply,
})
if (input.reply === "reject") {
yield* Deferred.fail(
existing.deferred,
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
)
for (const [id, item] of pending.entries()) {
if (item.info.sessionID !== existing.info.sessionID) continue
pending.delete(id)
void Bus.publish(Event.Replied, {
sessionID: item.info.sessionID,
requestID: item.info.id,
reply: "reject",
})
yield* Deferred.fail(item.deferred, new RejectedError())
}
return
}
yield* Deferred.succeed(existing.deferred, undefined)
if (input.reply === "once") return
for (const pattern of existing.info.always) {
approved.push({
permission: existing.info.permission,
pattern,
action: "allow",
})
}
for (const [id, item] of pending.entries()) {
if (item.info.sessionID !== existing.info.sessionID) continue
const ok = item.info.patterns.every(
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
)
if (!ok) continue
pending.delete(id)
void Bus.publish(Event.Replied, {
sessionID: item.info.sessionID,
requestID: item.info.id,
reply: "always",
})
yield* Deferred.succeed(item.deferred, undefined)
}
})
const list = Effect.fn("Permission.list")(function* () {
const pending = (yield* InstanceState.get(state)).pending
return Array.from(pending.values(), (item) => item.info)
})
return Service.of({ ask, reply, list })
}),
)
function expand(pattern: string): string {
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
if (pattern === "~") return os.homedir()
if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
return pattern
}
export function fromConfig(permission: Config.Permission) {
const ruleset: Ruleset = []
for (const [key, value] of Object.entries(permission)) {
if (typeof value === "string") {
ruleset.push({ permission: key, action: value, pattern: "*" })
continue
}
ruleset.push(
...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
)
}
return ruleset
}
export function merge(...rulesets: Ruleset[]): Ruleset {
return rulesets.flat()
}
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
const result = new Set<string>()
for (const tool of tools) {
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
if (!rule) continue
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
}
return result
}
export const runPromise = makeRunPromise(Service, layer)
export async function ask(input: z.infer<typeof AskInput>) {
return runPromise((s) => s.ask(input))
}
export async function reply(input: z.infer<typeof ReplyInput>) {
return runPromise((s) => s.reply(input))
}
export async function list() { export async function list() {
return runPromiseInstance(S.Service.use((s) => s.list())) return runPromise((s) => s.list())
} }
} }
-282
View File
@@ -1,282 +0,0 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config/config"
import { InstanceContext } from "@/effect/instance-context"
import { ProjectID } from "@/project/schema"
import { MessageID, SessionID } from "@/session/schema"
import { PermissionTable } from "@/session/session.sql"
import { Database, eq } from "@/storage/db"
import { Log } from "@/util/log"
import { Wildcard } from "@/util/wildcard"
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import os from "os"
import z from "zod"
import { evaluate as evalRule } from "./evaluate"
import { PermissionID } from "./schema"
export namespace Permission {
const log = Log.create({ service: "permission" })
export const Action = z.enum(["allow", "deny", "ask"]).meta({
ref: "PermissionAction",
})
export type Action = z.infer<typeof Action>
export const Rule = z
.object({
permission: z.string(),
pattern: z.string(),
action: Action,
})
.meta({
ref: "PermissionRule",
})
export type Rule = z.infer<typeof Rule>
export const Ruleset = Rule.array().meta({
ref: "PermissionRuleset",
})
export type Ruleset = z.infer<typeof Ruleset>
export const Request = z
.object({
id: PermissionID.zod,
sessionID: SessionID.zod,
permission: z.string(),
patterns: z.string().array(),
metadata: z.record(z.string(), z.any()),
always: z.string().array(),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({
ref: "PermissionRequest",
})
export type Request = z.infer<typeof Request>
export const Reply = z.enum(["once", "always", "reject"])
export type Reply = z.infer<typeof Reply>
export const Approval = z.object({
projectID: ProjectID.zod,
patterns: z.string().array(),
})
export const Event = {
Asked: BusEvent.define("permission.asked", Request),
Replied: BusEvent.define(
"permission.replied",
z.object({
sessionID: SessionID.zod,
requestID: PermissionID.zod,
reply: Reply,
}),
),
}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
override get message() {
return "The user rejected permission to use this specific tool call."
}
}
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
feedback: Schema.String,
}) {
override get message() {
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
}
}
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
ruleset: Schema.Any,
}) {
override get message() {
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
}
}
export type Error = DeniedError | RejectedError | CorrectedError
export const AskInput = Request.partial({ id: true }).extend({
ruleset: Ruleset,
})
export const ReplyInput = z.object({
requestID: PermissionID.zod,
reply: Reply,
message: z.string().optional(),
})
export interface Interface {
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]>
}
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
return evalRule(permission, pattern, ...rulesets)
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { project } = yield* InstanceContext
const row = Database.use((db) =>
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(),
)
const pending = new Map<PermissionID, PendingEntry>()
const approved: Ruleset = row?.data ?? []
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
const { ruleset, ...request } = input
let needsAsk = false
for (const pattern of request.patterns) {
const rule = evaluate(request.permission, pattern, ruleset, approved)
log.info("evaluated", { permission: request.permission, pattern, action: rule })
if (rule.action === "deny") {
return yield* new DeniedError({
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
})
}
if (rule.action === "allow") continue
needsAsk = true
}
if (!needsAsk) return
const id = request.id ?? PermissionID.ascending()
const info: Request = {
id,
...request,
}
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
pending.set(id, { info, deferred })
void Bus.publish(Event.Asked, info)
return yield* Effect.ensuring(
Deferred.await(deferred),
Effect.sync(() => {
pending.delete(id)
}),
)
})
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
const existing = pending.get(input.requestID)
if (!existing) return
pending.delete(input.requestID)
void Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
reply: input.reply,
})
if (input.reply === "reject") {
yield* Deferred.fail(
existing.deferred,
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
)
for (const [id, item] of pending.entries()) {
if (item.info.sessionID !== existing.info.sessionID) continue
pending.delete(id)
void Bus.publish(Event.Replied, {
sessionID: item.info.sessionID,
requestID: item.info.id,
reply: "reject",
})
yield* Deferred.fail(item.deferred, new RejectedError())
}
return
}
yield* Deferred.succeed(existing.deferred, undefined)
if (input.reply === "once") return
for (const pattern of existing.info.always) {
approved.push({
permission: existing.info.permission,
pattern,
action: "allow",
})
}
for (const [id, item] of pending.entries()) {
if (item.info.sessionID !== existing.info.sessionID) continue
const ok = item.info.patterns.every(
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
)
if (!ok) continue
pending.delete(id)
void Bus.publish(Event.Replied, {
sessionID: item.info.sessionID,
requestID: item.info.id,
reply: "always",
})
yield* Deferred.succeed(item.deferred, undefined)
}
})
const list = Effect.fn("Permission.list")(function* () {
return Array.from(pending.values(), (item) => item.info)
})
return Service.of({ ask, reply, list })
}),
).pipe(Layer.fresh)
function expand(pattern: string): string {
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
if (pattern === "~") return os.homedir()
if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
return pattern
}
export function fromConfig(permission: Config.Permission) {
const ruleset: Ruleset = []
for (const [key, value] of Object.entries(permission)) {
if (typeof value === "string") {
ruleset.push({ permission: key, action: value, pattern: "*" })
continue
}
ruleset.push(
...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
)
}
return ruleset
}
export function merge(...rulesets: Ruleset[]): Ruleset {
return rulesets.flat()
}
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
const result = new Set<string>()
for (const tool of tools) {
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
if (!rule) continue
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
}
return result
}
}
@@ -1,7 +1,11 @@
import { Plugin } from "../plugin" import { Plugin } from "../plugin"
import { Format } from "../format"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { File } from "../file" import { File } from "../file"
import { FileWatcher } from "../file/watcher"
import { Snapshot } from "../snapshot"
import { Project } from "./project" import { Project } from "./project"
import { Vcs } from "./vcs"
import { Bus } from "../bus" import { Bus } from "../bus"
import { Command } from "../command" import { Command } from "../command"
import { Instance } from "./instance" import { Instance } from "./instance"
@@ -12,8 +16,12 @@ export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory }) Log.Default.info("bootstrapping", { directory: Instance.directory })
await Plugin.init() await Plugin.init()
ShareNext.init() ShareNext.init()
Format.init()
await LSP.init() await LSP.init()
File.init() File.init()
FileWatcher.init()
Vcs.init()
Snapshot.init()
Bus.subscribe(Command.Event.Executed, async (payload) => { Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) { if (payload.properties.name === Command.Default.INIT) {
+4 -4
View File
@@ -7,13 +7,13 @@ import { Context } from "../util/context"
import { Project } from "./project" import { Project } from "./project"
import { State } from "./state" import { State } from "./state"
interface Context { export interface Shape {
directory: string directory: string
worktree: string worktree: string
project: Project.Info project: Project.Info
} }
const context = Context.create<Context>("instance") const context = Context.create<Shape>("instance")
const cache = new Map<string, Promise<Context>>() const cache = new Map<string, Promise<Shape>>()
const disposal = { const disposal = {
all: undefined as Promise<void> | undefined, all: undefined as Promise<void> | undefined,
@@ -52,7 +52,7 @@ function boot(input: { directory: string; init?: () => Promise<any>; project?: P
}) })
} }
function track(directory: string, next: Promise<Context>) { function track(directory: string, next: Promise<Shape>) {
const task = next.catch((error) => { const task = next.catch((error) => {
if (cache.get(directory) === task) cache.delete(directory) if (cache.get(directory) === task) cache.delete(directory)
throw error throw error
+62 -34
View File
@@ -1,7 +1,8 @@
import { Effect, Layer, ServiceMap } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceContext } from "@/effect/instance-context" import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { FileWatcher } from "@/file/watcher" import { FileWatcher } from "@/file/watcher"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { git } from "@/util/git" import { git } from "@/util/git"
@@ -30,54 +31,81 @@ export namespace Vcs {
export type Info = z.infer<typeof Info> export type Info = z.infer<typeof Info>
export interface Interface { export interface Interface {
readonly init: () => Effect.Effect<void>
readonly branch: () => Effect.Effect<string | undefined> readonly branch: () => Effect.Effect<string | undefined>
} }
interface State {
current: string | undefined
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const instance = yield* InstanceContext const state = yield* InstanceState.make<State>(
let currentBranch: string | undefined Effect.fn("Vcs.state")((ctx) =>
Effect.gen(function* () {
if (ctx.project.vcs !== "git") {
return { current: undefined }
}
if (instance.project.vcs === "git") { const getCurrentBranch = async () => {
const getCurrentBranch = async () => { const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: ctx.worktree,
cwd: instance.project.worktree, })
}) if (result.exitCode !== 0) return undefined
if (result.exitCode !== 0) return undefined const text = result.text().trim()
const text = result.text().trim() return text || undefined
return text || undefined }
}
currentBranch = yield* Effect.promise(() => getCurrentBranch()) const value = {
log.info("initialized", { branch: currentBranch }) current: yield* Effect.promise(() => getCurrentBranch()),
}
log.info("initialized", { branch: value.current })
yield* Effect.acquireRelease( yield* Effect.acquireRelease(
Effect.sync(() => Effect.sync(() =>
Bus.subscribe( Bus.subscribe(
FileWatcher.Event.Updated, FileWatcher.Event.Updated,
Instance.bind(async (evt) => { Instance.bind(async (evt) => {
if (!evt.properties.file.endsWith("HEAD")) return if (!evt.properties.file.endsWith("HEAD")) return
const next = await getCurrentBranch() const next = await getCurrentBranch()
if (next !== currentBranch) { if (next !== value.current) {
log.info("branch changed", { from: currentBranch, to: next }) log.info("branch changed", { from: value.current, to: next })
currentBranch = next value.current = next
Bus.publish(Event.BranchUpdated, { branch: next }) Bus.publish(Event.BranchUpdated, { branch: next })
} }
}), }),
), ),
), ),
(unsubscribe) => Effect.sync(unsubscribe), (unsubscribe) => Effect.sync(unsubscribe),
) )
}
return value
}),
),
)
return Service.of({ return Service.of({
init: Effect.fn("Vcs.init")(function* () {
yield* InstanceState.get(state)
}),
branch: Effect.fn("Vcs.branch")(function* () { branch: Effect.fn("Vcs.branch")(function* () {
return currentBranch return yield* InstanceState.use(state, (x) => x.current)
}), }),
}) })
}), }),
).pipe(Layer.fresh) )
const runPromise = makeRunPromise(Service, layer)
export function init() {
return runPromise((svc) => svc.init())
}
export function branch() {
return runPromise((svc) => svc.branch())
}
} }
@@ -1,215 +0,0 @@
import type { AuthOuathResult } from "@opencode-ai/plugin"
import { NamedError } from "@opencode-ai/util/error"
import * as Auth from "@/auth/effect"
import { ProviderID } from "./schema"
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap, Struct } from "effect"
import z from "zod"
export namespace ProviderAuth {
export const Method = z
.object({
type: z.union([z.literal("oauth"), z.literal("api")]),
label: z.string(),
prompts: z
.array(
z.union([
z.object({
type: z.literal("text"),
key: z.string(),
message: z.string(),
placeholder: z.string().optional(),
when: z
.object({
key: z.string(),
op: z.union([z.literal("eq"), z.literal("neq")]),
value: z.string(),
})
.optional(),
}),
z.object({
type: z.literal("select"),
key: z.string(),
message: z.string(),
options: z.array(
z.object({
label: z.string(),
value: z.string(),
hint: z.string().optional(),
}),
),
when: z
.object({
key: z.string(),
op: z.union([z.literal("eq"), z.literal("neq")]),
value: z.string(),
})
.optional(),
}),
]),
)
.optional(),
})
.meta({
ref: "ProviderAuthMethod",
})
export type Method = z.infer<typeof Method>
export const Authorization = z
.object({
url: z.string(),
method: z.union([z.literal("auto"), z.literal("code")]),
instructions: z.string(),
})
.meta({
ref: "ProviderAuthAuthorization",
})
export type Authorization = z.infer<typeof Authorization>
export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod }))
export const OauthCodeMissing = NamedError.create(
"ProviderAuthOauthCodeMissing",
z.object({ providerID: ProviderID.zod }),
)
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
export const ValidationFailed = NamedError.create(
"ProviderAuthValidationFailed",
z.object({
field: z.string(),
message: z.string(),
}),
)
export type Error =
| Auth.AuthError
| InstanceType<typeof OauthMissing>
| InstanceType<typeof OauthCodeMissing>
| InstanceType<typeof OauthCallbackFailed>
| InstanceType<typeof ValidationFailed>
export interface Interface {
readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
readonly authorize: (input: {
providerID: ProviderID
method: number
inputs?: Record<string, string>
}) => Effect.Effect<Authorization | undefined, Error>
readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const auth = yield* Auth.Auth.Service
const hooks = yield* Effect.promise(async () => {
const mod = await import("../plugin")
const plugins = await mod.Plugin.list()
return Record.fromEntries(
Arr.filterMap(plugins, (x) =>
x.auth?.provider !== undefined
? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
: Result.failVoid,
),
)
})
const pending = new Map<ProviderID, AuthOuathResult>()
const methods = Effect.fn("ProviderAuth.methods")(function* () {
return Record.map(hooks, (item) =>
item.methods.map(
(method): Method => ({
type: method.type,
label: method.label,
prompts: method.prompts?.map((prompt) => {
if (prompt.type === "select") {
return {
type: "select" as const,
key: prompt.key,
message: prompt.message,
options: prompt.options,
when: prompt.when,
}
}
return {
type: "text" as const,
key: prompt.key,
message: prompt.message,
placeholder: prompt.placeholder,
when: prompt.when,
}
}),
}),
),
)
})
const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: {
providerID: ProviderID
method: number
inputs?: Record<string, string>
}) {
const method = hooks[input.providerID].methods[input.method]
if (method.type !== "oauth") return
if (method.prompts && input.inputs) {
for (const prompt of method.prompts) {
if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) {
const error = prompt.validate(input.inputs[prompt.key])
if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error }))
}
}
}
const result = yield* Effect.promise(() => method.authorize(input.inputs))
pending.set(input.providerID, result)
return {
url: result.url,
method: result.method,
instructions: result.instructions,
}
})
const callback = Effect.fn("ProviderAuth.callback")(function* (input: {
providerID: ProviderID
method: number
code?: string
}) {
const match = pending.get(input.providerID)
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
if (match.method === "code" && !input.code) {
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
}
const result = yield* Effect.promise(() =>
match.method === "code" ? match.callback(input.code!) : match.callback(),
)
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
if ("key" in result) {
yield* auth.set(input.providerID, {
type: "api",
key: result.key,
})
}
if ("refresh" in result) {
yield* auth.set(input.providerID, {
type: "oauth",
access: result.access,
refresh: result.refresh,
expires: result.expires,
...(result.accountId ? { accountId: result.accountId } : {}),
})
}
})
return Service.of({ methods, authorize, callback })
}),
).pipe(Layer.fresh)
export const defaultLayer = layer.pipe(Layer.provide(Auth.Auth.layer))
}
+236 -33
View File
@@ -1,48 +1,251 @@
import { runPromiseInstance } from "@/effect/runtime" import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
import { fn } from "@/util/fn" import { NamedError } from "@opencode-ai/util/error"
import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { Plugin } from "../plugin"
import { ProviderID } from "./schema" import { ProviderID } from "./schema"
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap } from "effect"
import z from "zod" import z from "zod"
import { ProviderAuth as S } from "./auth-service"
export namespace ProviderAuth { export namespace ProviderAuth {
export const Method = S.Method export const Method = z
export type Method = S.Method .object({
type: z.union([z.literal("oauth"), z.literal("api")]),
label: z.string(),
prompts: z
.array(
z.union([
z.object({
type: z.literal("text"),
key: z.string(),
message: z.string(),
placeholder: z.string().optional(),
when: z
.object({
key: z.string(),
op: z.union([z.literal("eq"), z.literal("neq")]),
value: z.string(),
})
.optional(),
}),
z.object({
type: z.literal("select"),
key: z.string(),
message: z.string(),
options: z.array(
z.object({
label: z.string(),
value: z.string(),
hint: z.string().optional(),
}),
),
when: z
.object({
key: z.string(),
op: z.union([z.literal("eq"), z.literal("neq")]),
value: z.string(),
})
.optional(),
}),
]),
)
.optional(),
})
.meta({
ref: "ProviderAuthMethod",
})
export type Method = z.infer<typeof Method>
export const Authorization = S.Authorization export const Authorization = z
export type Authorization = S.Authorization .object({
url: z.string(),
method: z.union([z.literal("auto"), z.literal("code")]),
instructions: z.string(),
})
.meta({
ref: "ProviderAuthAuthorization",
})
export type Authorization = z.infer<typeof Authorization>
export const OauthMissing = S.OauthMissing export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod }))
export const OauthCodeMissing = S.OauthCodeMissing
export const OauthCallbackFailed = S.OauthCallbackFailed
export const ValidationFailed = S.ValidationFailed
export type Error = S.Error
export type Interface = S.Interface export const OauthCodeMissing = NamedError.create(
"ProviderAuthOauthCodeMissing",
z.object({ providerID: ProviderID.zod }),
)
export const Service = S.Service export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
export const layer = S.layer
export const defaultLayer = S.defaultLayer
export async function methods() { export const ValidationFailed = NamedError.create(
return runPromiseInstance(S.Service.use((svc) => svc.methods())) "ProviderAuthValidationFailed",
z.object({
field: z.string(),
message: z.string(),
}),
)
export type Error =
| Auth.AuthError
| InstanceType<typeof OauthMissing>
| InstanceType<typeof OauthCodeMissing>
| InstanceType<typeof OauthCallbackFailed>
| InstanceType<typeof ValidationFailed>
type Hook = NonNullable<Hooks["auth"]>
export interface Interface {
readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
readonly authorize: (input: {
providerID: ProviderID
method: number
inputs?: Record<string, string>
}) => Effect.Effect<Authorization | undefined, Error>
readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error>
} }
export const authorize = fn( interface State {
z.object({ hooks: Record<ProviderID, Hook>
providerID: ProviderID.zod, pending: Map<ProviderID, AuthOuathResult>
method: z.number(), }
inputs: z.record(z.string(), z.string()).optional(),
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const auth = yield* Auth.Service
const state = yield* InstanceState.make<State>(
Effect.fn("ProviderAuth.state")(() =>
Effect.promise(async () => {
const plugins = await Plugin.list()
return {
hooks: Record.fromEntries(
Arr.filterMap(plugins, (x) =>
x.auth?.provider !== undefined
? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
: Result.failVoid,
),
),
pending: new Map<ProviderID, AuthOuathResult>(),
}
}),
),
)
const methods = Effect.fn("ProviderAuth.methods")(function* () {
const hooks = (yield* InstanceState.get(state)).hooks
return Record.map(hooks, (item) =>
item.methods.map(
(method): Method => ({
type: method.type,
label: method.label,
prompts: method.prompts?.map((prompt) => {
if (prompt.type === "select") {
return {
type: "select" as const,
key: prompt.key,
message: prompt.message,
options: prompt.options,
when: prompt.when,
}
}
return {
type: "text" as const,
key: prompt.key,
message: prompt.message,
placeholder: prompt.placeholder,
when: prompt.when,
}
}),
}),
),
)
})
const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: {
providerID: ProviderID
method: number
inputs?: Record<string, string>
}) {
const { hooks, pending } = yield* InstanceState.get(state)
const method = hooks[input.providerID].methods[input.method]
if (method.type !== "oauth") return
if (method.prompts && input.inputs) {
for (const prompt of method.prompts) {
if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) {
const error = prompt.validate(input.inputs[prompt.key])
if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error }))
}
}
}
const result = yield* Effect.promise(() => method.authorize(input.inputs))
pending.set(input.providerID, result)
return {
url: result.url,
method: result.method,
instructions: result.instructions,
}
})
const callback = Effect.fn("ProviderAuth.callback")(function* (input: {
providerID: ProviderID
method: number
code?: string
}) {
const pending = (yield* InstanceState.get(state)).pending
const match = pending.get(input.providerID)
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
if (match.method === "code" && !input.code) {
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
}
const result = yield* Effect.promise(() =>
match.method === "code" ? match.callback(input.code!) : match.callback(),
)
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
if ("key" in result) {
yield* auth.set(input.providerID, {
type: "api",
key: result.key,
})
}
if ("refresh" in result) {
yield* auth.set(input.providerID, {
type: "oauth",
access: result.access,
refresh: result.refresh,
expires: result.expires,
...(result.accountId ? { accountId: result.accountId } : {}),
})
}
})
return Service.of({ methods, authorize, callback })
}), }),
async (input): Promise<Authorization | undefined> =>
runPromiseInstance(S.Service.use((svc) => svc.authorize(input))),
) )
export const callback = fn( export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
z.object({
providerID: ProviderID.zod, const runPromise = makeRunPromise(Service, defaultLayer)
method: z.number(),
code: z.string().optional(), export async function methods() {
}), return runPromise((svc) => svc.methods())
async (input) => runPromiseInstance(S.Service.use((svc) => svc.callback(input))), }
)
export async function authorize(input: {
providerID: ProviderID
method: number
inputs?: Record<string, string>
}): Promise<Authorization | undefined> {
return runPromise((svc) => svc.authorize(input))
}
export async function callback(input: { providerID: ProviderID; method: number; code?: string }) {
return runPromise((svc) => svc.callback(input))
}
} }
+7 -5
View File
@@ -755,11 +755,13 @@ export namespace ProviderTransform {
} }
if (input.model.api.npm === "@ai-sdk/google" || input.model.api.npm === "@ai-sdk/google-vertex") { if (input.model.api.npm === "@ai-sdk/google" || input.model.api.npm === "@ai-sdk/google-vertex") {
result["thinkingConfig"] = { if (input.model.capabilities.reasoning) {
includeThoughts: true, result["thinkingConfig"] = {
} includeThoughts: true,
if (input.model.api.id.includes("gemini-3")) { }
result["thinkingConfig"]["thinkingLevel"] = "high" if (input.model.api.id.includes("gemini-3")) {
result["thinkingConfig"]["thinkingLevel"] = "high"
}
} }
} }
+195 -23
View File
@@ -1,49 +1,221 @@
import { runPromiseInstance } from "@/effect/runtime" import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import type { MessageID, SessionID } from "@/session/schema" import { Bus } from "@/bus"
import type { QuestionID } from "./schema" import { BusEvent } from "@/bus/bus-event"
import { Question as S } from "./service" import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { SessionID, MessageID } from "@/session/schema"
import { Log } from "@/util/log"
import z from "zod"
import { QuestionID } from "./schema"
export namespace Question { export namespace Question {
export const Option = S.Option const log = Log.create({ service: "question" })
export type Option = S.Option
export const Info = S.Info // Schemas
export type Info = S.Info
export const Request = S.Request export const Option = z
export type Request = S.Request .object({
label: z.string().describe("Display text (1-5 words, concise)"),
description: z.string().describe("Explanation of choice"),
})
.meta({ ref: "QuestionOption" })
export type Option = z.infer<typeof Option>
export const Answer = S.Answer export const Info = z
export type Answer = S.Answer .object({
question: z.string().describe("Complete question"),
header: z.string().describe("Very short label (max 30 chars)"),
options: z.array(Option).describe("Available choices"),
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
})
.meta({ ref: "QuestionInfo" })
export type Info = z.infer<typeof Info>
export const Reply = S.Reply export const Request = z
export type Reply = S.Reply .object({
id: QuestionID.zod,
sessionID: SessionID.zod,
questions: z.array(Info).describe("Questions to ask"),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({ ref: "QuestionRequest" })
export type Request = z.infer<typeof Request>
export const Event = S.Event export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
export const RejectedError = S.RejectedError export type Answer = z.infer<typeof Answer>
export type Interface = S.Interface export const Reply = z.object({
answers: z
.array(Answer)
.describe("User answers in order of questions (each answer is an array of selected labels)"),
})
export type Reply = z.infer<typeof Reply>
export const Service = S.Service export const Event = {
export const layer = S.layer Asked: BusEvent.define("question.asked", Request),
Replied: BusEvent.define(
"question.replied",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
answers: z.array(Answer),
}),
),
Rejected: BusEvent.define(
"question.rejected",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
}),
),
}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
override get message() {
return "The user dismissed this question"
}
}
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<Answer[], RejectedError>
}
interface State {
pending: Map<QuestionID, PendingEntry>
}
// Service
export interface Interface {
readonly ask: (input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}) => Effect.Effect<Answer[], RejectedError>
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Question") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("Question.state")(function* () {
const state = {
pending: new Map<QuestionID, PendingEntry>(),
}
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const item of state.pending.values()) {
yield* Deferred.fail(item.deferred, new RejectedError())
}
state.pending.clear()
}),
)
return state
}),
)
const ask = Effect.fn("Question.ask")(function* (input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}) {
const pending = (yield* InstanceState.get(state)).pending
const id = QuestionID.ascending()
log.info("asking", { id, questions: input.questions.length })
const deferred = yield* Deferred.make<Answer[], RejectedError>()
const info: Request = {
id,
sessionID: input.sessionID,
questions: input.questions,
tool: input.tool,
}
pending.set(id, { info, deferred })
Bus.publish(Event.Asked, info)
return yield* Effect.ensuring(
Deferred.await(deferred),
Effect.sync(() => {
pending.delete(id)
}),
)
})
const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
const pending = (yield* InstanceState.get(state)).pending
const existing = pending.get(input.requestID)
if (!existing) {
log.warn("reply for unknown request", { requestID: input.requestID })
return
}
pending.delete(input.requestID)
log.info("replied", { requestID: input.requestID, answers: input.answers })
Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: input.answers,
})
yield* Deferred.succeed(existing.deferred, input.answers)
})
const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
const pending = (yield* InstanceState.get(state)).pending
const existing = pending.get(requestID)
if (!existing) {
log.warn("reject for unknown request", { requestID })
return
}
pending.delete(requestID)
log.info("rejected", { requestID })
Bus.publish(Event.Rejected, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
})
yield* Deferred.fail(existing.deferred, new RejectedError())
})
const list = Effect.fn("Question.list")(function* () {
const pending = (yield* InstanceState.get(state)).pending
return Array.from(pending.values(), (x) => x.info)
})
return Service.of({ ask, reply, reject, list })
}),
)
const runPromise = makeRunPromise(Service, layer)
export async function ask(input: { export async function ask(input: {
sessionID: SessionID sessionID: SessionID
questions: Info[] questions: Info[]
tool?: { messageID: MessageID; callID: string } tool?: { messageID: MessageID; callID: string }
}): Promise<Answer[]> { }): Promise<Answer[]> {
return runPromiseInstance(S.Service.use((s) => s.ask(input))) return runPromise((s) => s.ask(input))
} }
export async function reply(input: { requestID: QuestionID; answers: Answer[] }) { export async function reply(input: { requestID: QuestionID; answers: Answer[] }) {
return runPromiseInstance(S.Service.use((s) => s.reply(input))) return runPromise((s) => s.reply(input))
} }
export async function reject(requestID: QuestionID) { export async function reject(requestID: QuestionID) {
return runPromiseInstance(S.Service.use((s) => s.reject(requestID))) return runPromise((s) => s.reject(requestID))
} }
export async function list() { export async function list() {
return runPromiseInstance(S.Service.use((s) => s.list())) return runPromise((s) => s.list())
} }
} }
-172
View File
@@ -1,172 +0,0 @@
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { SessionID, MessageID } from "@/session/schema"
import { Log } from "@/util/log"
import z from "zod"
import { QuestionID } from "./schema"
const log = Log.create({ service: "question" })
export namespace Question {
// Schemas
export const Option = z
.object({
label: z.string().describe("Display text (1-5 words, concise)"),
description: z.string().describe("Explanation of choice"),
})
.meta({ ref: "QuestionOption" })
export type Option = z.infer<typeof Option>
export const Info = z
.object({
question: z.string().describe("Complete question"),
header: z.string().describe("Very short label (max 30 chars)"),
options: z.array(Option).describe("Available choices"),
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
})
.meta({ ref: "QuestionInfo" })
export type Info = z.infer<typeof Info>
export const Request = z
.object({
id: QuestionID.zod,
sessionID: SessionID.zod,
questions: z.array(Info).describe("Questions to ask"),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({ ref: "QuestionRequest" })
export type Request = z.infer<typeof Request>
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
export type Answer = z.infer<typeof Answer>
export const Reply = z.object({
answers: z
.array(Answer)
.describe("User answers in order of questions (each answer is an array of selected labels)"),
})
export type Reply = z.infer<typeof Reply>
export const Event = {
Asked: BusEvent.define("question.asked", Request),
Replied: BusEvent.define(
"question.replied",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
answers: z.array(Answer),
}),
),
Rejected: BusEvent.define(
"question.rejected",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
}),
),
}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
override get message() {
return "The user dismissed this question"
}
}
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<Answer[], RejectedError>
}
// Service
export interface Interface {
readonly ask: (input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}) => Effect.Effect<Answer[], RejectedError>
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Question") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const pending = new Map<QuestionID, PendingEntry>()
const ask = Effect.fn("Question.ask")(function* (input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}) {
const id = QuestionID.ascending()
log.info("asking", { id, questions: input.questions.length })
const deferred = yield* Deferred.make<Answer[], RejectedError>()
const info: Request = {
id,
sessionID: input.sessionID,
questions: input.questions,
tool: input.tool,
}
pending.set(id, { info, deferred })
Bus.publish(Event.Asked, info)
return yield* Effect.ensuring(
Deferred.await(deferred),
Effect.sync(() => {
pending.delete(id)
}),
)
})
const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
const existing = pending.get(input.requestID)
if (!existing) {
log.warn("reply for unknown request", { requestID: input.requestID })
return
}
pending.delete(input.requestID)
log.info("replied", { requestID: input.requestID, answers: input.answers })
Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: input.answers,
})
yield* Deferred.succeed(existing.deferred, input.answers)
})
const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
const existing = pending.get(requestID)
if (!existing) {
log.warn("reject for unknown request", { requestID })
return
}
pending.delete(requestID)
log.info("rejected", { requestID })
Bus.publish(Event.Rejected, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
})
yield* Deferred.fail(existing.deferred, new RejectedError())
})
const list = Effect.fn("Question.list")(function* () {
return Array.from(pending.values(), (x) => x.info)
})
return Service.of({ ask, reply, reject, list })
}),
).pipe(Layer.fresh)
}
@@ -1,7 +1,7 @@
import { Hono } from "hono" import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi" import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod" import z from "zod"
import { PermissionNext } from "@/permission" import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema" import { PermissionID } from "@/permission/schema"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
@@ -32,11 +32,11 @@ export const PermissionRoutes = lazy(() =>
requestID: PermissionID.zod, requestID: PermissionID.zod,
}), }),
), ),
validator("json", z.object({ reply: PermissionNext.Reply, message: z.string().optional() })), validator("json", z.object({ reply: Permission.Reply, message: z.string().optional() })),
async (c) => { async (c) => {
const params = c.req.valid("param") const params = c.req.valid("param")
const json = c.req.valid("json") const json = c.req.valid("json")
await PermissionNext.reply({ await Permission.reply({
requestID: params.requestID, requestID: params.requestID,
reply: json.reply, reply: json.reply,
message: json.message, message: json.message,
@@ -55,14 +55,14 @@ export const PermissionRoutes = lazy(() =>
description: "List of pending permissions", description: "List of pending permissions",
content: { content: {
"application/json": { "application/json": {
schema: resolver(PermissionNext.Request.array()), schema: resolver(Permission.Request.array()),
}, },
}, },
}, },
}, },
}), }),
async (c) => { async (c) => {
const permissions = await PermissionNext.list() const permissions = await Permission.list()
return c.json(permissions) return c.json(permissions)
}, },
), ),
@@ -14,7 +14,7 @@ import { Todo } from "../../session/todo"
import { Agent } from "../../agent/agent" import { Agent } from "../../agent/agent"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { Log } from "../../util/log" import { Log } from "../../util/log"
import { PermissionNext } from "@/permission" import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema" import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema" import { ModelID, ProviderID } from "@/provider/schema"
import { errors } from "../error" import { errors } from "../error"
@@ -1010,10 +1010,10 @@ export const SessionRoutes = lazy(() =>
permissionID: PermissionID.zod, permissionID: PermissionID.zod,
}), }),
), ),
validator("json", z.object({ response: PermissionNext.Reply })), validator("json", z.object({ response: Permission.Reply })),
async (c) => { async (c) => {
const params = c.req.valid("param") const params = c.req.valid("param")
PermissionNext.reply({ Permission.reply({
requestID: params.permissionID, requestID: params.permissionID,
reply: c.req.valid("json").response, reply: c.req.valid("json").response,
}) })
+3 -4
View File
@@ -12,9 +12,8 @@ import { Format } from "../format"
import { TuiRoutes } from "./routes/tui" import { TuiRoutes } from "./routes/tui"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Vcs } from "../project/vcs" import { Vcs } from "../project/vcs"
import { runPromiseInstance } from "@/effect/runtime"
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
import { Skill } from "../skill/skill" import { Skill } from "../skill"
import { Auth } from "../auth" import { Auth } from "../auth"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { Command } from "../command" import { Command } from "../command"
@@ -152,7 +151,7 @@ export namespace Server {
providerID: ProviderID.zod, providerID: ProviderID.zod,
}), }),
), ),
validator("json", Auth.Info), validator("json", Auth.Info.zod),
async (c) => { async (c) => {
const providerID = c.req.valid("param").providerID const providerID = c.req.valid("param").providerID
const info = c.req.valid("json") const info = c.req.valid("json")
@@ -331,7 +330,7 @@ export namespace Server {
}, },
}), }),
async (c) => { async (c) => {
const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch())) const branch = await Vcs.branch()
return c.json({ return c.json({
branch, branch,
}) })
+4 -4
View File
@@ -28,7 +28,7 @@ import { SessionID, MessageID, PartID } from "./schema"
import type { Provider } from "@/provider/provider" import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema" import { ModelID, ProviderID } from "@/provider/schema"
import { PermissionNext } from "@/permission" import { Permission } from "@/permission"
import { Global } from "@/global" import { Global } from "@/global"
import type { LanguageModelV2Usage } from "@ai-sdk/provider" import type { LanguageModelV2Usage } from "@ai-sdk/provider"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
@@ -148,7 +148,7 @@ export namespace Session {
compacting: z.number().optional(), compacting: z.number().optional(),
archived: z.number().optional(), archived: z.number().optional(),
}), }),
permission: PermissionNext.Ruleset.optional(), permission: Permission.Ruleset.optional(),
revert: z revert: z
.object({ .object({
messageID: MessageID.zod, messageID: MessageID.zod,
@@ -300,7 +300,7 @@ export namespace Session {
parentID?: SessionID parentID?: SessionID
workspaceID?: WorkspaceID workspaceID?: WorkspaceID
directory: string directory: string
permission?: PermissionNext.Ruleset permission?: Permission.Ruleset
}) { }) {
const result: Info = { const result: Info = {
id: SessionID.descending(input.id), id: SessionID.descending(input.id),
@@ -423,7 +423,7 @@ export namespace Session {
export const setPermission = fn( export const setPermission = fn(
z.object({ z.object({
sessionID: SessionID.zod, sessionID: SessionID.zod,
permission: PermissionNext.Ruleset, permission: Permission.Ruleset,
}), }),
async (input) => { async (input) => {
return Database.use((db) => { return Database.use((db) => {
+4 -4
View File
@@ -21,7 +21,7 @@ import type { MessageV2 } from "./message-v2"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
import { SystemPrompt } from "./system" import { SystemPrompt } from "./system"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { PermissionNext } from "@/permission" import { Permission } from "@/permission"
import { Auth } from "@/auth" import { Auth } from "@/auth"
export namespace LLM { export namespace LLM {
@@ -33,7 +33,7 @@ export namespace LLM {
sessionID: string sessionID: string
model: Provider.Model model: Provider.Model
agent: Agent.Info agent: Agent.Info
permission?: PermissionNext.Ruleset permission?: Permission.Ruleset
system: string[] system: string[]
abort: AbortSignal abort: AbortSignal
messages: ModelMessage[] messages: ModelMessage[]
@@ -286,9 +286,9 @@ export namespace LLM {
} }
async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) { async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
const disabled = PermissionNext.disabled( const disabled = Permission.disabled(
Object.keys(input.tools), Object.keys(input.tools),
PermissionNext.merge(input.agent.permission, input.permission ?? []), Permission.merge(input.agent.permission, input.permission ?? []),
) )
for (const tool of Object.keys(input.tools)) { for (const tool of Object.keys(input.tools)) {
if (input.user.tools?.[tool] === false || disabled.has(tool)) { if (input.user.tools?.[tool] === false || disabled.has(tool)) {
+3 -3
View File
@@ -12,7 +12,7 @@ import type { Provider } from "@/provider/provider"
import { LLM } from "./llm" import { LLM } from "./llm"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { SessionCompaction } from "./compaction" import { SessionCompaction } from "./compaction"
import { PermissionNext } from "@/permission" import { Permission } from "@/permission"
import { Question } from "@/question" import { Question } from "@/question"
import { PartID } from "./schema" import { PartID } from "./schema"
import type { SessionID, MessageID } from "./schema" import type { SessionID, MessageID } from "./schema"
@@ -163,7 +163,7 @@ export namespace SessionProcessor {
) )
) { ) {
const agent = await Agent.get(input.assistantMessage.agent) const agent = await Agent.get(input.assistantMessage.agent)
await PermissionNext.ask({ await Permission.ask({
permission: "doom_loop", permission: "doom_loop",
patterns: [value.toolName], patterns: [value.toolName],
sessionID: input.assistantMessage.sessionID, sessionID: input.assistantMessage.sessionID,
@@ -219,7 +219,7 @@ export namespace SessionProcessor {
}) })
if ( if (
value.error instanceof PermissionNext.RejectedError || value.error instanceof Permission.RejectedError ||
value.error instanceof Question.RejectedError value.error instanceof Question.RejectedError
) { ) {
blocked = shouldBreak blocked = shouldBreak
+10 -7
View File
@@ -41,7 +41,7 @@ import { fn } from "@/util/fn"
import { SessionProcessor } from "./processor" import { SessionProcessor } from "./processor"
import { TaskTool } from "@/tool/task" import { TaskTool } from "@/tool/task"
import { Tool } from "@/tool/tool" import { Tool } from "@/tool/tool"
import { PermissionNext } from "@/permission" import { Permission } from "@/permission"
import { SessionStatus } from "./status" import { SessionStatus } from "./status"
import { LLM } from "./llm" import { LLM } from "./llm"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
@@ -168,7 +168,7 @@ export namespace SessionPrompt {
// this is backwards compatibility for allowing `tools` to be specified when // this is backwards compatibility for allowing `tools` to be specified when
// prompting // prompting
const permissions: PermissionNext.Ruleset = [] const permissions: Permission.Ruleset = []
for (const [tool, enabled] of Object.entries(input.tools ?? {})) { for (const [tool, enabled] of Object.entries(input.tools ?? {})) {
permissions.push({ permissions.push({
permission: tool, permission: tool,
@@ -437,10 +437,10 @@ export namespace SessionPrompt {
} satisfies MessageV2.ToolPart)) as MessageV2.ToolPart } satisfies MessageV2.ToolPart)) as MessageV2.ToolPart
}, },
async ask(req) { async ask(req) {
await PermissionNext.ask({ await Permission.ask({
...req, ...req,
sessionID: sessionID, sessionID: sessionID,
ruleset: PermissionNext.merge(taskAgent.permission, session.permission ?? []), ruleset: Permission.merge(taskAgent.permission, session.permission ?? []),
}) })
}, },
} }
@@ -781,11 +781,11 @@ export namespace SessionPrompt {
} }
}, },
async ask(req) { async ask(req) {
await PermissionNext.ask({ await Permission.ask({
...req, ...req,
sessionID: input.session.id, sessionID: input.session.id,
tool: { messageID: input.processor.message.id, callID: options.toolCallId }, tool: { messageID: input.processor.message.id, callID: options.toolCallId },
ruleset: PermissionNext.merge(input.agent.permission, input.session.permission ?? []), ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
}) })
}, },
}) })
@@ -1271,7 +1271,7 @@ export namespace SessionPrompt {
if (part.type === "agent") { if (part.type === "agent") {
// Check if this agent would be denied by task permission // Check if this agent would be denied by task permission
const perm = PermissionNext.evaluate("task", part.name, agent.permission) const perm = Permission.evaluate("task", part.name, agent.permission)
const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : "" const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : ""
return [ return [
{ {
@@ -1782,6 +1782,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
export async function command(input: CommandInput) { export async function command(input: CommandInput) {
log.info("command", input) log.info("command", input)
const command = await Command.get(input.command) const command = await Command.get(input.command)
if (!command) {
throw new NamedError.Unknown({ message: `Command not found: "${input.command}"` })
}
const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent()) const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent())
const raw = input.arguments.match(argsRegex) ?? [] const raw = input.arguments.match(argsRegex) ?? []
+3 -3
View File
@@ -2,7 +2,7 @@ import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlit
import { ProjectTable } from "../project/project.sql" import { ProjectTable } from "../project/project.sql"
import type { MessageV2 } from "./message-v2" import type { MessageV2 } from "./message-v2"
import type { Snapshot } from "../snapshot" import type { Snapshot } from "../snapshot"
import type { PermissionNext } from "../permission" import type { Permission } from "../permission"
import type { ProjectID } from "../project/schema" import type { ProjectID } from "../project/schema"
import type { SessionID, MessageID, PartID } from "./schema" import type { SessionID, MessageID, PartID } from "./schema"
import type { WorkspaceID } from "../control-plane/schema" import type { WorkspaceID } from "../control-plane/schema"
@@ -31,7 +31,7 @@ export const SessionTable = sqliteTable(
summary_files: integer(), summary_files: integer(),
summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(), summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(), revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
permission: text({ mode: "json" }).$type<PermissionNext.Ruleset>(), permission: text({ mode: "json" }).$type<Permission.Ruleset>(),
...Timestamps, ...Timestamps,
time_compacting: integer(), time_compacting: integer(),
time_archived: integer(), time_archived: integer(),
@@ -99,5 +99,5 @@ export const PermissionTable = sqliteTable("permission", {
.primaryKey() .primaryKey()
.references(() => ProjectTable.id, { onDelete: "cascade" }), .references(() => ProjectTable.id, { onDelete: "cascade" }),
...Timestamps, ...Timestamps,
data: text({ mode: "json" }).notNull().$type<PermissionNext.Ruleset>(), data: text({ mode: "json" }).notNull().$type<Permission.Ruleset>(),
}) })
+2 -2
View File
@@ -11,7 +11,7 @@ import PROMPT_CODEX from "./prompt/codex.txt"
import PROMPT_TRINITY from "./prompt/trinity.txt" import PROMPT_TRINITY from "./prompt/trinity.txt"
import type { Provider } from "@/provider/provider" import type { Provider } from "@/provider/provider"
import type { Agent } from "@/agent/agent" import type { Agent } from "@/agent/agent"
import { PermissionNext } from "@/permission" import { Permission } from "@/permission"
import { Skill } from "@/skill" import { Skill } from "@/skill"
export namespace SystemPrompt { export namespace SystemPrompt {
@@ -53,7 +53,7 @@ export namespace SystemPrompt {
} }
export async function skills(agent: Agent.Info) { export async function skills(agent: Agent.Info) {
if (PermissionNext.disabled(["skill"], agent.permission).has("skill")) return if (Permission.disabled(["skill"], agent.permission).has("skill")) return
const list = await Skill.available(agent) const list = await Skill.available(agent)
+1 -1
View File
@@ -45,7 +45,7 @@ export namespace ShareNext {
}> { }> {
const headers: Record<string, string> = {} const headers: Record<string, string> = {}
const active = Account.active() const active = await Account.active()
if (!active?.active_org_id) { if (!active?.active_org_id) {
const baseUrl = await Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai") const baseUrl = await Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
return { headers, api: legacyApi, baseUrl } return { headers, api: legacyApi, baseUrl }
+262 -1
View File
@@ -1 +1,262 @@
export * from "./skill" import os from "os"
import path from "path"
import { pathToFileURL } from "url"
import z from "zod"
import { Effect, Layer, ServiceMap } from "effect"
import { NamedError } from "@opencode-ai/util/error"
import type { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
import { Permission } from "@/permission"
import { Filesystem } from "@/util/filesystem"
import { Config } from "../config/config"
import { ConfigMarkdown } from "../config/markdown"
import { Glob } from "../util/glob"
import { Log } from "../util/log"
import { Discovery } from "./discovery"
export namespace Skill {
const log = Log.create({ service: "skill" })
const EXTERNAL_DIRS = [".claude", ".agents"]
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
const SKILL_PATTERN = "**/SKILL.md"
export const Info = z.object({
name: z.string(),
description: z.string(),
location: z.string(),
content: z.string(),
})
export type Info = z.infer<typeof Info>
export const InvalidError = NamedError.create(
"SkillInvalidError",
z.object({
path: z.string(),
message: z.string().optional(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
}),
)
export const NameMismatchError = NamedError.create(
"SkillNameMismatchError",
z.object({
path: z.string(),
expected: z.string(),
actual: z.string(),
}),
)
type State = {
skills: Record<string, Info>
dirs: Set<string>
task?: Promise<void>
}
type Cache = State & {
ensure: () => Promise<void>
}
export interface Interface {
readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly all: () => Effect.Effect<Info[]>
readonly dirs: () => Effect.Effect<string[]>
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
}
const add = async (state: State, match: string) => {
const md = await ConfigMarkdown.parse(match).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse skill ${match}`
const { Session } = await import("@/session")
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load skill", { skill: match, err })
return undefined
})
if (!md) return
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
if (!parsed.success) return
if (state.skills[parsed.data.name]) {
log.warn("duplicate skill name", {
name: parsed.data.name,
existing: state.skills[parsed.data.name].location,
duplicate: match,
})
}
state.dirs.add(path.dirname(match))
state.skills[parsed.data.name] = {
name: parsed.data.name,
description: parsed.data.description,
location: match,
content: md.content,
}
}
const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
return Glob.scan(pattern, {
cwd: root,
absolute: true,
include: "file",
symlink: true,
dot: opts?.dot,
})
.then((matches) => Promise.all(matches.map((match) => add(state, match))))
.catch((error) => {
if (!opts?.scope) throw error
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
})
}
// TODO: Migrate to Effect
const create = (discovery: Discovery.Interface, directory: string, worktree: string): Cache => {
const state: State = {
skills: {},
dirs: new Set<string>(),
}
const load = async () => {
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
for (const dir of EXTERNAL_DIRS) {
const root = path.join(Global.Path.home, dir)
if (!(await Filesystem.isDir(root))) continue
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
}
for await (const root of Filesystem.up({
targets: EXTERNAL_DIRS,
start: directory,
stop: worktree,
})) {
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
}
}
for (const dir of await Config.directories()) {
await scan(state, dir, OPENCODE_SKILL_PATTERN)
}
const cfg = await Config.get()
for (const item of cfg.skills?.paths ?? []) {
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
if (!(await Filesystem.isDir(dir))) {
log.warn("skill path not found", { path: dir })
continue
}
await scan(state, dir, SKILL_PATTERN)
}
for (const url of cfg.skills?.urls ?? []) {
for (const dir of await Effect.runPromise(discovery.pull(url))) {
state.dirs.add(dir)
await scan(state, dir, SKILL_PATTERN)
}
}
log.info("init", { count: Object.keys(state.skills).length })
}
const ensure = () => {
if (state.task) return state.task
state.task = load().catch((err) => {
state.task = undefined
throw err
})
return state.task
}
return { ...state, ensure }
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
export const layer: Layer.Layer<Service, never, Discovery.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const discovery = yield* Discovery.Service
const state = yield* InstanceState.make(
Effect.fn("Skill.state")((ctx) => Effect.sync(() => create(discovery, ctx.directory, ctx.worktree))),
)
const ensure = Effect.fn("Skill.ensure")(function* () {
const cache = yield* InstanceState.get(state)
yield* Effect.promise(() => cache.ensure())
return cache
})
const get = Effect.fn("Skill.get")(function* (name: string) {
const cache = yield* ensure()
return cache.skills[name]
})
const all = Effect.fn("Skill.all")(function* () {
const cache = yield* ensure()
return Object.values(cache.skills)
})
const dirs = Effect.fn("Skill.dirs")(function* () {
const cache = yield* ensure()
return Array.from(cache.dirs)
})
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
const cache = yield* ensure()
const list = Object.values(cache.skills).toSorted((a, b) => a.name.localeCompare(b.name))
if (!agent) return list
return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
})
return Service.of({ get, all, dirs, available })
}),
)
export const defaultLayer: Layer.Layer<Service> = layer.pipe(Layer.provide(Discovery.defaultLayer))
export function fmt(list: Info[], opts: { verbose: boolean }) {
if (list.length === 0) return "No skills are currently available."
if (opts.verbose) {
return [
"<available_skills>",
...list.flatMap((skill) => [
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
" </skill>",
]),
"</available_skills>",
].join("\n")
}
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
}
const runPromise = makeRunPromise(Service, defaultLayer)
export async function get(name: string) {
return runPromise((skill) => skill.get(name))
}
export async function all() {
return runPromise((skill) => skill.all())
}
export async function dirs() {
return runPromise((skill) => skill.dirs())
}
export async function available(agent?: Agent.Info) {
return runPromise((skill) => skill.available(agent))
}
}
-238
View File
@@ -1,238 +0,0 @@
import os from "os"
import path from "path"
import { pathToFileURL } from "url"
import z from "zod"
import { Effect, Layer, ServiceMap } from "effect"
import { NamedError } from "@opencode-ai/util/error"
import type { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
import { InstanceContext } from "@/effect/instance-context"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
import { Permission } from "@/permission/service"
import { Filesystem } from "@/util/filesystem"
import { Config } from "../config/config"
import { ConfigMarkdown } from "../config/markdown"
import { Glob } from "../util/glob"
import { Log } from "../util/log"
import { Discovery } from "./discovery"
export namespace Skill {
const log = Log.create({ service: "skill" })
const EXTERNAL_DIRS = [".claude", ".agents"]
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
const SKILL_PATTERN = "**/SKILL.md"
export const Info = z.object({
name: z.string(),
description: z.string(),
location: z.string(),
content: z.string(),
})
export type Info = z.infer<typeof Info>
export const InvalidError = NamedError.create(
"SkillInvalidError",
z.object({
path: z.string(),
message: z.string().optional(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
}),
)
export const NameMismatchError = NamedError.create(
"SkillNameMismatchError",
z.object({
path: z.string(),
expected: z.string(),
actual: z.string(),
}),
)
type State = {
skills: Record<string, Info>
dirs: Set<string>
task?: Promise<void>
}
type Cache = State & {
ensure: () => Promise<void>
}
export interface Interface {
readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly all: () => Effect.Effect<Info[]>
readonly dirs: () => Effect.Effect<string[]>
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
}
const add = async (state: State, match: string) => {
const md = await ConfigMarkdown.parse(match).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse skill ${match}`
const { Session } = await import("@/session")
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load skill", { skill: match, err })
return undefined
})
if (!md) return
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
if (!parsed.success) return
if (state.skills[parsed.data.name]) {
log.warn("duplicate skill name", {
name: parsed.data.name,
existing: state.skills[parsed.data.name].location,
duplicate: match,
})
}
state.dirs.add(path.dirname(match))
state.skills[parsed.data.name] = {
name: parsed.data.name,
description: parsed.data.description,
location: match,
content: md.content,
}
}
const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
return Glob.scan(pattern, {
cwd: root,
absolute: true,
include: "file",
symlink: true,
dot: opts?.dot,
})
.then((matches) => Promise.all(matches.map((match) => add(state, match))))
.catch((error) => {
if (!opts?.scope) throw error
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
})
}
// TODO: Migrate to Effect
const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => {
const state: State = {
skills: {},
dirs: new Set<string>(),
}
const load = async () => {
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
for (const dir of EXTERNAL_DIRS) {
const root = path.join(Global.Path.home, dir)
if (!(await Filesystem.isDir(root))) continue
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
}
for await (const root of Filesystem.up({
targets: EXTERNAL_DIRS,
start: instance.directory,
stop: instance.project.worktree,
})) {
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
}
}
for (const dir of await Config.directories()) {
await scan(state, dir, OPENCODE_SKILL_PATTERN)
}
const cfg = await Config.get()
for (const item of cfg.skills?.paths ?? []) {
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
if (!(await Filesystem.isDir(dir))) {
log.warn("skill path not found", { path: dir })
continue
}
await scan(state, dir, SKILL_PATTERN)
}
for (const url of cfg.skills?.urls ?? []) {
for (const dir of await Effect.runPromise(discovery.pull(url))) {
state.dirs.add(dir)
await scan(state, dir, SKILL_PATTERN)
}
}
log.info("init", { count: Object.keys(state.skills).length })
}
const ensure = () => {
if (state.task) return state.task
state.task = load().catch((err) => {
state.task = undefined
throw err
})
return state.task
}
return { ...state, ensure }
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
const discovery = yield* Discovery.Service
const state = create(instance, discovery)
const get = Effect.fn("Skill.get")(function* (name: string) {
yield* Effect.promise(() => state.ensure())
return state.skills[name]
})
const all = Effect.fn("Skill.all")(function* () {
yield* Effect.promise(() => state.ensure())
return Object.values(state.skills)
})
const dirs = Effect.fn("Skill.dirs")(function* () {
yield* Effect.promise(() => state.ensure())
return Array.from(state.dirs)
})
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
yield* Effect.promise(() => state.ensure())
const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name))
if (!agent) return list
return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
})
return Service.of({ get, all, dirs, available })
}),
).pipe(Layer.fresh)
export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = layer.pipe(
Layer.provide(Discovery.defaultLayer),
)
export function fmt(list: Info[], opts: { verbose: boolean }) {
if (list.length === 0) return "No skills are currently available."
if (opts.verbose) {
return [
"<available_skills>",
...list.flatMap((skill) => [
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
" </skill>",
]),
"</available_skills>",
].join("\n")
}
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
}
}
-35
View File
@@ -1,35 +0,0 @@
import { runPromiseInstance } from "@/effect/runtime"
import type { Agent } from "@/agent/agent"
import { Skill as S } from "./service"
export namespace Skill {
export const Info = S.Info
export type Info = S.Info
export const InvalidError = S.InvalidError
export const NameMismatchError = S.NameMismatchError
export type Interface = S.Interface
export const Service = S.Service
export const layer = S.layer
export const defaultLayer = S.defaultLayer
export const fmt = S.fmt
export async function get(name: string) {
return runPromiseInstance(S.Service.use((skill) => skill.get(name)))
}
export async function all() {
return runPromiseInstance(S.Service.use((skill) => skill.all()))
}
export async function dirs() {
return runPromiseInstance(S.Service.use((skill) => skill.dirs()))
}
export async function available(agent?: Agent.Info) {
return runPromiseInstance(S.Service.use((skill) => skill.available(agent)))
}
}
+369 -17
View File
@@ -1,44 +1,396 @@
import { runPromiseInstance } from "@/effect/runtime" import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
import { Snapshot as S } from "./service" import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path"
import z from "zod"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { Config } from "../config/config"
import { Global } from "../global"
import { Log } from "../util/log"
export namespace Snapshot { export namespace Snapshot {
export const Patch = S.Patch export const Patch = z.object({
export type Patch = S.Patch hash: z.string(),
files: z.string().array(),
})
export type Patch = z.infer<typeof Patch>
export const FileDiff = S.FileDiff export const FileDiff = z
export type FileDiff = S.FileDiff .object({
file: z.string(),
before: z.string(),
after: z.string(),
additions: z.number(),
deletions: z.number(),
status: z.enum(["added", "deleted", "modified"]).optional(),
})
.meta({
ref: "FileDiff",
})
export type FileDiff = z.infer<typeof FileDiff>
export type Interface = S.Interface const log = Log.create({ service: "snapshot" })
const prune = "7.days"
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
const cfg = ["-c", "core.autocrlf=false", ...core]
const quote = [...cfg, "-c", "core.quotepath=false"]
export const Service = S.Service interface GitResult {
export const layer = S.layer readonly code: ChildProcessSpawner.ExitCode
export const defaultLayer = S.defaultLayer readonly text: string
readonly stderr: string
}
type State = Omit<Interface, "init">
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly cleanup: () => Effect.Effect<void>
readonly track: () => Effect.Effect<string | undefined>
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
readonly restore: (snapshot: string) => Effect.Effect<void>
readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>
readonly diff: (hash: string) => Effect.Effect<string>
readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner> =
Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const state = yield* InstanceState.make<State>(
Effect.fn("Snapshot.state")(function* (ctx) {
const state = {
directory: ctx.directory,
worktree: ctx.worktree,
gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id),
vcs: ctx.project.vcs,
}
const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
const git = Effect.fnUntraced(
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
const proc = ChildProcess.make("git", cmd, {
cwd: opts?.cwd,
env: opts?.env,
extendEnv: true,
})
const handle = yield* spawner.spawn(proc)
const [text, stderr] = yield* Effect.all(
[
Stream.mkString(Stream.decodeText(handle.stdout)),
Stream.mkString(Stream.decodeText(handle.stderr)),
],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
return { code, text, stderr } satisfies GitResult
},
Effect.scoped,
Effect.catch((err) =>
Effect.succeed({
code: ChildProcessSpawner.ExitCode(1),
text: "",
stderr: String(err),
}),
),
)
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
const enabled = Effect.fnUntraced(function* () {
if (state.vcs !== "git") return false
return (yield* Effect.promise(() => Config.get())).snapshot !== false
})
const excludes = Effect.fnUntraced(function* () {
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
cwd: state.worktree,
})
const file = result.text.trim()
if (!file) return
if (!(yield* exists(file))) return
return file
})
const sync = Effect.fnUntraced(function* () {
const file = yield* excludes()
const target = path.join(state.gitdir, "info", "exclude")
yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
if (!file) {
yield* fs.writeFileString(target, "").pipe(Effect.orDie)
return
}
yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
})
const add = Effect.fnUntraced(function* () {
yield* sync()
yield* git([...cfg, ...args(["add", "."])], { cwd: state.directory })
})
const cleanup = Effect.fnUntraced(function* () {
if (!(yield* enabled())) return
if (!(yield* exists(state.gitdir))) return
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
if (result.code !== 0) {
log.warn("cleanup failed", {
exitCode: result.code,
stderr: result.stderr,
})
return
}
log.info("cleanup", { prune })
})
const track = Effect.fnUntraced(function* () {
if (!(yield* enabled())) return
const existed = yield* exists(state.gitdir)
yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
if (!existed) {
yield* git(["init"], {
env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
})
yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
log.info("initialized")
}
yield* add()
const result = yield* git(args(["write-tree"]), { cwd: state.directory })
const hash = result.text.trim()
log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
return hash
})
const patch = Effect.fnUntraced(function* (hash: string) {
yield* add()
const result = yield* git(
[...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])],
{
cwd: state.directory,
},
)
if (result.code !== 0) {
log.warn("failed to get diff", { hash, exitCode: result.code })
return { hash, files: [] }
}
return {
hash,
files: result.text
.trim()
.split("\n")
.map((x) => x.trim())
.filter(Boolean)
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
}
})
const restore = Effect.fnUntraced(function* (snapshot: string) {
log.info("restore", { commit: snapshot })
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
if (result.code === 0) {
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: state.worktree })
if (checkout.code === 0) return
log.error("failed to restore snapshot", {
snapshot,
exitCode: checkout.code,
stderr: checkout.stderr,
})
return
}
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.code,
stderr: result.stderr,
})
})
const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) {
const seen = new Set<string>()
for (const item of patches) {
for (const file of item.files) {
if (seen.has(file)) continue
seen.add(file)
log.info("reverting", { file, hash: item.hash })
const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], {
cwd: state.worktree,
})
if (result.code !== 0) {
const rel = path.relative(state.worktree, file)
const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], {
cwd: state.worktree,
})
if (tree.code === 0 && tree.text.trim()) {
log.info("file existed in snapshot but checkout failed, keeping", { file })
} else {
log.info("file did not exist in snapshot, deleting", { file })
yield* remove(file)
}
}
}
}
})
const diff = Effect.fnUntraced(function* (hash: string) {
yield* add()
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
cwd: state.worktree,
})
if (result.code !== 0) {
log.warn("failed to get diff", {
hash,
exitCode: result.code,
stderr: result.stderr,
})
return ""
}
return result.text.trim()
})
const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
const result: Snapshot.FileDiff[] = []
const status = new Map<string, "added" | "deleted" | "modified">()
const statuses = yield* git(
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
{ cwd: state.directory },
)
for (const line of statuses.text.trim().split("\n")) {
if (!line) continue
const [code, file] = line.split("\t")
if (!code || !file) continue
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
}
const numstat = yield* git(
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
{
cwd: state.directory,
},
)
for (const line of numstat.text.trim().split("\n")) {
if (!line) continue
const [adds, dels, file] = line.split("\t")
if (!file) continue
const binary = adds === "-" && dels === "-"
const [before, after] = binary
? ["", ""]
: yield* Effect.all(
[
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
],
{ concurrency: 2 },
)
const additions = binary ? 0 : parseInt(adds)
const deletions = binary ? 0 : parseInt(dels)
result.push({
file,
before,
after,
additions: Number.isFinite(additions) ? additions : 0,
deletions: Number.isFinite(deletions) ? deletions : 0,
status: status.get(file) ?? "modified",
})
}
return result
})
yield* cleanup().pipe(
Effect.catchCause((cause) => {
log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
return Effect.void
}),
Effect.repeat(Schedule.spaced(Duration.hours(1))),
Effect.delay(Duration.minutes(1)),
Effect.forkScoped,
)
return { cleanup, track, patch, restore, revert, diff, diffFull }
}),
)
return Service.of({
init: Effect.fn("Snapshot.init")(function* () {
yield* InstanceState.get(state)
}),
cleanup: Effect.fn("Snapshot.cleanup")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.cleanup())
}),
track: Effect.fn("Snapshot.track")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.track())
}),
patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
return yield* InstanceState.useEffect(state, (s) => s.patch(hash))
}),
restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) {
return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot))
}),
revert: Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
return yield* InstanceState.useEffect(state, (s) => s.revert(patches))
}),
diff: Effect.fn("Snapshot.diff")(function* (hash: string) {
return yield* InstanceState.useEffect(state, (s) => s.diff(hash))
}),
diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to))
}),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(NodeChildProcessSpawner.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
Layer.provide(NodePath.layer),
)
const runPromise = makeRunPromise(Service, defaultLayer)
export async function init() {
return runPromise((svc) => svc.init())
}
export async function cleanup() { export async function cleanup() {
return runPromiseInstance(S.Service.use((svc) => svc.cleanup())) return runPromise((svc) => svc.cleanup())
} }
export async function track() { export async function track() {
return runPromiseInstance(S.Service.use((svc) => svc.track())) return runPromise((svc) => svc.track())
} }
export async function patch(hash: string) { export async function patch(hash: string) {
return runPromiseInstance(S.Service.use((svc) => svc.patch(hash))) return runPromise((svc) => svc.patch(hash))
} }
export async function restore(snapshot: string) { export async function restore(snapshot: string) {
return runPromiseInstance(S.Service.use((svc) => svc.restore(snapshot))) return runPromise((svc) => svc.restore(snapshot))
} }
export async function revert(patches: Patch[]) { export async function revert(patches: Patch[]) {
return runPromiseInstance(S.Service.use((svc) => svc.revert(patches))) return runPromise((svc) => svc.revert(patches))
} }
export async function diff(hash: string) { export async function diff(hash: string) {
return runPromiseInstance(S.Service.use((svc) => svc.diff(hash))) return runPromise((svc) => svc.diff(hash))
} }
export async function diffFull(from: string, to: string) { export async function diffFull(from: string, to: string) {
return runPromiseInstance(S.Service.use((svc) => svc.diffFull(from, to))) return runPromise((svc) => svc.diffFull(from, to))
} }
} }
-320
View File
@@ -1,320 +0,0 @@
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path"
import z from "zod"
import { InstanceContext } from "@/effect/instance-context"
import { AppFileSystem } from "@/filesystem"
import { Config } from "../config/config"
import { Global } from "../global"
import { Log } from "../util/log"
export namespace Snapshot {
export const Patch = z.object({
hash: z.string(),
files: z.string().array(),
})
export type Patch = z.infer<typeof Patch>
export const FileDiff = z
.object({
file: z.string(),
before: z.string(),
after: z.string(),
additions: z.number(),
deletions: z.number(),
status: z.enum(["added", "deleted", "modified"]).optional(),
})
.meta({
ref: "FileDiff",
})
export type FileDiff = z.infer<typeof FileDiff>
const log = Log.create({ service: "snapshot" })
const prune = "7.days"
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
const cfg = ["-c", "core.autocrlf=false", ...core]
const quote = [...cfg, "-c", "core.quotepath=false"]
interface GitResult {
readonly code: ChildProcessSpawner.ExitCode
readonly text: string
readonly stderr: string
}
export interface Interface {
readonly cleanup: () => Effect.Effect<void>
readonly track: () => Effect.Effect<string | undefined>
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
readonly restore: (snapshot: string) => Effect.Effect<void>
readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>
readonly diff: (hash: string) => Effect.Effect<string>
readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
export const layer: Layer.Layer<
Service,
never,
InstanceContext | AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner
> = Layer.effect(
Service,
Effect.gen(function* () {
const ctx = yield* InstanceContext
const fs = yield* AppFileSystem.Service
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const directory = ctx.directory
const worktree = ctx.worktree
const project = ctx.project
const gitdir = path.join(Global.Path.data, "snapshot", project.id)
const args = (cmd: string[]) => ["--git-dir", gitdir, "--work-tree", worktree, ...cmd]
const git = Effect.fnUntraced(
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
const proc = ChildProcess.make("git", cmd, {
cwd: opts?.cwd,
env: opts?.env,
extendEnv: true,
})
const handle = yield* spawner.spawn(proc)
const [text, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
return { code, text, stderr } satisfies GitResult
},
Effect.scoped,
Effect.catch((err) =>
Effect.succeed({
code: ChildProcessSpawner.ExitCode(1),
text: "",
stderr: String(err),
}),
),
)
// Snapshot-specific error handling on top of AppFileSystem
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
const enabled = Effect.fnUntraced(function* () {
if (project.vcs !== "git") return false
return (yield* Effect.promise(() => Config.get())).snapshot !== false
})
const excludes = Effect.fnUntraced(function* () {
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
cwd: worktree,
})
const file = result.text.trim()
if (!file) return
if (!(yield* exists(file))) return
return file
})
const sync = Effect.fnUntraced(function* () {
const file = yield* excludes()
const target = path.join(gitdir, "info", "exclude")
yield* fs.ensureDir(path.join(gitdir, "info")).pipe(Effect.orDie)
if (!file) {
yield* fs.writeFileString(target, "").pipe(Effect.orDie)
return
}
yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
})
const add = Effect.fnUntraced(function* () {
yield* sync()
yield* git([...cfg, ...args(["add", "."])], { cwd: directory })
})
const cleanup = Effect.fn("Snapshot.cleanup")(function* () {
if (!(yield* enabled())) return
if (!(yield* exists(gitdir))) return
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: directory })
if (result.code !== 0) {
log.warn("cleanup failed", {
exitCode: result.code,
stderr: result.stderr,
})
return
}
log.info("cleanup", { prune })
})
const track = Effect.fn("Snapshot.track")(function* () {
if (!(yield* enabled())) return
const existed = yield* exists(gitdir)
yield* fs.ensureDir(gitdir).pipe(Effect.orDie)
if (!existed) {
yield* git(["init"], {
env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree },
})
yield* git(["--git-dir", gitdir, "config", "core.autocrlf", "false"])
yield* git(["--git-dir", gitdir, "config", "core.longpaths", "true"])
yield* git(["--git-dir", gitdir, "config", "core.symlinks", "true"])
yield* git(["--git-dir", gitdir, "config", "core.fsmonitor", "false"])
log.info("initialized")
}
yield* add()
const result = yield* git(args(["write-tree"]), { cwd: directory })
const hash = result.text.trim()
log.info("tracking", { hash, cwd: directory, git: gitdir })
return hash
})
const patch = Effect.fn("Snapshot.patch")(function* (hash: string) {
yield* add()
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], {
cwd: directory,
})
if (result.code !== 0) {
log.warn("failed to get diff", { hash, exitCode: result.code })
return { hash, files: [] }
}
return {
hash,
files: result.text
.trim()
.split("\n")
.map((x) => x.trim())
.filter(Boolean)
.map((x) => path.join(worktree, x).replaceAll("\\", "/")),
}
})
const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) {
log.info("restore", { commit: snapshot })
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: worktree })
if (result.code === 0) {
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: worktree })
if (checkout.code === 0) return
log.error("failed to restore snapshot", {
snapshot,
exitCode: checkout.code,
stderr: checkout.stderr,
})
return
}
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.code,
stderr: result.stderr,
})
})
const revert = Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
const seen = new Set<string>()
for (const item of patches) {
for (const file of item.files) {
if (seen.has(file)) continue
seen.add(file)
log.info("reverting", { file, hash: item.hash })
const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], { cwd: worktree })
if (result.code !== 0) {
const rel = path.relative(worktree, file)
const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], { cwd: worktree })
if (tree.code === 0 && tree.text.trim()) {
log.info("file existed in snapshot but checkout failed, keeping", { file })
} else {
log.info("file did not exist in snapshot, deleting", { file })
yield* remove(file)
}
}
}
}
})
const diff = Effect.fn("Snapshot.diff")(function* (hash: string) {
yield* add()
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
cwd: worktree,
})
if (result.code !== 0) {
log.warn("failed to get diff", {
hash,
exitCode: result.code,
stderr: result.stderr,
})
return ""
}
return result.text.trim()
})
const diffFull = Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
const result: Snapshot.FileDiff[] = []
const status = new Map<string, "added" | "deleted" | "modified">()
const statuses = yield* git(
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
{ cwd: directory },
)
for (const line of statuses.text.trim().split("\n")) {
if (!line) continue
const [code, file] = line.split("\t")
if (!code || !file) continue
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
}
const numstat = yield* git(
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
{
cwd: directory,
},
)
for (const line of numstat.text.trim().split("\n")) {
if (!line) continue
const [adds, dels, file] = line.split("\t")
if (!file) continue
const binary = adds === "-" && dels === "-"
const [before, after] = binary
? ["", ""]
: yield* Effect.all(
[
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
],
{ concurrency: 2 },
)
const additions = binary ? 0 : parseInt(adds)
const deletions = binary ? 0 : parseInt(dels)
result.push({
file,
before,
after,
additions: Number.isFinite(additions) ? additions : 0,
deletions: Number.isFinite(deletions) ? deletions : 0,
status: status.get(file) ?? "modified",
})
}
return result
})
yield* cleanup().pipe(
Effect.catchCause((cause) => {
log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
return Effect.void
}),
Effect.repeat(Schedule.spaced(Duration.hours(1))),
Effect.delay(Duration.minutes(1)),
Effect.forkScoped,
)
return Service.of({ cleanup, track, patch, restore, revert, diff, diffFull })
}),
).pipe(Layer.fresh)
export const defaultLayer = layer.pipe(
Layer.provide(NodeChildProcessSpawner.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
Layer.provide(NodePath.layer),
)
}
+4
View File
@@ -28,6 +28,10 @@ const log = Log.create({ service: "db" })
export namespace Database { export namespace Database {
export const Path = iife(() => { export const Path = iife(() => {
if (Flag.OPENCODE_DB) {
if (path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
return path.join(Global.Path.data, Flag.OPENCODE_DB)
}
const channel = Installation.CHANNEL const channel = Installation.CHANNEL
if (["latest", "beta"].includes(channel) || Flag.OPENCODE_DISABLE_CHANNEL_DB) if (["latest", "beta"].includes(channel) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
return path.join(Global.Path.data, "opencode.db") return path.join(Global.Path.data, "opencode.db")
+2 -2
View File
@@ -10,7 +10,7 @@ import { SessionPrompt } from "../session/prompt"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { defer } from "@/util/defer" import { defer } from "@/util/defer"
import { Config } from "../config/config" import { Config } from "../config/config"
import { PermissionNext } from "@/permission" import { Permission } from "@/permission"
const parameters = z.object({ const parameters = z.object({
description: z.string().describe("A short (3-5 words) description of the task"), description: z.string().describe("A short (3-5 words) description of the task"),
@@ -31,7 +31,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
// Filter agents by permissions if agent provided // Filter agents by permissions if agent provided
const caller = ctx?.agent const caller = ctx?.agent
const accessibleAgents = caller const accessibleAgents = caller
? agents.filter((a) => PermissionNext.evaluate("task", a.name, caller.permission).action !== "deny") ? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny")
: agents : agents
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name)) const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
+2 -2
View File
@@ -1,7 +1,7 @@
import z from "zod" import z from "zod"
import type { MessageV2 } from "../session/message-v2" import type { MessageV2 } from "../session/message-v2"
import type { Agent } from "../agent/agent" import type { Agent } from "../agent/agent"
import type { PermissionNext } from "../permission" import type { Permission } from "../permission"
import type { SessionID, MessageID } from "../session/schema" import type { SessionID, MessageID } from "../session/schema"
import { Truncate } from "./truncate" import { Truncate } from "./truncate"
@@ -23,7 +23,7 @@ export namespace Tool {
extra?: { [key: string]: any } extra?: { [key: string]: any }
messages: MessageV2.WithParts[] messages: MessageV2.WithParts[]
metadata(input: { title?: string; metadata?: M }): void metadata(input: { title?: string; metadata?: M }): void
ask(input: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">): Promise<void> ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Promise<void>
} }
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> { export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string id: string
@@ -1,137 +0,0 @@
import { NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
import path from "path"
import type { Agent } from "../agent/agent"
import { AppFileSystem } from "@/filesystem"
import { evaluate } from "@/permission/evaluate"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
import { ToolID } from "./schema"
import { TRUNCATION_DIR } from "./truncation-dir"
export namespace Truncate {
const log = Log.create({ service: "truncation" })
const RETENTION = Duration.days(7)
export const MAX_LINES = 2000
export const MAX_BYTES = 50 * 1024
export const DIR = TRUNCATION_DIR
export const GLOB = path.join(TRUNCATION_DIR, "*")
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
export interface Options {
maxLines?: number
maxBytes?: number
direction?: "head" | "tail"
}
function hasTaskTool(agent?: Agent.Info) {
if (!agent?.permission) return false
return evaluate("task", "*", agent.permission).action !== "deny"
}
export interface Interface {
readonly cleanup: () => Effect.Effect<void>
/**
* Returns output unchanged when it fits within the limits, otherwise writes the full text
* to the truncation directory and returns a preview plus a hint to inspect the saved file.
*/
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Truncate") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
Effect.catch(() => Effect.succeed([])),
)
for (const entry of entries) {
if (Identifier.timestamp(entry) >= cutoff) continue
yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
}
})
const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
const maxLines = options.maxLines ?? MAX_LINES
const maxBytes = options.maxBytes ?? MAX_BYTES
const direction = options.direction ?? "head"
const lines = text.split("\n")
const totalBytes = Buffer.byteLength(text, "utf-8")
if (lines.length <= maxLines && totalBytes <= maxBytes) {
return { content: text, truncated: false } as const
}
const out: string[] = []
let i = 0
let bytes = 0
let hitBytes = false
if (direction === "head") {
for (i = 0; i < lines.length && i < maxLines; i++) {
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
out.push(lines[i])
bytes += size
}
} else {
for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
out.unshift(lines[i])
bytes += size
}
}
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
const unit = hitBytes ? "bytes" : "lines"
const preview = out.join("\n")
const file = path.join(TRUNCATION_DIR, ToolID.ascending())
yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
const hint = hasTaskTool(agent)
? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
: `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
return {
content:
direction === "head"
? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
: `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
truncated: true,
outputPath: file,
} as const
})
yield* cleanup().pipe(
Effect.catchCause((cause) => {
log.error("truncation cleanup failed", { cause: Cause.pretty(cause) })
return Effect.void
}),
Effect.repeat(Schedule.spaced(Duration.hours(1))),
Effect.delay(Duration.minutes(1)),
Effect.forkScoped,
)
return Service.of({ cleanup, output })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
}
+135 -9
View File
@@ -1,18 +1,144 @@
import { NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
import path from "path"
import type { Agent } from "../agent/agent" import type { Agent } from "../agent/agent"
import { runtime } from "@/effect/runtime" import { makeRunPromise } from "@/effect/run-service"
import { Truncate as S } from "./truncate-effect" import { AppFileSystem } from "@/filesystem"
import { evaluate } from "@/permission/evaluate"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
import { ToolID } from "./schema"
import { TRUNCATION_DIR } from "./truncation-dir"
export namespace Truncate { export namespace Truncate {
export const MAX_LINES = S.MAX_LINES const log = Log.create({ service: "truncation" })
export const MAX_BYTES = S.MAX_BYTES const RETENTION = Duration.days(7)
export const DIR = S.DIR
export const GLOB = S.GLOB
export type Result = S.Result export const MAX_LINES = 2000
export const MAX_BYTES = 50 * 1024
export const DIR = TRUNCATION_DIR
export const GLOB = path.join(TRUNCATION_DIR, "*")
export type Options = S.Options export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
export interface Options {
maxLines?: number
maxBytes?: number
direction?: "head" | "tail"
}
function hasTaskTool(agent?: Agent.Info) {
if (!agent?.permission) return false
return evaluate("task", "*", agent.permission).action !== "deny"
}
export interface Interface {
readonly cleanup: () => Effect.Effect<void>
/**
* Returns output unchanged when it fits within the limits, otherwise writes the full text
* to the truncation directory and returns a preview plus a hint to inspect the saved file.
*/
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Truncate") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
Effect.catch(() => Effect.succeed([])),
)
for (const entry of entries) {
if (Identifier.timestamp(entry) >= cutoff) continue
yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
}
})
const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
const maxLines = options.maxLines ?? MAX_LINES
const maxBytes = options.maxBytes ?? MAX_BYTES
const direction = options.direction ?? "head"
const lines = text.split("\n")
const totalBytes = Buffer.byteLength(text, "utf-8")
if (lines.length <= maxLines && totalBytes <= maxBytes) {
return { content: text, truncated: false } as const
}
const out: string[] = []
let i = 0
let bytes = 0
let hitBytes = false
if (direction === "head") {
for (i = 0; i < lines.length && i < maxLines; i++) {
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
out.push(lines[i])
bytes += size
}
} else {
for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
out.unshift(lines[i])
bytes += size
}
}
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
const unit = hitBytes ? "bytes" : "lines"
const preview = out.join("\n")
const file = path.join(TRUNCATION_DIR, ToolID.ascending())
yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
const hint = hasTaskTool(agent)
? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
: `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
return {
content:
direction === "head"
? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
: `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
truncated: true,
outputPath: file,
} as const
})
yield* cleanup().pipe(
Effect.catchCause((cause) => {
log.error("truncation cleanup failed", { cause: Cause.pretty(cause) })
return Effect.void
}),
Effect.repeat(Schedule.spaced(Duration.hours(1))),
Effect.delay(Duration.minutes(1)),
Effect.forkScoped,
)
return Service.of({ cleanup, output })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
const runPromise = makeRunPromise(Service, defaultLayer)
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> { export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
return runtime.runPromise(S.Service.use((s) => s.output(text, options, agent))) return runPromise((s) => s.output(text, options, agent))
} }
} }
+6
View File
@@ -60,6 +60,12 @@ function union(ast: SchemaAST.Union): z.ZodTypeAny {
const items = ast.types.map(walk) const items = ast.types.map(walk)
if (items.length === 1) return items[0] if (items.length === 1) return items[0]
if (items.length < 2) return fail(ast) if (items.length < 2) return fail(ast)
const discriminator = (ast as any).annotations?.discriminator
if (discriminator) {
return z.discriminatedUnion(discriminator, items as [z.ZodObject<any>, z.ZodObject<any>, ...z.ZodObject<any>[]])
}
return z.union(items as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>]) return z.union(items as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>])
} }
@@ -3,7 +3,7 @@ import { Duration, Effect, Layer, Option, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AccountRepo } from "../../src/account/repo" import { AccountRepo } from "../../src/account/repo"
import { Account } from "../../src/account/effect" import { Account } from "../../src/account"
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema" import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
import { Database } from "../../src/storage/db" import { Database } from "../../src/storage/db"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
+21 -17
View File
@@ -1,16 +1,20 @@
import { test, expect } from "bun:test" import { afterEach, test, expect } from "bun:test"
import path from "path" import path from "path"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { PermissionNext } from "../../src/permission" import { Permission } from "../../src/permission"
// Helper to evaluate permission for a tool with wildcard pattern // Helper to evaluate permission for a tool with wildcard pattern
function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined { function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined {
if (!agent) return undefined if (!agent) return undefined
return PermissionNext.evaluate(permission, "*", agent.permission).action return Permission.evaluate(permission, "*", agent.permission).action
} }
afterEach(async () => {
await Instance.disposeAll()
})
test("returns default native agents when no config", async () => { test("returns default native agents when no config", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
await Instance.provide({ await Instance.provide({
@@ -54,7 +58,7 @@ test("plan agent denies edits except .opencode/plans/*", async () => {
// Wildcard is denied // Wildcard is denied
expect(evalPerm(plan, "edit")).toBe("deny") expect(evalPerm(plan, "edit")).toBe("deny")
// But specific path is allowed // But specific path is allowed
expect(PermissionNext.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow") expect(Permission.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow")
}, },
}) })
}) })
@@ -83,8 +87,8 @@ test("explore agent asks for external directories and allows Truncate.GLOB", asy
fn: async () => { fn: async () => {
const explore = await Agent.get("explore") const explore = await Agent.get("explore")
expect(explore).toBeDefined() expect(explore).toBeDefined()
expect(PermissionNext.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask") expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask")
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow") expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow")
}, },
}) })
}) })
@@ -216,7 +220,7 @@ test("agent permission config merges with defaults", async () => {
const build = await Agent.get("build") const build = await Agent.get("build")
expect(build).toBeDefined() expect(build).toBeDefined()
// Specific pattern is denied // Specific pattern is denied
expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny") expect(Permission.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
// Edit still allowed // Edit still allowed
expect(evalPerm(build, "edit")).toBe("allow") expect(evalPerm(build, "edit")).toBe("allow")
}, },
@@ -501,9 +505,9 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const build = await Agent.get("build") const build = await Agent.get("build")
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny") expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
}, },
}) })
}) })
@@ -525,9 +529,9 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const build = await Agent.get("build") const build = await Agent.get("build")
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny") expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
}, },
}) })
}) })
@@ -548,8 +552,8 @@ test("explicit Truncate.GLOB deny is respected", async () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const build = await Agent.get("build") const build = await Agent.get("build")
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny") expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny")
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
}, },
}) })
}) })
@@ -582,7 +586,7 @@ description: Permission skill.
const build = await Agent.get("build") const build = await Agent.get("build")
const skillDir = path.join(tmp.path, ".opencode", "skill", "perm-skill") const skillDir = path.join(tmp.path, ".opencode", "skill", "perm-skill")
const target = path.join(skillDir, "reference", "notes.md") const target = path.join(skillDir, "reference", "notes.md")
expect(PermissionNext.evaluate("external_directory", target, build!.permission).action).toBe("allow") expect(Permission.evaluate("external_directory", target, build!.permission).action).toBe("allow")
}, },
}) })
} finally { } finally {
+1 -1
View File
@@ -251,7 +251,7 @@ test("resolves env templates in account config with account token", async () =>
const originalToken = Account.token const originalToken = Account.token
const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"] const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"]
Account.active = mock(() => ({ Account.active = mock(async () => ({
id: AccountID.make("account-1"), id: AccountID.make("account-1"),
email: "user@example.com", email: "user@example.com",
url: "https://control.example.com", url: "https://control.example.com",
@@ -0,0 +1,384 @@
import { afterEach, expect, test } from "bun:test"
import { Duration, Effect, Layer, ManagedRuntime, ServiceMap } from "effect"
import { InstanceState } from "../../src/effect/instance-state"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
async function access<A, E>(state: InstanceState<A, E>, dir: string) {
return Instance.provide({
directory: dir,
fn: () => Effect.runPromise(InstanceState.get(state)),
})
}
afterEach(async () => {
await Instance.disposeAll()
})
test("InstanceState caches values per directory", async () => {
await using tmp = await tmpdir()
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n })))
const a = yield* Effect.promise(() => access(state, tmp.path))
const b = yield* Effect.promise(() => access(state, tmp.path))
expect(a).toBe(b)
expect(n).toBe(1)
}),
),
)
})
test("InstanceState isolates directories", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n })))
const a = yield* Effect.promise(() => access(state, one.path))
const b = yield* Effect.promise(() => access(state, two.path))
const c = yield* Effect.promise(() => access(state, one.path))
expect(a).toBe(c)
expect(a).not.toBe(b)
expect(n).toBe(2)
}),
),
)
})
test("InstanceState invalidates on reload", async () => {
await using tmp = await tmpdir()
const seen: string[] = []
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make(() =>
Effect.acquireRelease(
Effect.sync(() => ({ n: ++n })),
(value) =>
Effect.sync(() => {
seen.push(String(value.n))
}),
),
)
const a = yield* Effect.promise(() => access(state, tmp.path))
yield* Effect.promise(() => Instance.reload({ directory: tmp.path }))
const b = yield* Effect.promise(() => access(state, tmp.path))
expect(a).not.toBe(b)
expect(seen).toEqual(["1"])
}),
),
)
})
test("InstanceState invalidates on disposeAll", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
const seen: string[] = []
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) =>
Effect.acquireRelease(
Effect.sync(() => ({ dir: ctx.directory })),
(value) =>
Effect.sync(() => {
seen.push(value.dir)
}),
),
)
yield* Effect.promise(() => access(state, one.path))
yield* Effect.promise(() => access(state, two.path))
yield* Effect.promise(() => Instance.disposeAll())
expect(seen.sort()).toEqual([one.path, two.path].sort())
}),
),
)
})
test("InstanceState.get reads the current directory lazily", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
interface Api {
readonly get: () => Effect.Effect<string>
}
class Test extends ServiceMap.Service<Test, Api>()("@test/InstanceStateLazy") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
const get = InstanceState.get(state)
return Test.of({
get: Effect.fn("Test.get")(function* () {
return yield* get
}),
})
}),
)
}
const rt = ManagedRuntime.make(Test.layer)
try {
const a = await Instance.provide({
directory: one.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
})
const b = await Instance.provide({
directory: two.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
})
expect(a).toBe(one.path)
expect(b).toBe(two.path)
} finally {
await rt.dispose()
}
})
test("InstanceState preserves directory across async boundaries", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
await using three = await tmpdir({ git: true })
interface Api {
readonly get: () => Effect.Effect<{ directory: string; worktree: string; project: string }>
}
class Test extends ServiceMap.Service<Test, Api>()("@test/InstanceStateAsync") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) =>
Effect.sync(() => ({
directory: ctx.directory,
worktree: ctx.worktree,
project: ctx.project.id,
})),
)
return Test.of({
get: Effect.fn("Test.get")(function* () {
yield* Effect.promise(() => Bun.sleep(1))
yield* Effect.sleep(Duration.millis(1))
for (let i = 0; i < 100; i++) {
yield* Effect.yieldNow
}
for (let i = 0; i < 100; i++) {
yield* Effect.promise(() => Promise.resolve())
}
yield* Effect.sleep(Duration.millis(2))
yield* Effect.promise(() => Bun.sleep(1))
return yield* InstanceState.get(state)
}),
})
}),
)
}
const rt = ManagedRuntime.make(Test.layer)
try {
const [a, b, c] = await Promise.all([
Instance.provide({
directory: one.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
Instance.provide({
directory: two.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
Instance.provide({
directory: three.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
])
expect(a).toEqual({ directory: one.path, worktree: one.path, project: a.project })
expect(b).toEqual({ directory: two.path, worktree: two.path, project: b.project })
expect(c).toEqual({ directory: three.path, worktree: three.path, project: c.project })
expect(a.project).not.toBe(b.project)
expect(a.project).not.toBe(c.project)
expect(b.project).not.toBe(c.project)
} finally {
await rt.dispose()
}
})
test("InstanceState survives high-contention concurrent access", async () => {
const N = 20
const dirs = await Promise.all(Array.from({ length: N }, () => tmpdir()))
interface Api {
readonly get: () => Effect.Effect<string>
}
class Test extends ServiceMap.Service<Test, Api>()("@test/HighContention") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
return Test.of({
get: Effect.fn("Test.get")(function* () {
// Interleave many async hops to maximize chance of ALS corruption
for (let i = 0; i < 10; i++) {
yield* Effect.promise(() => Bun.sleep(Math.random() * 3))
yield* Effect.yieldNow
yield* Effect.promise(() => Promise.resolve())
}
return yield* InstanceState.get(state)
}),
})
}),
)
}
const rt = ManagedRuntime.make(Test.layer)
try {
const results = await Promise.all(
dirs.map((d) =>
Instance.provide({
directory: d.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
),
)
for (let i = 0; i < N; i++) {
expect(results[i]).toBe(dirs[i].path)
}
} finally {
await rt.dispose()
for (const d of dirs) await d[Symbol.asyncDispose]()
}
})
test("InstanceState correct after interleaved init and dispose", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
interface Api {
readonly get: () => Effect.Effect<string>
}
class Test extends ServiceMap.Service<Test, Api>()("@test/InterleavedDispose") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) =>
Effect.promise(async () => {
await Bun.sleep(5) // slow init
return ctx.directory
}),
)
return Test.of({
get: Effect.fn("Test.get")(function* () {
return yield* InstanceState.get(state)
}),
})
}),
)
}
const rt = ManagedRuntime.make(Test.layer)
try {
// Init both directories
const a = await Instance.provide({
directory: one.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
})
expect(a).toBe(one.path)
// Dispose one directory, access the other concurrently
const [, b] = await Promise.all([
Instance.reload({ directory: one.path }),
Instance.provide({
directory: two.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
])
expect(b).toBe(two.path)
// Re-access disposed directory - should get fresh state
const c = await Instance.provide({
directory: one.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
})
expect(c).toBe(one.path)
} finally {
await rt.dispose()
}
})
test("InstanceState mutation in one directory does not leak to another", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make(() => Effect.sync(() => ({ count: 0 })))
// Mutate state in directory one
const s1 = yield* Effect.promise(() => access(state, one.path))
s1.count = 42
// Access directory two — should be independent
const s2 = yield* Effect.promise(() => access(state, two.path))
expect(s2.count).toBe(0)
// Confirm directory one still has the mutation
const s1again = yield* Effect.promise(() => access(state, one.path))
expect(s1again.count).toBe(42)
expect(s1again).toBe(s1) // same reference
}),
),
)
})
test("InstanceState dedupes concurrent lookups", async () => {
await using tmp = await tmpdir()
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make(() =>
Effect.promise(async () => {
n += 1
await Bun.sleep(10)
return { n }
}),
)
const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)]))
expect(a).toBe(b)
expect(n).toBe(1)
}),
),
)
})
@@ -0,0 +1,46 @@
import { expect, test } from "bun:test"
import { Effect, Layer, ServiceMap } from "effect"
import { makeRunPromise } from "../../src/effect/run-service"
class Shared extends ServiceMap.Service<Shared, { readonly id: number }>()("@test/Shared") {}
test("makeRunPromise shares dependent layers through the shared memo map", async () => {
let n = 0
const shared = Layer.effect(
Shared,
Effect.sync(() => {
n += 1
return Shared.of({ id: n })
}),
)
class One extends ServiceMap.Service<One, { readonly get: () => Effect.Effect<number> }>()("@test/One") {}
const one = Layer.effect(
One,
Effect.gen(function* () {
const svc = yield* Shared
return One.of({
get: Effect.fn("One.get")(() => Effect.succeed(svc.id)),
})
}),
).pipe(Layer.provide(shared))
class Two extends ServiceMap.Service<Two, { readonly get: () => Effect.Effect<number> }>()("@test/Two") {}
const two = Layer.effect(
Two,
Effect.gen(function* () {
const svc = yield* Shared
return Two.of({
get: Effect.fn("Two.get")(() => Effect.succeed(svc.id)),
})
}),
).pipe(Layer.provide(shared))
const runOne = makeRunPromise(One, one)
const runTwo = makeRunPromise(Two, two)
expect(await runOne((svc) => svc.get())).toBe(1)
expect(await runTwo((svc) => svc.get())).toBe(1)
expect(n).toBe(1)
})
@@ -1,128 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { runtime, runPromiseInstance } from "../../src/effect/runtime"
import { Auth } from "../../src/auth/effect"
import { Instances } from "../../src/effect/instances"
import { Instance } from "../../src/project/instance"
import { ProviderAuth } from "../../src/provider/auth"
import { Vcs } from "../../src/project/vcs"
import { Question } from "../../src/question"
import { tmpdir } from "../fixture/fixture"
/**
* Integration tests for the Effect runtime and LayerMap-based instance system.
*
* Each instance service layer has `.pipe(Layer.fresh)` at its definition site
* so it is always rebuilt per directory, while shared dependencies are provided
* outside the fresh boundary and remain memoizable.
*
* These tests verify the invariants using object identity (===) on the real
* production services not mock services or return-value checks.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const grabInstance = (service: any) => runPromiseInstance(service.use(Effect.succeed))
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const grabGlobal = (service: any) => runtime.runPromise(service.use(Effect.succeed))
describe("effect/runtime", () => {
afterEach(async () => {
await Instance.disposeAll()
})
test("global services are shared across directories", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
// Auth is a global service — it should be the exact same object
// regardless of which directory we're in.
const authOne = await Instance.provide({
directory: one.path,
fn: () => grabGlobal(Auth.Service),
})
const authTwo = await Instance.provide({
directory: two.path,
fn: () => grabGlobal(Auth.Service),
})
expect(authOne).toBe(authTwo)
})
test("instance services with global deps share the global (ProviderAuth → Auth)", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
// ProviderAuth depends on Auth via defaultLayer.
// The instance service itself should be different per directory,
// but the underlying Auth should be shared.
const paOne = await Instance.provide({
directory: one.path,
fn: () => grabInstance(ProviderAuth.Service),
})
const paTwo = await Instance.provide({
directory: two.path,
fn: () => grabInstance(ProviderAuth.Service),
})
// Different directories → different ProviderAuth instances.
expect(paOne).not.toBe(paTwo)
// But the global Auth is the same object in both.
const authOne = await Instance.provide({
directory: one.path,
fn: () => grabGlobal(Auth.Service),
})
const authTwo = await Instance.provide({
directory: two.path,
fn: () => grabGlobal(Auth.Service),
})
expect(authOne).toBe(authTwo)
})
test("instance services are shared within the same directory", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
expect(await grabInstance(Vcs.Service)).toBe(await grabInstance(Vcs.Service))
expect(await grabInstance(Question.Service)).toBe(await grabInstance(Question.Service))
},
})
})
test("different directories get different service instances", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
const vcsOne = await Instance.provide({
directory: one.path,
fn: () => grabInstance(Vcs.Service),
})
const vcsTwo = await Instance.provide({
directory: two.path,
fn: () => grabInstance(Vcs.Service),
})
expect(vcsOne).not.toBe(vcsTwo)
})
test("disposal rebuilds services with a new instance", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await grabInstance(Question.Service)
await runtime.runPromise(Instances.use((map) => map.invalidate(Instance.directory)))
const after = await grabInstance(Question.Service)
expect(after).not.toBe(before)
},
})
})
})
+95 -1
View File
@@ -1,4 +1,4 @@
import { describe, test, expect } from "bun:test" import { afterEach, describe, test, expect } from "bun:test"
import { $ } from "bun" import { $ } from "bun"
import path from "path" import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
@@ -7,6 +7,10 @@ import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
afterEach(async () => {
await Instance.disposeAll()
})
describe("file/index Filesystem patterns", () => { describe("file/index Filesystem patterns", () => {
describe("File.read() - text content", () => { describe("File.read() - text content", () => {
test("reads text file via Filesystem.readText()", async () => { test("reads text file via Filesystem.readText()", async () => {
@@ -689,6 +693,18 @@ describe("file/index Filesystem patterns", () => {
}) })
}) })
test("search works before explicit init", async () => {
await using tmp = await setupSearchableRepo()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.search({ query: "main", type: "file" })
expect(result.some((f) => f.includes("main"))).toBe(true)
},
})
})
test("empty query returns dirs sorted with hidden last", async () => { test("empty query returns dirs sorted with hidden last", async () => {
await using tmp = await setupSearchableRepo() await using tmp = await setupSearchableRepo()
@@ -785,6 +801,23 @@ describe("file/index Filesystem patterns", () => {
}, },
}) })
}) })
test("search refreshes after init when files change", async () => {
await using tmp = await setupSearchableRepo()
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
expect(await File.search({ query: "fresh", type: "file" })).toEqual([])
await fs.writeFile(path.join(tmp.path, "fresh.ts"), "fresh", "utf-8")
const result = await File.search({ query: "fresh", type: "file" })
expect(result).toContain("fresh.ts")
},
})
})
}) })
describe("File.read() - diff/patch", () => { describe("File.read() - diff/patch", () => {
@@ -849,4 +882,65 @@ describe("file/index Filesystem patterns", () => {
}) })
}) })
}) })
describe("InstanceState isolation", () => {
test("two directories get independent file caches", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
await fs.writeFile(path.join(one.path, "a.ts"), "one", "utf-8")
await fs.writeFile(path.join(two.path, "b.ts"), "two", "utf-8")
await Instance.provide({
directory: one.path,
fn: async () => {
await File.init()
const results = await File.search({ query: "a.ts", type: "file" })
expect(results).toContain("a.ts")
const results2 = await File.search({ query: "b.ts", type: "file" })
expect(results2).not.toContain("b.ts")
},
})
await Instance.provide({
directory: two.path,
fn: async () => {
await File.init()
const results = await File.search({ query: "b.ts", type: "file" })
expect(results).toContain("b.ts")
const results2 = await File.search({ query: "a.ts", type: "file" })
expect(results2).not.toContain("a.ts")
},
})
})
test("disposal gives fresh state on next access", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "before.ts"), "before", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
const results = await File.search({ query: "before", type: "file" })
expect(results).toContain("before.ts")
},
})
await Instance.disposeAll()
await fs.writeFile(path.join(tmp.path, "after.ts"), "after", "utf-8")
await fs.rm(path.join(tmp.path, "before.ts"))
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
const results = await File.search({ query: "after", type: "file" })
expect(results).toContain("after.ts")
const stale = await File.search({ query: "before", type: "file" })
expect(stale).not.toContain("before.ts")
},
})
})
})
}) })
+25 -1
View File
@@ -7,7 +7,9 @@ import { SessionID } from "../../src/session/schema"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
afterEach(() => Instance.disposeAll()) afterEach(async () => {
await Instance.disposeAll()
})
async function touch(file: string, time: number) { async function touch(file: string, time: number) {
const date = new Date(time) const date = new Date(time)
@@ -84,6 +86,28 @@ describe("file/time", () => {
}, },
}) })
}) })
test("isolates reads by directory", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
await using shared = await tmpdir()
const filepath = path.join(shared.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: one.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
},
})
await Instance.provide({
directory: two.path,
fn: async () => {
expect(await FileTime.get(sessionID, filepath)).toBeUndefined()
},
})
})
}) })
describe("assert()", () => { describe("assert()", () => {
+11 -15
View File
@@ -5,9 +5,9 @@ import path from "path"
import { Deferred, Effect, Option } from "effect" import { Deferred, Effect, Option } from "effect"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { watcherConfigLayer, withServices } from "../fixture/instance" import { watcherConfigLayer, withServices } from "../fixture/instance"
import { Bus } from "../../src/bus"
import { FileWatcher } from "../../src/file/watcher" import { FileWatcher } from "../../src/file/watcher"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { GlobalBus } from "../../src/bus/global"
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows) // Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
@@ -16,7 +16,6 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
/** Run `body` with a live FileWatcher service. */ /** Run `body` with a live FileWatcher service. */
@@ -25,7 +24,7 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
directory, directory,
FileWatcher.layer, FileWatcher.layer,
async (rt) => { async (rt) => {
await rt.runPromise(FileWatcher.Service.use(() => Effect.void)) await rt.runPromise(FileWatcher.Service.use((s) => s.init()))
await Effect.runPromise(ready(directory)) await Effect.runPromise(ready(directory))
await Effect.runPromise(body) await Effect.runPromise(body)
}, },
@@ -36,22 +35,17 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) { function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
let done = false let done = false
function on(evt: BusUpdate) { const unsub = Bus.subscribe(FileWatcher.Event.Updated, (evt) => {
if (done) return if (done) return
if (evt.directory !== directory) return if (!check(evt.properties)) return
if (evt.payload.type !== FileWatcher.Event.Updated.type) return hit(evt.properties)
if (!check(evt.payload.properties)) return })
hit(evt.payload.properties)
}
function cleanup() { return () => {
if (done) return if (done) return
done = true done = true
GlobalBus.off("event", on) unsub()
} }
GlobalBus.on("event", on)
return cleanup
} }
function wait(directory: string, check: (evt: WatcherEvent) => boolean) { function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
@@ -136,7 +130,9 @@ function ready(directory: string) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describeWatcher("FileWatcher", () => { describeWatcher("FileWatcher", () => {
afterEach(() => Instance.disposeAll()) afterEach(async () => {
await Instance.disposeAll()
})
test("publishes root create, update, and delete events", async () => { test("publishes root create, update, and delete events", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
+108 -1
View File
@@ -2,11 +2,16 @@ import { Effect } from "effect"
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { withServices } from "../fixture/instance" import { withServices } from "../fixture/instance"
import { Bus } from "../../src/bus"
import { File } from "../../src/file"
import { Format } from "../../src/format" import { Format } from "../../src/format"
import * as Formatter from "../../src/format/formatter"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
describe("Format", () => { describe("Format", () => {
afterEach(() => Instance.disposeAll()) afterEach(async () => {
await Instance.disposeAll()
})
test("status() returns built-in formatters when no config overrides", async () => { test("status() returns built-in formatters when no config overrides", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
@@ -62,4 +67,106 @@ describe("Format", () => {
await rt.runPromise(Format.Service.use(() => Effect.void)) await rt.runPromise(Format.Service.use(() => Effect.void))
}) })
}) })
test("status() initializes formatter state per directory", async () => {
await using off = await tmpdir({
config: { formatter: false },
})
await using on = await tmpdir()
const a = await Instance.provide({
directory: off.path,
fn: () => Format.status(),
})
const b = await Instance.provide({
directory: on.path,
fn: () => Format.status(),
})
expect(a).toEqual([])
expect(b.length).toBeGreaterThan(0)
})
test("runs enabled checks for matching formatters in parallel", async () => {
await using tmp = await tmpdir()
const file = `${tmp.path}/test.parallel`
await Bun.write(file, "x")
const one = {
extensions: Formatter.gofmt.extensions,
enabled: Formatter.gofmt.enabled,
command: Formatter.gofmt.command,
}
const two = {
extensions: Formatter.mix.extensions,
enabled: Formatter.mix.enabled,
command: Formatter.mix.command,
}
let active = 0
let max = 0
Formatter.gofmt.extensions = [".parallel"]
Formatter.mix.extensions = [".parallel"]
Formatter.gofmt.command = ["sh", "-c", "true"]
Formatter.mix.command = ["sh", "-c", "true"]
Formatter.gofmt.enabled = async () => {
active++
max = Math.max(max, active)
await Bun.sleep(20)
active--
return true
}
Formatter.mix.enabled = async () => {
active++
max = Math.max(max, active)
await Bun.sleep(20)
active--
return true
}
try {
await withServices(tmp.path, Format.layer, async (rt) => {
await rt.runPromise(Format.Service.use((s) => s.init()))
await Bus.publish(File.Event.Edited, { file })
})
} finally {
Formatter.gofmt.extensions = one.extensions
Formatter.gofmt.enabled = one.enabled
Formatter.gofmt.command = one.command
Formatter.mix.extensions = two.extensions
Formatter.mix.enabled = two.enabled
Formatter.mix.command = two.command
}
expect(max).toBe(2)
})
test("runs matching formatters sequentially for the same file", async () => {
await using tmp = await tmpdir({
config: {
formatter: {
first: {
command: ["sh", "-c", 'sleep 0.05; v=$(cat "$1"); printf \'%sA\' "$v" > "$1"', "sh", "$FILE"],
extensions: [".seq"],
},
second: {
command: ["sh", "-c", 'v=$(cat "$1"); printf \'%sB\' "$v" > "$1"', "sh", "$FILE"],
extensions: [".seq"],
},
},
},
})
const file = `${tmp.path}/test.seq`
await Bun.write(file, "x")
await withServices(tmp.path, Format.layer, async (rt) => {
await rt.runPromise(Format.Service.use((s) => s.init()))
await Bus.publish(File.Event.Edited, { file })
})
expect(await Bun.file(file).text()).toBe("xAB")
})
}) })
+59 -55
View File
@@ -1,11 +1,15 @@
import { describe, test, expect } from "bun:test" import { afterEach, describe, test, expect } from "bun:test"
import { PermissionNext } from "../src/permission" import { Permission } from "../src/permission"
import { Config } from "../src/config/config" import { Config } from "../src/config/config"
import { Instance } from "../src/project/instance" import { Instance } from "../src/project/instance"
import { tmpdir } from "./fixture/fixture" import { tmpdir } from "./fixture/fixture"
describe("PermissionNext.evaluate for permission.task", () => { afterEach(async () => {
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset => await Instance.disposeAll()
})
describe("Permission.evaluate for permission.task", () => {
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): Permission.Ruleset =>
Object.entries(rules).map(([pattern, action]) => ({ Object.entries(rules).map(([pattern, action]) => ({
permission: "task", permission: "task",
pattern, pattern,
@@ -13,42 +17,42 @@ describe("PermissionNext.evaluate for permission.task", () => {
})) }))
test("returns ask when no match (default)", () => { test("returns ask when no match (default)", () => {
expect(PermissionNext.evaluate("task", "code-reviewer", []).action).toBe("ask") expect(Permission.evaluate("task", "code-reviewer", []).action).toBe("ask")
}) })
test("returns deny for explicit deny", () => { test("returns deny for explicit deny", () => {
const ruleset = createRuleset({ "code-reviewer": "deny" }) const ruleset = createRuleset({ "code-reviewer": "deny" })
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
}) })
test("returns allow for explicit allow", () => { test("returns allow for explicit allow", () => {
const ruleset = createRuleset({ "code-reviewer": "allow" }) const ruleset = createRuleset({ "code-reviewer": "allow" })
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("allow")
}) })
test("returns ask for explicit ask", () => { test("returns ask for explicit ask", () => {
const ruleset = createRuleset({ "code-reviewer": "ask" }) const ruleset = createRuleset({ "code-reviewer": "ask" })
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask") expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
}) })
test("matches wildcard patterns with deny", () => { test("matches wildcard patterns with deny", () => {
const ruleset = createRuleset({ "orchestrator-*": "deny" }) const ruleset = createRuleset({ "orchestrator-*": "deny" })
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny") expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny") expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask") expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
}) })
test("matches wildcard patterns with allow", () => { test("matches wildcard patterns with allow", () => {
const ruleset = createRuleset({ "orchestrator-*": "allow" }) const ruleset = createRuleset({ "orchestrator-*": "allow" })
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow")
}) })
test("matches wildcard patterns with ask", () => { test("matches wildcard patterns with ask", () => {
const ruleset = createRuleset({ "orchestrator-*": "ask" }) const ruleset = createRuleset({ "orchestrator-*": "ask" })
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask") expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask")
const globalRuleset = createRuleset({ "*": "ask" }) const globalRuleset = createRuleset({ "*": "ask" })
expect(PermissionNext.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask") expect(Permission.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask")
}) })
test("later rules take precedence (last match wins)", () => { test("later rules take precedence (last match wins)", () => {
@@ -56,22 +60,22 @@ describe("PermissionNext.evaluate for permission.task", () => {
"orchestrator-*": "deny", "orchestrator-*": "deny",
"orchestrator-fast": "allow", "orchestrator-fast": "allow",
}) })
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny") expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
}) })
test("matches global wildcard", () => { test("matches global wildcard", () => {
expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow") expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow")
expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny") expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny")
expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "ask" })).action).toBe("ask") expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "ask" })).action).toBe("ask")
}) })
}) })
describe("PermissionNext.disabled for task tool", () => { describe("Permission.disabled for task tool", () => {
// Note: The `disabled` function checks if a TOOL should be completely removed from the tool list. // Note: The `disabled` function checks if a TOOL should be completely removed from the tool list.
// It only disables a tool when there's a rule with `pattern: "*"` and `action: "deny"`. // It only disables a tool when there's a rule with `pattern: "*"` and `action: "deny"`.
// It does NOT evaluate complex subagent patterns - those are handled at runtime by `evaluate`. // It does NOT evaluate complex subagent patterns - those are handled at runtime by `evaluate`.
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset => const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): Permission.Ruleset =>
Object.entries(rules).map(([pattern, action]) => ({ Object.entries(rules).map(([pattern, action]) => ({
permission: "task", permission: "task",
pattern, pattern,
@@ -85,7 +89,7 @@ describe("PermissionNext.disabled for task tool", () => {
"orchestrator-*": "allow", "orchestrator-*": "allow",
"*": "deny", "*": "deny",
}) })
const disabled = PermissionNext.disabled(["task", "bash", "read"], ruleset) const disabled = Permission.disabled(["task", "bash", "read"], ruleset)
// The task tool IS disabled because there's a pattern: "*" with action: "deny" // The task tool IS disabled because there's a pattern: "*" with action: "deny"
expect(disabled.has("task")).toBe(true) expect(disabled.has("task")).toBe(true)
}) })
@@ -95,14 +99,14 @@ describe("PermissionNext.disabled for task tool", () => {
"orchestrator-*": "ask", "orchestrator-*": "ask",
"*": "deny", "*": "deny",
}) })
const disabled = PermissionNext.disabled(["task"], ruleset) const disabled = Permission.disabled(["task"], ruleset)
// The task tool IS disabled because there's a pattern: "*" with action: "deny" // The task tool IS disabled because there's a pattern: "*" with action: "deny"
expect(disabled.has("task")).toBe(true) expect(disabled.has("task")).toBe(true)
}) })
test("task tool is disabled when global deny pattern exists", () => { test("task tool is disabled when global deny pattern exists", () => {
const ruleset = createRuleset({ "*": "deny" }) const ruleset = createRuleset({ "*": "deny" })
const disabled = PermissionNext.disabled(["task"], ruleset) const disabled = Permission.disabled(["task"], ruleset)
expect(disabled.has("task")).toBe(true) expect(disabled.has("task")).toBe(true)
}) })
@@ -113,13 +117,13 @@ describe("PermissionNext.disabled for task tool", () => {
"orchestrator-*": "deny", "orchestrator-*": "deny",
general: "deny", general: "deny",
}) })
const disabled = PermissionNext.disabled(["task"], ruleset) const disabled = Permission.disabled(["task"], ruleset)
// The task tool is NOT disabled because no rule has pattern: "*" with action: "deny" // The task tool is NOT disabled because no rule has pattern: "*" with action: "deny"
expect(disabled.has("task")).toBe(false) expect(disabled.has("task")).toBe(false)
}) })
test("task tool is enabled when no task rules exist (default ask)", () => { test("task tool is enabled when no task rules exist (default ask)", () => {
const disabled = PermissionNext.disabled(["task"], []) const disabled = Permission.disabled(["task"], [])
expect(disabled.has("task")).toBe(false) expect(disabled.has("task")).toBe(false)
}) })
@@ -129,7 +133,7 @@ describe("PermissionNext.disabled for task tool", () => {
"*": "deny", "*": "deny",
"orchestrator-coder": "allow", "orchestrator-coder": "allow",
}) })
const disabled = PermissionNext.disabled(["task"], ruleset) const disabled = Permission.disabled(["task"], ruleset)
// The disabled() function uses findLast and checks if the last matching rule // The disabled() function uses findLast and checks if the last matching rule
// has pattern: "*" and action: "deny". In this case, the last rule matching // has pattern: "*" and action: "deny". In this case, the last rule matching
// "task" permission has pattern "orchestrator-coder", not "*", so not disabled // "task" permission has pattern "orchestrator-coder", not "*", so not disabled
@@ -155,11 +159,11 @@ describe("permission.task with real config files", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await Config.get()
const ruleset = PermissionNext.fromConfig(config.permission ?? {}) const ruleset = Permission.fromConfig(config.permission ?? {})
// general and orchestrator-fast should be allowed, code-reviewer denied // general and orchestrator-fast should be allowed, code-reviewer denied
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
}, },
}) })
}) })
@@ -180,11 +184,11 @@ describe("permission.task with real config files", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await Config.get()
const ruleset = PermissionNext.fromConfig(config.permission ?? {}) const ruleset = Permission.fromConfig(config.permission ?? {})
// general and code-reviewer should be ask, orchestrator-* denied // general and code-reviewer should be ask, orchestrator-* denied
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask") expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask") expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny") expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
}, },
}) })
}) })
@@ -205,11 +209,11 @@ describe("permission.task with real config files", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await Config.get()
const ruleset = PermissionNext.fromConfig(config.permission ?? {}) const ruleset = Permission.fromConfig(config.permission ?? {})
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
// Unspecified agents default to "ask" // Unspecified agents default to "ask"
expect(PermissionNext.evaluate("task", "unknown-agent", ruleset).action).toBe("ask") expect(Permission.evaluate("task", "unknown-agent", ruleset).action).toBe("ask")
}, },
}) })
}) })
@@ -232,18 +236,18 @@ describe("permission.task with real config files", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await Config.get()
const ruleset = PermissionNext.fromConfig(config.permission ?? {}) const ruleset = Permission.fromConfig(config.permission ?? {})
// Verify task permissions // Verify task permissions
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
// Verify other tool permissions // Verify other tool permissions
expect(PermissionNext.evaluate("bash", "*", ruleset).action).toBe("allow") expect(Permission.evaluate("bash", "*", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("edit", "*", ruleset).action).toBe("ask") expect(Permission.evaluate("edit", "*", ruleset).action).toBe("ask")
// Verify disabled tools // Verify disabled tools
const disabled = PermissionNext.disabled(["bash", "edit", "task"], ruleset) const disabled = Permission.disabled(["bash", "edit", "task"], ruleset)
expect(disabled.has("bash")).toBe(false) expect(disabled.has("bash")).toBe(false)
expect(disabled.has("edit")).toBe(false) expect(disabled.has("edit")).toBe(false)
// task is NOT disabled because disabled() uses findLast, and the last rule // task is NOT disabled because disabled() uses findLast, and the last rule
@@ -270,16 +274,16 @@ describe("permission.task with real config files", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await Config.get()
const ruleset = PermissionNext.fromConfig(config.permission ?? {}) const ruleset = Permission.fromConfig(config.permission ?? {})
// Last matching rule wins - "*" deny is last, so all agents are denied // Last matching rule wins - "*" deny is last, so all agents are denied
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("deny") expect(Permission.evaluate("task", "general", ruleset).action).toBe("deny")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
expect(PermissionNext.evaluate("task", "unknown", ruleset).action).toBe("deny") expect(Permission.evaluate("task", "unknown", ruleset).action).toBe("deny")
// Since "*": "deny" is the last rule, disabled() finds it with findLast // Since "*": "deny" is the last rule, disabled() finds it with findLast
// and sees pattern: "*" with action: "deny", so task is disabled // and sees pattern: "*" with action: "deny", so task is disabled
const disabled = PermissionNext.disabled(["task"], ruleset) const disabled = Permission.disabled(["task"], ruleset)
expect(disabled.has("task")).toBe(true) expect(disabled.has("task")).toBe(true)
}, },
}) })
@@ -301,17 +305,17 @@ describe("permission.task with real config files", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await Config.get()
const ruleset = PermissionNext.fromConfig(config.permission ?? {}) const ruleset = Permission.fromConfig(config.permission ?? {})
// Evaluate uses findLast - "general" allow comes after "*" deny // Evaluate uses findLast - "general" allow comes after "*" deny
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
// Other agents still denied by the earlier "*" deny // Other agents still denied by the earlier "*" deny
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
// disabled() uses findLast and checks if the last rule has pattern: "*" with action: "deny" // disabled() uses findLast and checks if the last rule has pattern: "*" with action: "deny"
// In this case, the last rule is {pattern: "general", action: "allow"}, not pattern: "*" // In this case, the last rule is {pattern: "general", action: "allow"}, not pattern: "*"
// So the task tool is NOT disabled (even though most subagents are denied) // So the task tool is NOT disabled (even though most subagents are denied)
const disabled = PermissionNext.disabled(["task"], ruleset) const disabled = Permission.disabled(["task"], ruleset)
expect(disabled.has("task")).toBe(false) expect(disabled.has("task")).toBe(false)
}, },
}) })
+243 -127
View File
@@ -1,11 +1,7 @@
import { afterEach, test, expect } from "bun:test" import { afterEach, test, expect } from "bun:test"
import os from "os" import os from "os"
import { Effect } from "effect"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { runtime } from "../../src/effect/runtime" import { Permission } from "../../src/permission"
import { Instances } from "../../src/effect/instances"
import { PermissionNext } from "../../src/permission"
import { PermissionNext as S } from "../../src/permission"
import { PermissionID } from "../../src/permission/schema" import { PermissionID } from "../../src/permission/schema"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
@@ -16,8 +12,8 @@ afterEach(async () => {
}) })
async function rejectAll(message?: string) { async function rejectAll(message?: string) {
for (const req of await PermissionNext.list()) { for (const req of await Permission.list()) {
await PermissionNext.reply({ await Permission.reply({
requestID: req.id, requestID: req.id,
reply: "reject", reply: "reject",
message, message,
@@ -27,22 +23,22 @@ async function rejectAll(message?: string) {
async function waitForPending(count: number) { async function waitForPending(count: number) {
for (let i = 0; i < 20; i++) { for (let i = 0; i < 20; i++) {
const list = await PermissionNext.list() const list = await Permission.list()
if (list.length === count) return list if (list.length === count) return list
await Bun.sleep(0) await Bun.sleep(0)
} }
return PermissionNext.list() return Permission.list()
} }
// fromConfig tests // fromConfig tests
test("fromConfig - string value becomes wildcard rule", () => { test("fromConfig - string value becomes wildcard rule", () => {
const result = PermissionNext.fromConfig({ bash: "allow" }) const result = Permission.fromConfig({ bash: "allow" })
expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }]) expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
}) })
test("fromConfig - object value converts to rules array", () => { test("fromConfig - object value converts to rules array", () => {
const result = PermissionNext.fromConfig({ bash: { "*": "allow", rm: "deny" } }) const result = Permission.fromConfig({ bash: { "*": "allow", rm: "deny" } })
expect(result).toEqual([ expect(result).toEqual([
{ permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "rm", action: "deny" }, { permission: "bash", pattern: "rm", action: "deny" },
@@ -50,7 +46,7 @@ test("fromConfig - object value converts to rules array", () => {
}) })
test("fromConfig - mixed string and object values", () => { test("fromConfig - mixed string and object values", () => {
const result = PermissionNext.fromConfig({ const result = Permission.fromConfig({
bash: { "*": "allow", rm: "deny" }, bash: { "*": "allow", rm: "deny" },
edit: "allow", edit: "allow",
webfetch: "ask", webfetch: "ask",
@@ -64,51 +60,51 @@ test("fromConfig - mixed string and object values", () => {
}) })
test("fromConfig - empty object", () => { test("fromConfig - empty object", () => {
const result = PermissionNext.fromConfig({}) const result = Permission.fromConfig({})
expect(result).toEqual([]) expect(result).toEqual([])
}) })
test("fromConfig - expands tilde to home directory", () => { test("fromConfig - expands tilde to home directory", () => {
const result = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } }) const result = Permission.fromConfig({ external_directory: { "~/projects/*": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }]) expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }])
}) })
test("fromConfig - expands $HOME to home directory", () => { test("fromConfig - expands $HOME to home directory", () => {
const result = PermissionNext.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } }) const result = Permission.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }]) expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }])
}) })
test("fromConfig - expands $HOME without trailing slash", () => { test("fromConfig - expands $HOME without trailing slash", () => {
const result = PermissionNext.fromConfig({ external_directory: { $HOME: "allow" } }) const result = Permission.fromConfig({ external_directory: { $HOME: "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }]) expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }])
}) })
test("fromConfig - does not expand tilde in middle of path", () => { test("fromConfig - does not expand tilde in middle of path", () => {
const result = PermissionNext.fromConfig({ external_directory: { "/some/~/path": "allow" } }) const result = Permission.fromConfig({ external_directory: { "/some/~/path": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: "/some/~/path", action: "allow" }]) expect(result).toEqual([{ permission: "external_directory", pattern: "/some/~/path", action: "allow" }])
}) })
test("fromConfig - expands exact tilde to home directory", () => { test("fromConfig - expands exact tilde to home directory", () => {
const result = PermissionNext.fromConfig({ external_directory: { "~": "allow" } }) const result = Permission.fromConfig({ external_directory: { "~": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }]) expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }])
}) })
test("evaluate - matches expanded tilde pattern", () => { test("evaluate - matches expanded tilde pattern", () => {
const ruleset = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } }) const ruleset = Permission.fromConfig({ external_directory: { "~/projects/*": "allow" } })
const result = PermissionNext.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset) const result = Permission.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
expect(result.action).toBe("allow") expect(result.action).toBe("allow")
}) })
test("evaluate - matches expanded $HOME pattern", () => { test("evaluate - matches expanded $HOME pattern", () => {
const ruleset = PermissionNext.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } }) const ruleset = Permission.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
const result = PermissionNext.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset) const result = Permission.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
expect(result.action).toBe("allow") expect(result.action).toBe("allow")
}) })
// merge tests // merge tests
test("merge - simple concatenation", () => { test("merge - simple concatenation", () => {
const result = PermissionNext.merge( const result = Permission.merge(
[{ permission: "bash", pattern: "*", action: "allow" }], [{ permission: "bash", pattern: "*", action: "allow" }],
[{ permission: "bash", pattern: "*", action: "deny" }], [{ permission: "bash", pattern: "*", action: "deny" }],
) )
@@ -119,7 +115,7 @@ test("merge - simple concatenation", () => {
}) })
test("merge - adds new permission", () => { test("merge - adds new permission", () => {
const result = PermissionNext.merge( const result = Permission.merge(
[{ permission: "bash", pattern: "*", action: "allow" }], [{ permission: "bash", pattern: "*", action: "allow" }],
[{ permission: "edit", pattern: "*", action: "deny" }], [{ permission: "edit", pattern: "*", action: "deny" }],
) )
@@ -130,7 +126,7 @@ test("merge - adds new permission", () => {
}) })
test("merge - concatenates rules for same permission", () => { test("merge - concatenates rules for same permission", () => {
const result = PermissionNext.merge( const result = Permission.merge(
[{ permission: "bash", pattern: "foo", action: "ask" }], [{ permission: "bash", pattern: "foo", action: "ask" }],
[{ permission: "bash", pattern: "*", action: "deny" }], [{ permission: "bash", pattern: "*", action: "deny" }],
) )
@@ -141,7 +137,7 @@ test("merge - concatenates rules for same permission", () => {
}) })
test("merge - multiple rulesets", () => { test("merge - multiple rulesets", () => {
const result = PermissionNext.merge( const result = Permission.merge(
[{ permission: "bash", pattern: "*", action: "allow" }], [{ permission: "bash", pattern: "*", action: "allow" }],
[{ permission: "bash", pattern: "rm", action: "ask" }], [{ permission: "bash", pattern: "rm", action: "ask" }],
[{ permission: "edit", pattern: "*", action: "allow" }], [{ permission: "edit", pattern: "*", action: "allow" }],
@@ -154,12 +150,12 @@ test("merge - multiple rulesets", () => {
}) })
test("merge - empty ruleset does nothing", () => { test("merge - empty ruleset does nothing", () => {
const result = PermissionNext.merge([{ permission: "bash", pattern: "*", action: "allow" }], []) const result = Permission.merge([{ permission: "bash", pattern: "*", action: "allow" }], [])
expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }]) expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
}) })
test("merge - preserves rule order", () => { test("merge - preserves rule order", () => {
const result = PermissionNext.merge( const result = Permission.merge(
[ [
{ permission: "edit", pattern: "src/*", action: "allow" }, { permission: "edit", pattern: "src/*", action: "allow" },
{ permission: "edit", pattern: "src/secret/*", action: "deny" }, { permission: "edit", pattern: "src/secret/*", action: "deny" },
@@ -175,40 +171,40 @@ test("merge - preserves rule order", () => {
test("merge - config permission overrides default ask", () => { test("merge - config permission overrides default ask", () => {
// Simulates: defaults have "*": "ask", config sets bash: "allow" // Simulates: defaults have "*": "ask", config sets bash: "allow"
const defaults: PermissionNext.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }] const defaults: Permission.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const merged = PermissionNext.merge(defaults, config) const merged = Permission.merge(defaults, config)
// Config's bash allow should override default ask // Config's bash allow should override default ask
expect(PermissionNext.evaluate("bash", "ls", merged).action).toBe("allow") expect(Permission.evaluate("bash", "ls", merged).action).toBe("allow")
// Other permissions should still be ask (from defaults) // Other permissions should still be ask (from defaults)
expect(PermissionNext.evaluate("edit", "foo.ts", merged).action).toBe("ask") expect(Permission.evaluate("edit", "foo.ts", merged).action).toBe("ask")
}) })
test("merge - config ask overrides default allow", () => { test("merge - config ask overrides default allow", () => {
// Simulates: defaults have bash: "allow", config sets bash: "ask" // Simulates: defaults have bash: "allow", config sets bash: "ask"
const defaults: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] const defaults: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }] const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
const merged = PermissionNext.merge(defaults, config) const merged = Permission.merge(defaults, config)
// Config's ask should override default allow // Config's ask should override default allow
expect(PermissionNext.evaluate("bash", "ls", merged).action).toBe("ask") expect(Permission.evaluate("bash", "ls", merged).action).toBe("ask")
}) })
// evaluate tests // evaluate tests
test("evaluate - exact pattern match", () => { test("evaluate - exact pattern match", () => {
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }]) const result = Permission.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }])
expect(result.action).toBe("deny") expect(result.action).toBe("deny")
}) })
test("evaluate - wildcard pattern match", () => { test("evaluate - wildcard pattern match", () => {
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }]) const result = Permission.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }])
expect(result.action).toBe("allow") expect(result.action).toBe("allow")
}) })
test("evaluate - last matching rule wins", () => { test("evaluate - last matching rule wins", () => {
const result = PermissionNext.evaluate("bash", "rm", [ const result = Permission.evaluate("bash", "rm", [
{ permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "rm", action: "deny" }, { permission: "bash", pattern: "rm", action: "deny" },
]) ])
@@ -216,7 +212,7 @@ test("evaluate - last matching rule wins", () => {
}) })
test("evaluate - last matching rule wins (wildcard after specific)", () => { test("evaluate - last matching rule wins (wildcard after specific)", () => {
const result = PermissionNext.evaluate("bash", "rm", [ const result = Permission.evaluate("bash", "rm", [
{ permission: "bash", pattern: "rm", action: "deny" }, { permission: "bash", pattern: "rm", action: "deny" },
{ permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "*", action: "allow" },
]) ])
@@ -224,14 +220,12 @@ test("evaluate - last matching rule wins (wildcard after specific)", () => {
}) })
test("evaluate - glob pattern match", () => { test("evaluate - glob pattern match", () => {
const result = PermissionNext.evaluate("edit", "src/foo.ts", [ const result = Permission.evaluate("edit", "src/foo.ts", [{ permission: "edit", pattern: "src/*", action: "allow" }])
{ permission: "edit", pattern: "src/*", action: "allow" },
])
expect(result.action).toBe("allow") expect(result.action).toBe("allow")
}) })
test("evaluate - last matching glob wins", () => { test("evaluate - last matching glob wins", () => {
const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [ const result = Permission.evaluate("edit", "src/components/Button.tsx", [
{ permission: "edit", pattern: "src/*", action: "deny" }, { permission: "edit", pattern: "src/*", action: "deny" },
{ permission: "edit", pattern: "src/components/*", action: "allow" }, { permission: "edit", pattern: "src/components/*", action: "allow" },
]) ])
@@ -240,7 +234,7 @@ test("evaluate - last matching glob wins", () => {
test("evaluate - order matters for specificity", () => { test("evaluate - order matters for specificity", () => {
// If more specific rule comes first, later wildcard overrides it // If more specific rule comes first, later wildcard overrides it
const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [ const result = Permission.evaluate("edit", "src/components/Button.tsx", [
{ permission: "edit", pattern: "src/components/*", action: "allow" }, { permission: "edit", pattern: "src/components/*", action: "allow" },
{ permission: "edit", pattern: "src/*", action: "deny" }, { permission: "edit", pattern: "src/*", action: "deny" },
]) ])
@@ -248,31 +242,29 @@ test("evaluate - order matters for specificity", () => {
}) })
test("evaluate - unknown permission returns ask", () => { test("evaluate - unknown permission returns ask", () => {
const result = PermissionNext.evaluate("unknown_tool", "anything", [ const result = Permission.evaluate("unknown_tool", "anything", [
{ permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "*", action: "allow" },
]) ])
expect(result.action).toBe("ask") expect(result.action).toBe("ask")
}) })
test("evaluate - empty ruleset returns ask", () => { test("evaluate - empty ruleset returns ask", () => {
const result = PermissionNext.evaluate("bash", "rm", []) const result = Permission.evaluate("bash", "rm", [])
expect(result.action).toBe("ask") expect(result.action).toBe("ask")
}) })
test("evaluate - no matching pattern returns ask", () => { test("evaluate - no matching pattern returns ask", () => {
const result = PermissionNext.evaluate("edit", "etc/passwd", [ const result = Permission.evaluate("edit", "etc/passwd", [{ permission: "edit", pattern: "src/*", action: "allow" }])
{ permission: "edit", pattern: "src/*", action: "allow" },
])
expect(result.action).toBe("ask") expect(result.action).toBe("ask")
}) })
test("evaluate - empty rules array returns ask", () => { test("evaluate - empty rules array returns ask", () => {
const result = PermissionNext.evaluate("bash", "rm", []) const result = Permission.evaluate("bash", "rm", [])
expect(result.action).toBe("ask") expect(result.action).toBe("ask")
}) })
test("evaluate - multiple matching patterns, last wins", () => { test("evaluate - multiple matching patterns, last wins", () => {
const result = PermissionNext.evaluate("edit", "src/secret.ts", [ const result = Permission.evaluate("edit", "src/secret.ts", [
{ permission: "edit", pattern: "*", action: "ask" }, { permission: "edit", pattern: "*", action: "ask" },
{ permission: "edit", pattern: "src/*", action: "allow" }, { permission: "edit", pattern: "src/*", action: "allow" },
{ permission: "edit", pattern: "src/secret.ts", action: "deny" }, { permission: "edit", pattern: "src/secret.ts", action: "deny" },
@@ -281,7 +273,7 @@ test("evaluate - multiple matching patterns, last wins", () => {
}) })
test("evaluate - non-matching patterns are skipped", () => { test("evaluate - non-matching patterns are skipped", () => {
const result = PermissionNext.evaluate("edit", "src/foo.ts", [ const result = Permission.evaluate("edit", "src/foo.ts", [
{ permission: "edit", pattern: "*", action: "ask" }, { permission: "edit", pattern: "*", action: "ask" },
{ permission: "edit", pattern: "test/*", action: "deny" }, { permission: "edit", pattern: "test/*", action: "deny" },
{ permission: "edit", pattern: "src/*", action: "allow" }, { permission: "edit", pattern: "src/*", action: "allow" },
@@ -290,7 +282,7 @@ test("evaluate - non-matching patterns are skipped", () => {
}) })
test("evaluate - exact match at end wins over earlier wildcard", () => { test("evaluate - exact match at end wins over earlier wildcard", () => {
const result = PermissionNext.evaluate("bash", "/bin/rm", [ const result = Permission.evaluate("bash", "/bin/rm", [
{ permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "/bin/rm", action: "deny" }, { permission: "bash", pattern: "/bin/rm", action: "deny" },
]) ])
@@ -298,7 +290,7 @@ test("evaluate - exact match at end wins over earlier wildcard", () => {
}) })
test("evaluate - wildcard at end overrides earlier exact match", () => { test("evaluate - wildcard at end overrides earlier exact match", () => {
const result = PermissionNext.evaluate("bash", "/bin/rm", [ const result = Permission.evaluate("bash", "/bin/rm", [
{ permission: "bash", pattern: "/bin/rm", action: "deny" }, { permission: "bash", pattern: "/bin/rm", action: "deny" },
{ permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "*", action: "allow" },
]) ])
@@ -308,24 +300,24 @@ test("evaluate - wildcard at end overrides earlier exact match", () => {
// wildcard permission tests // wildcard permission tests
test("evaluate - wildcard permission matches any permission", () => { test("evaluate - wildcard permission matches any permission", () => {
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }]) const result = Permission.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }])
expect(result.action).toBe("deny") expect(result.action).toBe("deny")
}) })
test("evaluate - wildcard permission with specific pattern", () => { test("evaluate - wildcard permission with specific pattern", () => {
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }]) const result = Permission.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }])
expect(result.action).toBe("deny") expect(result.action).toBe("deny")
}) })
test("evaluate - glob permission pattern", () => { test("evaluate - glob permission pattern", () => {
const result = PermissionNext.evaluate("mcp_server_tool", "anything", [ const result = Permission.evaluate("mcp_server_tool", "anything", [
{ permission: "mcp_*", pattern: "*", action: "allow" }, { permission: "mcp_*", pattern: "*", action: "allow" },
]) ])
expect(result.action).toBe("allow") expect(result.action).toBe("allow")
}) })
test("evaluate - specific permission and wildcard permission combined", () => { test("evaluate - specific permission and wildcard permission combined", () => {
const result = PermissionNext.evaluate("bash", "rm", [ const result = Permission.evaluate("bash", "rm", [
{ permission: "*", pattern: "*", action: "deny" }, { permission: "*", pattern: "*", action: "deny" },
{ permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "*", action: "allow" },
]) ])
@@ -333,7 +325,7 @@ test("evaluate - specific permission and wildcard permission combined", () => {
}) })
test("evaluate - wildcard permission does not match when specific exists", () => { test("evaluate - wildcard permission does not match when specific exists", () => {
const result = PermissionNext.evaluate("edit", "src/foo.ts", [ const result = Permission.evaluate("edit", "src/foo.ts", [
{ permission: "*", pattern: "*", action: "deny" }, { permission: "*", pattern: "*", action: "deny" },
{ permission: "edit", pattern: "src/*", action: "allow" }, { permission: "edit", pattern: "src/*", action: "allow" },
]) ])
@@ -341,7 +333,7 @@ test("evaluate - wildcard permission does not match when specific exists", () =>
}) })
test("evaluate - multiple matching permission patterns combine rules", () => { test("evaluate - multiple matching permission patterns combine rules", () => {
const result = PermissionNext.evaluate("mcp_dangerous", "anything", [ const result = Permission.evaluate("mcp_dangerous", "anything", [
{ permission: "*", pattern: "*", action: "ask" }, { permission: "*", pattern: "*", action: "ask" },
{ permission: "mcp_*", pattern: "*", action: "allow" }, { permission: "mcp_*", pattern: "*", action: "allow" },
{ permission: "mcp_dangerous", pattern: "*", action: "deny" }, { permission: "mcp_dangerous", pattern: "*", action: "deny" },
@@ -350,7 +342,7 @@ test("evaluate - multiple matching permission patterns combine rules", () => {
}) })
test("evaluate - wildcard permission fallback for unknown tool", () => { test("evaluate - wildcard permission fallback for unknown tool", () => {
const result = PermissionNext.evaluate("unknown_tool", "anything", [ const result = Permission.evaluate("unknown_tool", "anything", [
{ permission: "*", pattern: "*", action: "ask" }, { permission: "*", pattern: "*", action: "ask" },
{ permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "*", action: "allow" },
]) ])
@@ -359,7 +351,7 @@ test("evaluate - wildcard permission fallback for unknown tool", () => {
test("evaluate - permission patterns sorted by length regardless of object order", () => { test("evaluate - permission patterns sorted by length regardless of object order", () => {
// specific permission listed before wildcard, but specific should still win // specific permission listed before wildcard, but specific should still win
const result = PermissionNext.evaluate("bash", "rm", [ const result = Permission.evaluate("bash", "rm", [
{ permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "*", action: "allow" },
{ permission: "*", pattern: "*", action: "deny" }, { permission: "*", pattern: "*", action: "deny" },
]) ])
@@ -368,22 +360,22 @@ test("evaluate - permission patterns sorted by length regardless of object order
}) })
test("evaluate - merges multiple rulesets", () => { test("evaluate - merges multiple rulesets", () => {
const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const approved: PermissionNext.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }] const approved: Permission.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
// approved comes after config, so rm should be denied // approved comes after config, so rm should be denied
const result = PermissionNext.evaluate("bash", "rm", config, approved) const result = Permission.evaluate("bash", "rm", config, approved)
expect(result.action).toBe("deny") expect(result.action).toBe("deny")
}) })
// disabled tests // disabled tests
test("disabled - returns empty set when all tools allowed", () => { test("disabled - returns empty set when all tools allowed", () => {
const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }]) const result = Permission.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }])
expect(result.size).toBe(0) expect(result.size).toBe(0)
}) })
test("disabled - disables tool when denied", () => { test("disabled - disables tool when denied", () => {
const result = PermissionNext.disabled( const result = Permission.disabled(
["bash", "edit", "read"], ["bash", "edit", "read"],
[ [
{ permission: "*", pattern: "*", action: "allow" }, { permission: "*", pattern: "*", action: "allow" },
@@ -396,7 +388,7 @@ test("disabled - disables tool when denied", () => {
}) })
test("disabled - disables edit/write/apply_patch/multiedit when edit denied", () => { test("disabled - disables edit/write/apply_patch/multiedit when edit denied", () => {
const result = PermissionNext.disabled( const result = Permission.disabled(
["edit", "write", "apply_patch", "multiedit", "bash"], ["edit", "write", "apply_patch", "multiedit", "bash"],
[ [
{ permission: "*", pattern: "*", action: "allow" }, { permission: "*", pattern: "*", action: "allow" },
@@ -411,7 +403,7 @@ test("disabled - disables edit/write/apply_patch/multiedit when edit denied", ()
}) })
test("disabled - does not disable when partially denied", () => { test("disabled - does not disable when partially denied", () => {
const result = PermissionNext.disabled( const result = Permission.disabled(
["bash"], ["bash"],
[ [
{ permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "*", action: "allow" },
@@ -422,14 +414,14 @@ test("disabled - does not disable when partially denied", () => {
}) })
test("disabled - does not disable when action is ask", () => { test("disabled - does not disable when action is ask", () => {
const result = PermissionNext.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }]) const result = Permission.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }])
expect(result.size).toBe(0) expect(result.size).toBe(0)
}) })
test("disabled - does not disable when specific allow after wildcard deny", () => { test("disabled - does not disable when specific allow after wildcard deny", () => {
// Tool is NOT disabled because a specific allow after wildcard deny means // Tool is NOT disabled because a specific allow after wildcard deny means
// there's at least some usage allowed // there's at least some usage allowed
const result = PermissionNext.disabled( const result = Permission.disabled(
["bash"], ["bash"],
[ [
{ permission: "bash", pattern: "*", action: "deny" }, { permission: "bash", pattern: "*", action: "deny" },
@@ -440,7 +432,7 @@ test("disabled - does not disable when specific allow after wildcard deny", () =
}) })
test("disabled - does not disable when wildcard allow after deny", () => { test("disabled - does not disable when wildcard allow after deny", () => {
const result = PermissionNext.disabled( const result = Permission.disabled(
["bash"], ["bash"],
[ [
{ permission: "bash", pattern: "rm *", action: "deny" }, { permission: "bash", pattern: "rm *", action: "deny" },
@@ -451,7 +443,7 @@ test("disabled - does not disable when wildcard allow after deny", () => {
}) })
test("disabled - disables multiple tools", () => { test("disabled - disables multiple tools", () => {
const result = PermissionNext.disabled( const result = Permission.disabled(
["bash", "edit", "webfetch"], ["bash", "edit", "webfetch"],
[ [
{ permission: "bash", pattern: "*", action: "deny" }, { permission: "bash", pattern: "*", action: "deny" },
@@ -465,14 +457,14 @@ test("disabled - disables multiple tools", () => {
}) })
test("disabled - wildcard permission denies all tools", () => { test("disabled - wildcard permission denies all tools", () => {
const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }]) const result = Permission.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }])
expect(result.has("bash")).toBe(true) expect(result.has("bash")).toBe(true)
expect(result.has("edit")).toBe(true) expect(result.has("edit")).toBe(true)
expect(result.has("read")).toBe(true) expect(result.has("read")).toBe(true)
}) })
test("disabled - specific allow overrides wildcard deny", () => { test("disabled - specific allow overrides wildcard deny", () => {
const result = PermissionNext.disabled( const result = Permission.disabled(
["bash", "edit", "read"], ["bash", "edit", "read"],
[ [
{ permission: "*", pattern: "*", action: "deny" }, { permission: "*", pattern: "*", action: "deny" },
@@ -491,7 +483,7 @@ test("ask - resolves immediately when action is allow", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await PermissionNext.ask({ const result = await Permission.ask({
sessionID: SessionID.make("session_test"), sessionID: SessionID.make("session_test"),
permission: "bash", permission: "bash",
patterns: ["ls"], patterns: ["ls"],
@@ -510,7 +502,7 @@ test("ask - throws RejectedError when action is deny", async () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await expect( await expect(
PermissionNext.ask({ Permission.ask({
sessionID: SessionID.make("session_test"), sessionID: SessionID.make("session_test"),
permission: "bash", permission: "bash",
patterns: ["rm -rf /"], patterns: ["rm -rf /"],
@@ -518,7 +510,7 @@ test("ask - throws RejectedError when action is deny", async () => {
always: [], always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "deny" }], ruleset: [{ permission: "bash", pattern: "*", action: "deny" }],
}), }),
).rejects.toBeInstanceOf(PermissionNext.DeniedError) ).rejects.toBeInstanceOf(Permission.DeniedError)
}, },
}) })
}) })
@@ -528,7 +520,7 @@ test("ask - returns pending promise when action is ask", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const promise = PermissionNext.ask({ const promise = Permission.ask({
sessionID: SessionID.make("session_test"), sessionID: SessionID.make("session_test"),
permission: "bash", permission: "bash",
patterns: ["ls"], patterns: ["ls"],
@@ -550,7 +542,7 @@ test("ask - adds request to pending list", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const ask = PermissionNext.ask({ const ask = Permission.ask({
sessionID: SessionID.make("session_test"), sessionID: SessionID.make("session_test"),
permission: "bash", permission: "bash",
patterns: ["ls"], patterns: ["ls"],
@@ -563,7 +555,7 @@ test("ask - adds request to pending list", async () => {
ruleset: [], ruleset: [],
}) })
const list = await PermissionNext.list() const list = await Permission.list()
expect(list).toHaveLength(1) expect(list).toHaveLength(1)
expect(list[0]).toMatchObject({ expect(list[0]).toMatchObject({
sessionID: SessionID.make("session_test"), sessionID: SessionID.make("session_test"),
@@ -588,12 +580,12 @@ test("ask - publishes asked event", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
let seen: PermissionNext.Request | undefined let seen: Permission.Request | undefined
const unsub = Bus.subscribe(PermissionNext.Event.Asked, (event) => { const unsub = Bus.subscribe(Permission.Event.Asked, (event) => {
seen = event.properties seen = event.properties
}) })
const ask = PermissionNext.ask({ const ask = Permission.ask({
sessionID: SessionID.make("session_test"), sessionID: SessionID.make("session_test"),
permission: "bash", permission: "bash",
patterns: ["ls"], patterns: ["ls"],
@@ -606,7 +598,7 @@ test("ask - publishes asked event", async () => {
ruleset: [], ruleset: [],
}) })
expect(await PermissionNext.list()).toHaveLength(1) expect(await Permission.list()).toHaveLength(1)
expect(seen).toBeDefined() expect(seen).toBeDefined()
expect(seen).toMatchObject({ expect(seen).toMatchObject({
sessionID: SessionID.make("session_test"), sessionID: SessionID.make("session_test"),
@@ -628,7 +620,7 @@ test("reply - once resolves the pending ask", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const askPromise = PermissionNext.ask({ const askPromise = Permission.ask({
id: PermissionID.make("per_test1"), id: PermissionID.make("per_test1"),
sessionID: SessionID.make("session_test"), sessionID: SessionID.make("session_test"),
permission: "bash", permission: "bash",
@@ -640,7 +632,7 @@ test("reply - once resolves the pending ask", async () => {
await waitForPending(1) await waitForPending(1)
await PermissionNext.reply({ await Permission.reply({
requestID: PermissionID.make("per_test1"), requestID: PermissionID.make("per_test1"),
reply: "once", reply: "once",
}) })
@@ -655,7 +647,7 @@ test("reply - reject throws RejectedError", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const askPromise = PermissionNext.ask({ const askPromise = Permission.ask({
id: PermissionID.make("per_test2"), id: PermissionID.make("per_test2"),
sessionID: SessionID.make("session_test"), sessionID: SessionID.make("session_test"),
permission: "bash", permission: "bash",
@@ -667,12 +659,12 @@ test("reply - reject throws RejectedError", async () => {
await waitForPending(1) await waitForPending(1)
await PermissionNext.reply({ await Permission.reply({
requestID: PermissionID.make("per_test2"), requestID: PermissionID.make("per_test2"),
reply: "reject", reply: "reject",
}) })
await expect(askPromise).rejects.toBeInstanceOf(PermissionNext.RejectedError) await expect(askPromise).rejects.toBeInstanceOf(Permission.RejectedError)
}, },
}) })
}) })
@@ -682,7 +674,7 @@ test("reply - reject with message throws CorrectedError", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const ask = PermissionNext.ask({ const ask = Permission.ask({
id: PermissionID.make("per_test2b"), id: PermissionID.make("per_test2b"),
sessionID: SessionID.make("session_test"), sessionID: SessionID.make("session_test"),
permission: "bash", permission: "bash",
@@ -694,14 +686,14 @@ test("reply - reject with message throws CorrectedError", async () => {
await waitForPending(1) await waitForPending(1)
await PermissionNext.reply({ await Permission.reply({
requestID: PermissionID.make("per_test2b"), requestID: PermissionID.make("per_test2b"),
reply: "reject", reply: "reject",
message: "Use a safer command", message: "Use a safer command",
}) })
const err = await ask.catch((err) => err) const err = await ask.catch((err) => err)
expect(err).toBeInstanceOf(PermissionNext.CorrectedError) expect(err).toBeInstanceOf(Permission.CorrectedError)
expect(err.message).toContain("Use a safer command") expect(err.message).toContain("Use a safer command")
}, },
}) })
@@ -712,7 +704,7 @@ test("reply - always persists approval and resolves", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const askPromise = PermissionNext.ask({ const askPromise = Permission.ask({
id: PermissionID.make("per_test3"), id: PermissionID.make("per_test3"),
sessionID: SessionID.make("session_test"), sessionID: SessionID.make("session_test"),
permission: "bash", permission: "bash",
@@ -724,7 +716,7 @@ test("reply - always persists approval and resolves", async () => {
await waitForPending(1) await waitForPending(1)
await PermissionNext.reply({ await Permission.reply({
requestID: PermissionID.make("per_test3"), requestID: PermissionID.make("per_test3"),
reply: "always", reply: "always",
}) })
@@ -737,7 +729,7 @@ test("reply - always persists approval and resolves", async () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
// Stored approval should allow without asking // Stored approval should allow without asking
const result = await PermissionNext.ask({ const result = await Permission.ask({
sessionID: SessionID.make("session_test2"), sessionID: SessionID.make("session_test2"),
permission: "bash", permission: "bash",
patterns: ["ls"], patterns: ["ls"],
@@ -755,7 +747,7 @@ test("reply - reject cancels all pending for same session", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const askPromise1 = PermissionNext.ask({ const askPromise1 = Permission.ask({
id: PermissionID.make("per_test4a"), id: PermissionID.make("per_test4a"),
sessionID: SessionID.make("session_same"), sessionID: SessionID.make("session_same"),
permission: "bash", permission: "bash",
@@ -765,7 +757,7 @@ test("reply - reject cancels all pending for same session", async () => {
ruleset: [], ruleset: [],
}) })
const askPromise2 = PermissionNext.ask({ const askPromise2 = Permission.ask({
id: PermissionID.make("per_test4b"), id: PermissionID.make("per_test4b"),
sessionID: SessionID.make("session_same"), sessionID: SessionID.make("session_same"),
permission: "edit", permission: "edit",
@@ -782,14 +774,14 @@ test("reply - reject cancels all pending for same session", async () => {
const result2 = askPromise2.catch((e) => e) const result2 = askPromise2.catch((e) => e)
// Reject the first one // Reject the first one
await PermissionNext.reply({ await Permission.reply({
requestID: PermissionID.make("per_test4a"), requestID: PermissionID.make("per_test4a"),
reply: "reject", reply: "reject",
}) })
// Both should be rejected // Both should be rejected
expect(await result1).toBeInstanceOf(PermissionNext.RejectedError) expect(await result1).toBeInstanceOf(Permission.RejectedError)
expect(await result2).toBeInstanceOf(PermissionNext.RejectedError) expect(await result2).toBeInstanceOf(Permission.RejectedError)
}, },
}) })
}) })
@@ -799,7 +791,7 @@ test("reply - always resolves matching pending requests in same session", async
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const a = PermissionNext.ask({ const a = Permission.ask({
id: PermissionID.make("per_test5a"), id: PermissionID.make("per_test5a"),
sessionID: SessionID.make("session_same"), sessionID: SessionID.make("session_same"),
permission: "bash", permission: "bash",
@@ -809,7 +801,7 @@ test("reply - always resolves matching pending requests in same session", async
ruleset: [], ruleset: [],
}) })
const b = PermissionNext.ask({ const b = Permission.ask({
id: PermissionID.make("per_test5b"), id: PermissionID.make("per_test5b"),
sessionID: SessionID.make("session_same"), sessionID: SessionID.make("session_same"),
permission: "bash", permission: "bash",
@@ -821,14 +813,14 @@ test("reply - always resolves matching pending requests in same session", async
await waitForPending(2) await waitForPending(2)
await PermissionNext.reply({ await Permission.reply({
requestID: PermissionID.make("per_test5a"), requestID: PermissionID.make("per_test5a"),
reply: "always", reply: "always",
}) })
await expect(a).resolves.toBeUndefined() await expect(a).resolves.toBeUndefined()
await expect(b).resolves.toBeUndefined() await expect(b).resolves.toBeUndefined()
expect(await PermissionNext.list()).toHaveLength(0) expect(await Permission.list()).toHaveLength(0)
}, },
}) })
}) })
@@ -838,7 +830,7 @@ test("reply - always keeps other session pending", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const a = PermissionNext.ask({ const a = Permission.ask({
id: PermissionID.make("per_test6a"), id: PermissionID.make("per_test6a"),
sessionID: SessionID.make("session_a"), sessionID: SessionID.make("session_a"),
permission: "bash", permission: "bash",
@@ -848,7 +840,7 @@ test("reply - always keeps other session pending", async () => {
ruleset: [], ruleset: [],
}) })
const b = PermissionNext.ask({ const b = Permission.ask({
id: PermissionID.make("per_test6b"), id: PermissionID.make("per_test6b"),
sessionID: SessionID.make("session_b"), sessionID: SessionID.make("session_b"),
permission: "bash", permission: "bash",
@@ -860,13 +852,13 @@ test("reply - always keeps other session pending", async () => {
await waitForPending(2) await waitForPending(2)
await PermissionNext.reply({ await Permission.reply({
requestID: PermissionID.make("per_test6a"), requestID: PermissionID.make("per_test6a"),
reply: "always", reply: "always",
}) })
await expect(a).resolves.toBeUndefined() await expect(a).resolves.toBeUndefined()
expect((await PermissionNext.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")]) expect((await Permission.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")])
await rejectAll() await rejectAll()
await b.catch(() => {}) await b.catch(() => {})
@@ -879,7 +871,7 @@ test("reply - publishes replied event", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const ask = PermissionNext.ask({ const ask = Permission.ask({
id: PermissionID.make("per_test7"), id: PermissionID.make("per_test7"),
sessionID: SessionID.make("session_test"), sessionID: SessionID.make("session_test"),
permission: "bash", permission: "bash",
@@ -895,14 +887,14 @@ test("reply - publishes replied event", async () => {
| { | {
sessionID: SessionID sessionID: SessionID
requestID: PermissionID requestID: PermissionID
reply: PermissionNext.Reply reply: Permission.Reply
} }
| undefined | undefined
const unsub = Bus.subscribe(PermissionNext.Event.Replied, (event) => { const unsub = Bus.subscribe(Permission.Event.Replied, (event) => {
seen = event.properties seen = event.properties
}) })
await PermissionNext.reply({ await Permission.reply({
requestID: PermissionID.make("per_test7"), requestID: PermissionID.make("per_test7"),
reply: "once", reply: "once",
}) })
@@ -918,16 +910,141 @@ test("reply - publishes replied event", async () => {
}) })
}) })
test("permission requests stay isolated by directory", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
const a = Instance.provide({
directory: one.path,
fn: () =>
Permission.ask({
id: PermissionID.make("per_dir_a"),
sessionID: SessionID.make("session_dir_a"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}),
})
const b = Instance.provide({
directory: two.path,
fn: () =>
Permission.ask({
id: PermissionID.make("per_dir_b"),
sessionID: SessionID.make("session_dir_b"),
permission: "bash",
patterns: ["pwd"],
metadata: {},
always: [],
ruleset: [],
}),
})
const onePending = await Instance.provide({
directory: one.path,
fn: () => waitForPending(1),
})
const twoPending = await Instance.provide({
directory: two.path,
fn: () => waitForPending(1),
})
expect(onePending).toHaveLength(1)
expect(twoPending).toHaveLength(1)
expect(onePending[0].id).toBe(PermissionID.make("per_dir_a"))
expect(twoPending[0].id).toBe(PermissionID.make("per_dir_b"))
await Instance.provide({
directory: one.path,
fn: () => Permission.reply({ requestID: onePending[0].id, reply: "reject" }),
})
await Instance.provide({
directory: two.path,
fn: () => Permission.reply({ requestID: twoPending[0].id, reply: "reject" }),
})
await a.catch(() => {})
await b.catch(() => {})
})
test("pending permission rejects on instance dispose", async () => {
await using tmp = await tmpdir({ git: true })
const ask = Instance.provide({
directory: tmp.path,
fn: () =>
Permission.ask({
id: PermissionID.make("per_dispose"),
sessionID: SessionID.make("session_dispose"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}),
})
const result = ask.then(
() => "resolved" as const,
(err) => err,
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const pending = await waitForPending(1)
expect(pending).toHaveLength(1)
await Instance.dispose()
},
})
expect(await result).toBeInstanceOf(Permission.RejectedError)
})
test("pending permission rejects on instance reload", async () => {
await using tmp = await tmpdir({ git: true })
const ask = Instance.provide({
directory: tmp.path,
fn: () =>
Permission.ask({
id: PermissionID.make("per_reload"),
sessionID: SessionID.make("session_reload"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}),
})
const result = ask.then(
() => "resolved" as const,
(err) => err,
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const pending = await waitForPending(1)
expect(pending).toHaveLength(1)
await Instance.reload({ directory: tmp.path })
},
})
expect(await result).toBeInstanceOf(Permission.RejectedError)
})
test("reply - does nothing for unknown requestID", async () => { test("reply - does nothing for unknown requestID", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await PermissionNext.reply({ await Permission.reply({
requestID: PermissionID.make("per_unknown"), requestID: PermissionID.make("per_unknown"),
reply: "once", reply: "once",
}) })
expect(await PermissionNext.list()).toHaveLength(0) expect(await Permission.list()).toHaveLength(0)
}, },
}) })
}) })
@@ -938,7 +1055,7 @@ test("ask - checks all patterns and stops on first deny", async () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await expect( await expect(
PermissionNext.ask({ Permission.ask({
sessionID: SessionID.make("session_test"), sessionID: SessionID.make("session_test"),
permission: "bash", permission: "bash",
patterns: ["echo hello", "rm -rf /"], patterns: ["echo hello", "rm -rf /"],
@@ -949,7 +1066,7 @@ test("ask - checks all patterns and stops on first deny", async () => {
{ permission: "bash", pattern: "rm *", action: "deny" }, { permission: "bash", pattern: "rm *", action: "deny" },
], ],
}), }),
).rejects.toBeInstanceOf(PermissionNext.DeniedError) ).rejects.toBeInstanceOf(Permission.DeniedError)
}, },
}) })
}) })
@@ -959,7 +1076,7 @@ test("ask - allows all patterns when all match allow rules", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await PermissionNext.ask({ const result = await Permission.ask({
sessionID: SessionID.make("session_test"), sessionID: SessionID.make("session_test"),
permission: "bash", permission: "bash",
patterns: ["echo hello", "ls -la", "pwd"], patterns: ["echo hello", "ls -la", "pwd"],
@@ -977,7 +1094,7 @@ test("ask - should deny even when an earlier pattern is ask", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const err = await PermissionNext.ask({ const err = await Permission.ask({
sessionID: SessionID.make("session_test"), sessionID: SessionID.make("session_test"),
permission: "bash", permission: "bash",
patterns: ["echo hello", "rm -rf /"], patterns: ["echo hello", "rm -rf /"],
@@ -992,8 +1109,8 @@ test("ask - should deny even when an earlier pattern is ask", async () => {
(err) => err, (err) => err,
) )
expect(err).toBeInstanceOf(PermissionNext.DeniedError) expect(err).toBeInstanceOf(Permission.DeniedError)
expect(await PermissionNext.list()).toHaveLength(0) expect(await Permission.list()).toHaveLength(0)
}, },
}) })
}) })
@@ -1004,8 +1121,8 @@ test("ask - abort should clear pending request", async () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const ctl = new AbortController() const ctl = new AbortController()
const ask = runtime.runPromise( const ask = Permission.runPromise(
S.Service.use((svc) => (svc) =>
svc.ask({ svc.ask({
sessionID: SessionID.make("session_test"), sessionID: SessionID.make("session_test"),
permission: "bash", permission: "bash",
@@ -1014,7 +1131,6 @@ test("ask - abort should clear pending request", async () => {
always: [], always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
}), }),
).pipe(Effect.provide(Instances.get(Instance.directory))),
{ signal: ctl.signal }, { signal: ctl.signal },
) )
@@ -1023,7 +1139,7 @@ test("ask - abort should clear pending request", async () => {
await ask.catch(() => {}) await ask.catch(() => {})
try { try {
expect(await PermissionNext.list()).toHaveLength(0) expect(await Permission.list()).toHaveLength(0)
} finally { } finally {
await rejectAll() await rejectAll()
} }
@@ -31,15 +31,26 @@ describe("plugin.auth-override", () => {
}, },
}) })
await Instance.provide({ await using plain = await tmpdir()
const methods = await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const methods = await ProviderAuth.methods() return ProviderAuth.methods()
const copilot = methods[ProviderID.make("github-copilot")]
expect(copilot).toBeDefined()
expect(copilot.length).toBe(1)
expect(copilot[0].label).toBe("Test Override Auth")
}, },
}) })
const plainMethods = await Instance.provide({
directory: plain.path,
fn: async () => {
return ProviderAuth.methods()
},
})
const copilot = methods[ProviderID.make("github-copilot")]
expect(copilot).toBeDefined()
expect(copilot.length).toBe(1)
expect(copilot[0].label).toBe("Test Override Auth")
expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth")
}, 30000) // Increased timeout for plugin installation }, 30000) // Increased timeout for plugin installation
}) })
+5 -3
View File
@@ -25,8 +25,8 @@ function withVcs(
directory, directory,
Layer.merge(FileWatcher.layer, Vcs.layer), Layer.merge(FileWatcher.layer, Vcs.layer),
async (rt) => { async (rt) => {
await rt.runPromise(FileWatcher.Service.use(() => Effect.void)) await rt.runPromise(FileWatcher.Service.use((s) => s.init()))
await rt.runPromise(Vcs.Service.use(() => Effect.void)) await rt.runPromise(Vcs.Service.use((s) => s.init()))
await Bun.sleep(500) await Bun.sleep(500)
await body(rt) await body(rt)
}, },
@@ -67,7 +67,9 @@ function nextBranchUpdate(directory: string, timeout = 10_000) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describeVcs("Vcs", () => { describeVcs("Vcs", () => {
afterEach(() => Instance.disposeAll()) afterEach(async () => {
await Instance.disposeAll()
})
test("branch() returns current branch name", async () => { test("branch() returns current branch name", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
@@ -104,6 +104,72 @@ describe("ProviderTransform.options - setCacheKey", () => {
}) })
}) })
describe("ProviderTransform.options - google thinkingConfig gating", () => {
const sessionID = "test-session-123"
const createGoogleModel = (reasoning: boolean, npm: "@ai-sdk/google" | "@ai-sdk/google-vertex") =>
({
id: `${npm === "@ai-sdk/google" ? "google" : "google-vertex"}/gemini-2.0-flash`,
providerID: npm === "@ai-sdk/google" ? "google" : "google-vertex",
api: {
id: "gemini-2.0-flash",
url: npm === "@ai-sdk/google" ? "https://generativelanguage.googleapis.com" : "https://vertexai.googleapis.com",
npm,
},
name: "Gemini 2.0 Flash",
capabilities: {
temperature: true,
reasoning,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 0.001,
output: 0.002,
cache: { read: 0.0001, write: 0.0002 },
},
limit: {
context: 1_000_000,
output: 8192,
},
status: "active",
options: {},
headers: {},
}) as any
test("does not set thinkingConfig for google models without reasoning capability", () => {
const result = ProviderTransform.options({
model: createGoogleModel(false, "@ai-sdk/google"),
sessionID,
providerOptions: {},
})
expect(result.thinkingConfig).toBeUndefined()
})
test("sets thinkingConfig for google models with reasoning capability", () => {
const result = ProviderTransform.options({
model: createGoogleModel(true, "@ai-sdk/google"),
sessionID,
providerOptions: {},
})
expect(result.thinkingConfig).toEqual({
includeThoughts: true,
})
})
test("does not set thinkingConfig for vertex models without reasoning capability", () => {
const result = ProviderTransform.options({
model: createGoogleModel(false, "@ai-sdk/google-vertex"),
sessionID,
providerOptions: {},
})
expect(result.thinkingConfig).toBeUndefined()
})
})
describe("ProviderTransform.options - gpt-5 textVerbosity", () => { describe("ProviderTransform.options - gpt-5 textVerbosity", () => {
const sessionID = "test-session-123" const sessionID = "test-session-123"
@@ -320,3 +320,134 @@ test("list - returns empty when no pending", async () => {
}, },
}) })
}) })
test("questions stay isolated by directory", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
const p1 = Instance.provide({
directory: one.path,
fn: () =>
Question.ask({
sessionID: SessionID.make("ses_one"),
questions: [
{
question: "Question 1?",
header: "Q1",
options: [{ label: "A", description: "A" }],
},
],
}),
})
const p2 = Instance.provide({
directory: two.path,
fn: () =>
Question.ask({
sessionID: SessionID.make("ses_two"),
questions: [
{
question: "Question 2?",
header: "Q2",
options: [{ label: "B", description: "B" }],
},
],
}),
})
const onePending = await Instance.provide({
directory: one.path,
fn: () => Question.list(),
})
const twoPending = await Instance.provide({
directory: two.path,
fn: () => Question.list(),
})
expect(onePending.length).toBe(1)
expect(twoPending.length).toBe(1)
expect(onePending[0].sessionID).toBe(SessionID.make("ses_one"))
expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two"))
await Instance.provide({
directory: one.path,
fn: () => Question.reject(onePending[0].id),
})
await Instance.provide({
directory: two.path,
fn: () => Question.reject(twoPending[0].id),
})
await p1.catch(() => {})
await p2.catch(() => {})
})
test("pending question rejects on instance dispose", async () => {
await using tmp = await tmpdir({ git: true })
const ask = Instance.provide({
directory: tmp.path,
fn: () => {
return Question.ask({
sessionID: SessionID.make("ses_dispose"),
questions: [
{
question: "Dispose me?",
header: "Dispose",
options: [{ label: "Yes", description: "Yes" }],
},
],
})
},
})
const result = ask.then(
() => "resolved" as const,
(err) => err,
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const pending = await Question.list()
expect(pending).toHaveLength(1)
await Instance.dispose()
},
})
expect(await result).toBeInstanceOf(Question.RejectedError)
})
test("pending question rejects on instance reload", async () => {
await using tmp = await tmpdir({ git: true })
const ask = Instance.provide({
directory: tmp.path,
fn: () => {
return Question.ask({
sessionID: SessionID.make("ses_reload"),
questions: [
{
question: "Reload me?",
header: "Reload",
options: [{ label: "Yes", description: "Yes" }],
},
],
})
},
})
const result = ask.then(
() => "resolved" as const,
(err) => err,
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const pending = await Question.list()
expect(pending).toHaveLength(1)
await Instance.reload({ directory: tmp.path })
},
})
expect(await result).toBeInstanceOf(Question.RejectedError)
})
@@ -7,7 +7,7 @@ test("ShareNext.request uses legacy share API without active org account", async
const originalActive = Account.active const originalActive = Account.active
const originalConfigGet = Config.get const originalConfigGet = Config.get
Account.active = mock(() => undefined) Account.active = mock(async () => undefined)
Config.get = mock(async () => ({ enterprise: { url: "https://legacy-share.example.com" } })) Config.get = mock(async () => ({ enterprise: { url: "https://legacy-share.example.com" } }))
try { try {
@@ -29,7 +29,7 @@ test("ShareNext.request uses org share API with auth headers when account is act
const originalActive = Account.active const originalActive = Account.active
const originalToken = Account.token const originalToken = Account.token
Account.active = mock(() => ({ Account.active = mock(async () => ({
id: AccountID.make("account-1"), id: AccountID.make("account-1"),
email: "user@example.com", email: "user@example.com",
url: "https://control.example.com", url: "https://control.example.com",
@@ -59,7 +59,7 @@ test("ShareNext.request fails when org account has no token", async () => {
const originalActive = Account.active const originalActive = Account.active
const originalToken = Account.token const originalToken = Account.token
Account.active = mock(() => ({ Account.active = mock(async () => ({
id: AccountID.make("account-1"), id: AccountID.make("account-1"),
email: "user@example.com", email: "user@example.com",
url: "https://control.example.com", url: "https://control.example.com",

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