Compare commits

..

11 Commits

Author SHA1 Message Date
Kit Langton
3d7501a6a3 refactor(task): split task spec from prompt execution 2026-04-10 13:42:58 -04:00
Kit Langton
bf601628db refactor(tool): convert codesearch tool internals to Effect (#21811) 2026-04-10 11:49:20 -04:00
opencode-agent[bot]
00e39d2114 chore: generate 2026-04-10 15:31:39 +00:00
Kit Langton
46b74e0873 refactor(tool): convert websearch tool internals to Effect (#21810) 2026-04-10 11:30:38 -04:00
opencode-agent[bot]
aedc4e964f chore: generate 2026-04-10 14:51:27 +00:00
Kit Langton
e83404367c refactor(tool): convert webfetch tool internals to Effect (#21809) 2026-04-10 10:50:13 -04:00
James Long
42206da1f8 refactor(tui): switch to global events and start passing workspace param (#21719) 2026-04-10 10:47:27 -04:00
Kit Langton
44f38193c0 refactor(tool): convert plan tool internals to Effect (#21807) 2026-04-10 10:38:46 -04:00
opencode-agent[bot]
9a6b455bfe chore: generate 2026-04-10 14:08:27 +00:00
Kit Langton
8063e0b5c6 refactor(tool): convert lsp tool internals to Effect (#21806) 2026-04-10 10:07:19 -04:00
Kit Langton
157c5d77f8 refactor(tool): convert question tool internals to Effect (#21808) 2026-04-10 09:42:06 -04:00
47 changed files with 1659 additions and 1094 deletions

View File

@@ -4,6 +4,8 @@ export const GlobalBus = new EventEmitter<{
event: [
{
directory?: string
project?: string
workspace?: string
payload: any
},
]

View File

@@ -1,9 +1,9 @@
import z from "zod"
import { Effect, Exit, Layer, PubSub, Scope, ServiceMap, Stream } from "effect"
import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
@@ -91,8 +91,13 @@ export namespace Bus {
yield* PubSub.publish(s.wildcard, payload)
const dir = yield* InstanceState.directory
const context = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
GlobalBus.emit("event", {
directory: dir,
project: context.project.id,
workspace,
payload,
})
})

View File

@@ -14,7 +14,6 @@ import {
batch,
Show,
on,
onCleanup,
} from "solid-js"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { Flag } from "@/flag/flag"
@@ -23,6 +22,8 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
import { ErrorComponent } from "@tui/component/error-component"
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
import { ProjectProvider } from "@tui/context/project"
import { useEvent } from "@tui/context/event"
import { SDKProvider, useSDK } from "@tui/context/sdk"
import { StartupLoading } from "@tui/component/startup-loading"
import { SyncProvider, useSync } from "@tui/context/sync"
@@ -54,7 +55,6 @@ import { KVProvider, useKV } from "./context/kv"
import { Provider } from "@/provider/provider"
import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
import { TuiConfig } from "@/config/tui"
@@ -216,27 +216,29 @@ export function tui(input: {
headers={input.headers}
events={input.events}
>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App onSnapshot={input.onSnapshot} />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
<ProjectProvider>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App onSnapshot={input.onSnapshot} />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</ProjectProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
@@ -260,6 +262,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
const kv = useKV()
const command = useCommandDialog()
const keybind = useKeybind()
const event = useEvent()
const sdk = useSDK()
const toast = useToast()
const themeState = useTheme()
@@ -283,6 +286,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
route,
routes,
bump: () => setRouteRev((x) => x + 1),
event,
sdk,
sync,
theme: themeState,
@@ -491,12 +495,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
const current = promptRef.current
// Don't require focus - if there's any text, preserve it
const currentPrompt = current?.current?.input ? current.current : undefined
const workspaceID =
route.data.type === "session" ? sync.session.get(route.data.sessionID)?.workspaceID : undefined
route.navigate({
type: "home",
initialPrompt: currentPrompt,
workspaceID,
})
dialog.clear()
},
@@ -806,11 +807,11 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
},
])
sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
event.on(TuiEvent.CommandExecute.type, (evt) => {
command.trigger(evt.properties.command)
})
sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
event.on(TuiEvent.ToastShow.type, (evt) => {
toast.show({
title: evt.properties.title,
message: evt.properties.message,
@@ -819,14 +820,14 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
})
})
sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
event.on(TuiEvent.SessionSelect.type, (evt) => {
route.navigate({
type: "session",
sessionID: evt.properties.sessionID,
})
})
sdk.event.on("session.deleted", (evt) => {
event.on("session.deleted", (evt) => {
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
route.navigate({ type: "home" })
toast.show({
@@ -836,7 +837,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}
})
sdk.event.on("session.error", (evt) => {
event.on("session.error", (evt) => {
const error = evt.properties.error
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
const message = errorMessage(error)
@@ -848,7 +849,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
})
})
sdk.event.on("installation.update-available", async (evt) => {
event.on("installation.update-available", async (evt) => {
const version = evt.properties.version
const skipped = kv.get("skipped_version")

View File

@@ -1,5 +1,6 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useProject } from "@tui/context/project"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
@@ -14,7 +15,7 @@ function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>
return createOpencodeClient({
baseUrl: sdk.url,
fetch: sdk.fetch,
directory: sync.data.path.directory || sdk.directory,
directory: sync.path.directory || sdk.directory,
experimental_workspaceID: workspaceID,
})
}
@@ -149,6 +150,7 @@ function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promi
export function DialogWorkspaceList() {
const dialog = useDialog()
const project = useProject()
const route = useRoute()
const sync = useSync()
const sdk = useSDK()
@@ -168,8 +170,9 @@ export function DialogWorkspaceList() {
forceCreate,
})
async function selectWorkspace(workspaceID: string) {
if (workspaceID === "__local__") {
async function selectWorkspace(workspaceID: string | null) {
if (workspaceID == null) {
project.workspace.set(undefined)
if (localCount() > 0) {
dialog.replace(() => <DialogSessionList localOnly={true} />)
return
@@ -199,12 +202,7 @@ export function DialogWorkspaceList() {
await open(workspaceID)
}
const currentWorkspaceID = createMemo(() => {
if (route.data.type === "session") {
return sync.session.get(route.data.sessionID)?.workspaceID ?? "__local__"
}
return "__local__"
})
const currentWorkspaceID = createMemo(() => project.workspace.current())
const localCount = createMemo(
() => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length,
@@ -234,7 +232,7 @@ export function DialogWorkspaceList() {
const options = createMemo(() => [
{
title: "Local",
value: "__local__",
value: null,
category: "Workspace",
description: "Use the local machine",
footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`,
@@ -292,7 +290,7 @@ export function DialogWorkspaceList() {
keybind: keybind.all.session_delete?.[0],
title: "delete",
onTrigger: async (option) => {
if (option.value === "__create__" || option.value === "__local__") return
if (option.value === "__create__" || option.value === null) return
if (toDelete() !== option.value) {
setToDelete(option.value)
return
@@ -307,6 +305,7 @@ export function DialogWorkspaceList() {
return
}
if (currentWorkspaceID() === option.value) {
project.workspace.set(undefined)
route.navigate({
type: "home",
})

View File

@@ -250,7 +250,7 @@ export function Autocomplete(props: {
const width = props.anchor().width - 4
options.push(
...sortedFiles.map((item): AutocompleteOption => {
const baseDir = (sync.data.path.directory || process.cwd()).replace(/\/+$/, "")
const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "")
const fullPath = `${baseDir}/${item}`
const urlObj = pathToFileURL(fullPath)
let filename = item

View File

@@ -10,6 +10,7 @@ import { EmptyBorder, SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { MessageID, PartID } from "@/session/schema"
import { createStore, produce } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
@@ -115,8 +116,9 @@ export function Prompt(props: PromptProps) {
const agentStyleId = syntax().getStyleId("extmark.agent")!
const pasteStyleId = syntax().getStyleId("extmark.paste")!
let promptPartTypeId = 0
const event = useEvent()
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
event.on(TuiEvent.PromptAppend.type, (evt) => {
if (!input || input.isDestroyed) return
input.insertText(evt.properties.text)
setTimeout(() => {

View File

@@ -1,11 +1,13 @@
import { createMemo } from "solid-js"
import { useProject } from "./project"
import { useSync } from "./sync"
import { Global } from "@/global"
export function useDirectory() {
const project = useProject()
const sync = useSync()
return createMemo(() => {
const directory = sync.data.path.directory || process.cwd()
const directory = project.instance.path().directory || process.cwd()
const result = directory.replace(Global.Path.home, "~")
if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
return result

View File

@@ -0,0 +1,41 @@
import type { Event } from "@opencode-ai/sdk/v2"
import { useProject } from "./project"
import { useSDK } from "./sdk"
export function useEvent() {
const project = useProject()
const sdk = useSDK()
function subscribe(handler: (event: Event) => void) {
return sdk.event.on("event", (event) => {
// Special hack for truly global events
if (event.directory === "global") {
handler(event.payload)
}
if (project.workspace.current()) {
if (event.workspace === project.workspace.current()) {
handler(event.payload)
}
return
}
if (event.directory === project.instance.directory()) {
handler(event.payload)
}
})
}
function on<T extends Event["type"]>(type: T, handler: (event: Extract<Event, { type: T }>) => void) {
return subscribe((event) => {
if (event.type !== type) return
handler(event as Extract<Event, { type: T }>)
})
}
return {
subscribe,
on,
}
}

View File

@@ -0,0 +1,65 @@
import { batch } from "solid-js"
import type { Path } from "@opencode-ai/sdk"
import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
export const { use: useProject, provider: ProjectProvider } = createSimpleContext({
name: "Project",
init: () => {
const sdk = useSDK()
const [store, setStore] = createStore({
project: {
id: undefined as string | undefined,
},
instance: {
path: {
state: "",
config: "",
worktree: "",
directory: sdk.directory ?? "",
} satisfies Path,
},
workspace: undefined as string | undefined,
})
async function sync() {
const workspace = store.workspace
const [path, project] = await Promise.all([
sdk.client.path.get({ workspace }),
sdk.client.project.current({ workspace }),
])
batch(() => {
setStore("instance", "path", reconcile(path.data!))
setStore("project", "id", project.data?.id)
})
}
return {
data: store,
project() {
return store.project.id
},
instance: {
path() {
return store.instance.path
},
directory() {
return store.instance.path.directory
},
},
workspace: {
current() {
return store.workspace
},
set(next?: string | null) {
const workspace = next ?? undefined
if (store.workspace === workspace) return
setStore("workspace", workspace)
},
},
sync,
}
},
})

View File

@@ -5,7 +5,6 @@ import type { PromptInfo } from "../component/prompt/history"
export type HomeRoute = {
type: "home"
initialPrompt?: PromptInfo
workspaceID?: string
}
export type SessionRoute = {

View File

@@ -1,10 +1,11 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { GlobalEvent, Event } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup, onMount } from "solid-js"
export type EventSource = {
subscribe: (directory: string | undefined, handler: (event: Event) => void) => Promise<() => void>
subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void>
}
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
@@ -32,10 +33,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
let sdk = createSDK()
const emitter = createGlobalEmitter<{
[key in Event["type"]]: Extract<Event, { type: key }>
event: GlobalEvent
}>()
let queue: Event[] = []
let queue: GlobalEvent[] = []
let timer: Timer | undefined
let last = 0
@@ -48,12 +49,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
// Batch all event emissions so all store updates result in a single render
batch(() => {
for (const event of events) {
emitter.emit(event.type, event)
emitter.emit("event", event)
}
})
}
const handleEvent = (event: Event) => {
const handleEvent = (event: GlobalEvent) => {
queue.push(event)
const elapsed = Date.now() - last
@@ -74,7 +75,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
;(async () => {
while (true) {
if (abort.signal.aborted || ctrl.signal.aborted) break
const events = await sdk.event.subscribe({}, { signal: ctrl.signal })
const events = await sdk.global.event({ signal: ctrl.signal })
for await (const event of events.stream) {
if (ctrl.signal.aborted) break
@@ -89,7 +90,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
onMount(async () => {
if (props.events) {
const unsub = await props.events.subscribe(props.directory, handleEvent)
const unsub = await props.events.subscribe(handleEvent)
onCleanup(unsub)
} else {
startSSE()

View File

@@ -17,18 +17,19 @@ import type {
ProviderListResponse,
ProviderAuthMethod,
VcsInfo,
Workspace,
} from "@opencode-ai/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store"
import { useProject } from "@tui/context/project"
import { useEvent } from "@tui/context/event"
import { useSDK } from "@tui/context/sdk"
import { Binary } from "@opencode-ai/util/binary"
import { createSimpleContext } from "./helper"
import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { useArgs } from "./args"
import { batch, onMount } from "solid-js"
import { batch, createEffect, on } from "solid-js"
import { Log } from "@/util/log"
import type { Path } from "@opencode-ai/sdk"
import type { Workspace } from "@opencode-ai/sdk/v2"
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
@@ -74,9 +75,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
[key: string]: McpResource
}
formatter: FormatterStatus[]
vcs: VcsInfo | undefined
path: Path
workspaceList: Workspace[]
vcs: VcsInfo | undefined
}>({
provider_next: {
all: [],
@@ -103,21 +103,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
mcp: {},
mcp_resource: {},
formatter: [],
vcs: undefined,
path: { state: "", config: "", worktree: "", directory: "" },
workspaceList: [],
vcs: undefined,
})
const event = useEvent()
const project = useProject()
const sdk = useSDK()
async function syncWorkspaces() {
const workspace = project.workspace.current()
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
if (!result?.data) return
setStore("workspaceList", reconcile(result.data))
if (!result.data.some((item) => item.id === workspace)) {
project.workspace.set(undefined)
}
}
sdk.event.listen((e) => {
const event = e.details
event.subscribe((event) => {
switch (event.type) {
case "server.instance.disposed":
bootstrap()
@@ -344,7 +348,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
case "lsp.updated": {
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!))
const workspace = project.workspace.current()
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data!))
break
}
@@ -360,25 +365,28 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
async function bootstrap() {
console.log("bootstrapping")
const workspace = project.workspace.current()
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const sessionListPromise = sdk.client.session
.list({ start: start })
.list({ start: start, workspace })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
// blocking - include session.list when continuing a session
const providersPromise = sdk.client.config.providers({}, { throwOnError: true })
const providerListPromise = sdk.client.provider.list({}, { throwOnError: true })
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
const consoleStatePromise = sdk.client.experimental.console
.get({}, { throwOnError: true })
.get({ workspace }, { throwOnError: true })
.then((x) => ConsoleState.parse(x.data))
.catch(() => emptyConsoleState)
const agentsPromise = sdk.client.app.agents({}, { throwOnError: true })
const configPromise = sdk.client.config.get({}, { throwOnError: true })
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
const projectPromise = project.sync()
const blockingRequests: Promise<unknown>[] = [
providersPromise,
providerListPromise,
agentsPromise,
configPromise,
projectPromise,
...(args.continue ? [sessionListPromise] : []),
]
@@ -423,17 +431,18 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
Promise.all([
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))),
sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))),
sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))),
sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))),
sdk.client.session.status().then((x) => {
sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data!))),
sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data!))),
sdk.client.experimental.resource
.list({ workspace })
.then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data!))),
sdk.client.session.status({ workspace }).then((x) => {
setStore("session_status", reconcile(x.data!))
}),
sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))),
sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
syncWorkspaces(),
]).then(() => {
setStore("status", "complete")
@@ -449,11 +458,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
}
onMount(() => {
bootstrap()
})
const fullSyncedSessions = new Set<string>()
createEffect(
on(
() => project.workspace.current(),
() => {
fullSyncedSessions.clear()
void bootstrap()
},
),
)
const result = {
data: store,
set: setStore,
@@ -463,6 +478,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
get ready() {
return store.status !== "loading"
},
get path() {
return project.instance.path()
},
session: {
get(sessionID: string) {
const match = Binary.search(store.session, sessionID, (s) => s.id)
@@ -481,11 +499,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
},
async sync(sessionID: string) {
if (fullSyncedSessions.has(sessionID)) return
const workspace = project.workspace.current()
const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100 }),
sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID }),
sdk.client.session.get({ sessionID, workspace }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100, workspace }),
sdk.client.session.todo({ sessionID, workspace }),
sdk.client.session.diff({ sessionID, workspace }),
])
setStore(
produce((draft) => {
@@ -504,8 +523,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
},
},
workspace: {
list() {
return store.workspaceList
},
get(workspaceID: string) {
return store.workspaceList.find((workspace) => workspace.id === workspaceID)
return store.workspaceList.find((item) => item.id === workspaceID)
},
sync: syncWorkspaces,
},

View File

@@ -1,6 +1,7 @@
import type { ParsedKey } from "@opentui/core"
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui"
import type { useCommandDialog } from "@tui/component/dialog-command"
import type { useEvent } from "@tui/context/event"
import type { useKeybind } from "@tui/context/keybind"
import type { useRoute } from "@tui/context/route"
import type { useSDK } from "@tui/context/sdk"
@@ -36,6 +37,7 @@ type Input = {
route: ReturnType<typeof useRoute>
routes: RouteMap
bump: () => void
event: ReturnType<typeof useEvent>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
theme: ReturnType<typeof useTheme>
@@ -136,7 +138,7 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
return sync.data.provider
},
get path() {
return sync.data.path
return sync.path
},
get vcs() {
if (!sync.data.vcs) return
@@ -342,7 +344,7 @@ export function createTuiApi(input: Input): TuiPluginApi {
get client() {
return input.sdk.client
},
event: input.sdk.event,
event: input.event,
renderer: input.renderer,
slots: {
register() {

View File

@@ -1,6 +1,7 @@
import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createEffect, createSignal } from "solid-js"
import { Logo } from "../component/logo"
import { useProject } from "../context/project"
import { useSync } from "../context/sync"
import { Toast } from "../ui/toast"
import { useArgs } from "../context/args"
@@ -18,6 +19,7 @@ const placeholder = {
export function Home() {
const sync = useSync()
const project = useProject()
const route = useRouteData("home")
const promptRef = usePromptRef()
const [ref, setRef] = createSignal<PromptRef | undefined>()
@@ -63,11 +65,16 @@ export function Home() {
</box>
<box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
<TuiPluginRuntime.Slot name="home_prompt" mode="replace" workspace_id={route.workspaceID} ref={bind}>
<TuiPluginRuntime.Slot
name="home_prompt"
mode="replace"
workspace_id={project.workspace.current()}
ref={bind}
>
<Prompt
ref={bind}
workspaceID={route.workspaceID}
right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={route.workspaceID} />}
workspaceID={project.workspace.current()}
right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={project.workspace.current()} />}
placeholders={placeholder}
/>
</TuiPluginRuntime.Slot>

View File

@@ -15,7 +15,9 @@ import {
import { Dynamic } from "solid-js/web"
import path from "path"
import { useRoute, useRouteData } from "@tui/context/route"
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { selectedForeground, useTheme } from "@tui/context/theme"
@@ -116,6 +118,8 @@ export function Session() {
const route = useRouteData("session")
const { navigate } = useRoute()
const sync = useSync()
const event = useEvent()
const project = useProject()
const tuiConfig = useTuiConfig()
const kv = useKV()
const { theme } = useTheme()
@@ -172,10 +176,16 @@ export function Session() {
const providers = createMemo(() => Model.index(sync.data.provider))
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const toast = useToast()
const sdk = useSDK()
createEffect(async () => {
await sync.session
.sync(route.sessionID)
await sdk.client.session
.get({ sessionID: route.sessionID }, { throwOnError: true })
.then((x) => {
project.workspace.set(x.data?.workspaceID)
})
.then(() => sync.session.sync(route.sessionID))
.then(() => {
if (scroll) scroll.scrollBy(100_000)
})
@@ -189,13 +199,10 @@ export function Session() {
})
})
const toast = useToast()
const sdk = useSDK()
// Handle initial prompt from fork
let seeded = false
let lastSwitch: string | undefined = undefined
sdk.event.on("message.part.updated", (evt) => {
event.on("message.part.updated", (evt) => {
const part = evt.properties.part
if (part.type !== "tool") return
if (part.sessionID !== route.sessionID) return
@@ -224,7 +231,7 @@ export function Session() {
const dialog = useDialog()
const renderer = useRenderer()
sdk.event.on("session.status", (evt) => {
event.on("session.status", (evt) => {
if (evt.properties.sessionID !== route.sessionID) return
if (evt.properties.status.type !== "retry") return
if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return
@@ -1791,7 +1798,7 @@ function Bash(props: ToolProps<typeof BashTool>) {
const workdir = props.input.workdir
if (!workdir || workdir === ".") return undefined
const base = sync.data.path.directory
const base = sync.path.directory
if (!base) return undefined
const absolute = path.resolve(base, workdir)

View File

@@ -10,7 +10,7 @@ import { errorMessage } from "@/util/error"
import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
import { Filesystem } from "@/util/filesystem"
import type { Event } from "@opencode-ai/sdk/v2"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui"
@@ -43,18 +43,10 @@ function createWorkerFetch(client: RpcClient): typeof fetch {
function createEventSource(client: RpcClient): EventSource {
return {
subscribe: async (directory, handler) => {
const id = await client.call("subscribe", { directory })
const unsub = client.on<{ id: string; event: Event }>("event", (e) => {
if (e.id === id) {
handler(e.event)
}
subscribe: async (handler) => {
return client.on<GlobalEvent>("global.event", (e) => {
handler(e)
})
return () => {
unsub()
client.call("unsubscribe", { id })
}
},
}
}

View File

@@ -6,13 +6,10 @@ import { InstanceBootstrap } from "@/project/bootstrap"
import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import type { Event } from "@opencode-ai/sdk/v2"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { Flag } from "@/flag/flag"
import { setTimeout as sleep } from "node:timers/promises"
import { writeHeapSnapshot } from "node:v8"
import { WorkspaceID } from "@/control-plane/schema"
import { Heap } from "@/cli/heap"
await Log.init({
@@ -45,87 +42,6 @@ GlobalBus.on("event", (event) => {
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
const eventStreams = new Map<string, AbortController>()
function startEventStream(directory: string) {
const id = crypto.randomUUID()
const abort = new AbortController()
const signal = abort.signal
eventStreams.set(id, abort)
async function run() {
while (!signal.aborted) {
const shouldReconnect = await Instance.provide({
directory,
init: InstanceBootstrap,
fn: () =>
new Promise<boolean>((resolve) => {
Rpc.emit("event", {
type: "server.connected",
properties: {},
} satisfies Event)
let settled = false
const settle = (value: boolean) => {
if (settled) return
settled = true
signal.removeEventListener("abort", onAbort)
unsub()
resolve(value)
}
const unsub = Bus.subscribeAll((event) => {
Rpc.emit("event", {
id,
event: event as Event,
})
if (event.type === Bus.InstanceDisposed.type) {
settle(true)
}
})
const onAbort = () => {
settle(false)
}
signal.addEventListener("abort", onAbort, { once: true })
}),
}).catch((error) => {
Log.Default.error("event stream subscribe error", {
error: error instanceof Error ? error.message : error,
})
return false
})
if (!shouldReconnect || signal.aborted) {
break
}
if (!signal.aborted) {
await sleep(250)
}
}
}
run().catch((error) => {
Log.Default.error("event stream error", {
error: error instanceof Error ? error.message : error,
})
})
return id
}
function stopEventStream(id: string) {
const abortController = eventStreams.get(id)
if (!abortController) return
abortController.abort()
eventStreams.delete(id)
}
export const rpc = {
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
const headers = { ...input.headers }
@@ -167,19 +83,9 @@ export const rpc = {
async reload() {
await Config.invalidate(true)
},
async subscribe(input: { directory: string | undefined }) {
return startEventStream(input.directory || process.cwd())
},
async unsubscribe(input: { id: string }) {
stopEventStream(input.id)
},
async shutdown() {
Log.Default.info("worker shutting down")
for (const id of [...eventStreams.keys()]) {
stopEventStream(id)
}
await Instance.disposeAll()
if (server) await server.stop(true)
},

View File

@@ -41,7 +41,6 @@ import { Duration, Effect, Layer, Option, ServiceMap } from "effect"
import { Flock } from "@/util/flock"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
import { Npm } from "@/npm"
import { InstanceRef } from "@/effect/instance-ref"
export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@@ -1328,31 +1327,27 @@ export namespace Config {
const consoleManagedProviders = new Set<string>()
let activeOrgName: string | undefined
const scope = Effect.fnUntraced(function* (source: string) {
const scope = (source: string): PluginScope => {
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
if (source === "OPENCODE_CONFIG_CONTENT") return "local"
if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
if (Instance.containsPath(source)) return "local"
return "global"
})
}
const track = Effect.fnUntraced(function* (
source: string,
list: PluginSpec[] | undefined,
kind?: PluginScope,
) {
const track = (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) => {
if (!list?.length) return
const hit = kind ?? (yield* scope(source))
const hit = kind ?? scope(source)
const plugins = deduplicatePluginOrigins([
...(result.plugin_origins ?? []),
...list.map((spec) => ({ spec, source, scope: hit })),
])
result.plugin = plugins.map((item) => item.spec)
result.plugin_origins = plugins
})
}
const merge = (source: string, next: Info, kind?: PluginScope) => {
result = mergeConfigConcatArrays(result, next)
return track(source, next.plugin, kind)
track(source, next.plugin, kind)
}
for (const [key, value] of Object.entries(auth)) {
@@ -1372,16 +1367,16 @@ export namespace Config {
dir: path.dirname(source),
source,
})
yield* merge(source, next, "global")
merge(source, next, "global")
log.debug("loaded remote config from well-known", { url })
}
}
const global = yield* getGlobal()
yield* merge(Global.Path.config, global, "global")
merge(Global.Path.config, global, "global")
if (Flag.OPENCODE_CONFIG) {
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
@@ -1389,7 +1384,7 @@ export namespace Config {
for (const file of yield* Effect.promise(() =>
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
)) {
yield* merge(file, yield* loadFile(file), "local")
merge(file, yield* loadFile(file), "local")
}
}
@@ -1410,7 +1405,7 @@ export namespace Config {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(dir, file)
log.debug(`loading config from ${source}`)
yield* merge(source, yield* loadFile(source))
merge(source, yield* loadFile(source))
result.agent ??= {}
result.mode ??= {}
result.plugin ??= []
@@ -1429,7 +1424,7 @@ export namespace Config {
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
const list = yield* Effect.promise(() => loadPlugin(dir))
yield* track(dir, list)
track(dir, list)
}
if (process.env.OPENCODE_CONFIG_CONTENT) {
@@ -1438,7 +1433,7 @@ export namespace Config {
dir: ctx.directory,
source,
})
yield* merge(source, next, "local")
merge(source, next, "local")
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
@@ -1467,7 +1462,7 @@ export namespace Config {
for (const providerID of Object.keys(next.provider ?? {})) {
consoleManagedProviders.add(providerID)
}
yield* merge(source, next, "global")
merge(source, next, "global")
}
}).pipe(
Effect.catch((err) => {
@@ -1482,7 +1477,7 @@ export namespace Config {
if (existsSync(managedDir)) {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(managedDir, file)
yield* merge(source, yield* loadFile(source), "global")
merge(source, yield* loadFile(source), "global")
}
}

View File

@@ -0,0 +1,22 @@
import { Context } from "../util/context"
import type { WorkspaceID } from "../control-plane/schema"
export interface WorkspaceContext {
workspaceID: string
}
const context = Context.create<WorkspaceContext>("instance")
export const WorkspaceContext = {
async provide<R>(input: { workspaceID: WorkspaceID; fn: () => R }): Promise<R> {
return context.provide({ workspaceID: input.workspaceID as string }, () => input.fn())
},
get workspaceID() {
try {
return context.use().workspaceID
} catch (err) {
return undefined
}
},
}

View File

@@ -134,12 +134,12 @@ export namespace Workspace {
continue
}
await parseSSE(res.body, stop, (event) => {
GlobalBus.emit("event", {
directory: space.id,
payload: event,
})
})
// await parseSSE(res.body, stop, (event) => {
// GlobalBus.emit("event", {
// directory: space.id,
// payload: event,
// })
// })
// Wait 250ms and retry if SSE connection fails
await sleep(250)

View File

@@ -4,3 +4,7 @@ import type { InstanceContext } from "@/project/instance"
export const InstanceRef = ServiceMap.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
defaultValue: () => undefined,
})
export const WorkspaceRef = ServiceMap.Reference<string | undefined>("~opencode/WorkspaceRef", {
defaultValue: () => undefined,
})

View File

@@ -1,8 +1,9 @@
import { Effect, Fiber, ScopedCache, Scope, ServiceMap } from "effect"
import { Instance, type InstanceContext } from "@/project/instance"
import { Context } from "@/util/context"
import { InstanceRef } from "./instance-ref"
import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { registerDisposer } from "./instance-registry"
import { WorkspaceContext } from "@/control-plane/workspace-context"
const TypeId = "~opencode/InstanceState"
@@ -28,6 +29,10 @@ export namespace InstanceState {
return (yield* InstanceRef) ?? Instance.current
})
export const workspaceID = Effect.gen(function* () {
return (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID
})
export const directory = Effect.map(context, (ctx) => ctx.directory)
export const make = <A, E = never, R = never>(

View File

@@ -2,15 +2,17 @@ import { Effect, Layer, ManagedRuntime } from "effect"
import * as ServiceMap from "effect/ServiceMap"
import { Instance } from "@/project/instance"
import { Context } from "@/util/context"
import { InstanceRef } from "./instance-ref"
import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { Observability } from "./oltp"
import { WorkspaceContext } from "@/control-plane/workspace-context"
export const memoMap = Layer.makeMemoMapUnsafe()
function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
try {
const ctx = Instance.current
return Effect.provideService(effect, InstanceRef, ctx)
const workspaceID = WorkspaceContext.workspaceID
return effect.pipe(Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, workspaceID))
} catch (err) {
if (!(err instanceof Context.NotFound)) throw err
}

View File

@@ -5,6 +5,7 @@ import { iife } from "@/util/iife"
import { Log } from "@/util/log"
import { Context } from "../util/context"
import { Project } from "./project"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { State } from "./state"
export interface InstanceContext {
@@ -20,19 +21,9 @@ const disposal = {
all: undefined as Promise<void> | undefined,
}
function emit(directory: string) {
GlobalBus.emit("event", {
directory,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
})
}
function emitDisposed(directory: string) {}
function boot(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
function boot(input: { directory: string; init?: () => Promise<any>; worktree?: string; project?: Project.Info }) {
return iife(async () => {
const ctx =
input.project && input.worktree
@@ -93,18 +84,18 @@ export const Instance = {
get project() {
return context.use().project
},
/**
* Check if a path is within the project boundary.
* Returns true if path is inside Instance.directory OR Instance.worktree.
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
*/
containsPath(filepath: string, ctx?: InstanceContext) {
const instance = ctx ?? Instance
if (Filesystem.contains(instance.directory, filepath)) return true
containsPath(filepath: string) {
if (Filesystem.contains(Instance.directory, filepath)) return true
// Non-git projects set worktree to "/" which would match ANY absolute path.
// Skip worktree check in this case to preserve external_directory permissions.
if (Instance.worktree === "/") return false
return Filesystem.contains(instance.worktree, filepath)
return Filesystem.contains(Instance.worktree, filepath)
},
/**
* Captures the current instance ALS context and returns a wrapper that
@@ -132,15 +123,39 @@ export const Instance = {
await Promise.all([State.dispose(directory), disposeInstance(directory)])
cache.delete(directory)
const next = track(directory, boot({ ...input, directory }))
emit(directory)
GlobalBus.emit("event", {
directory,
project: input.project?.id,
workspace: WorkspaceContext.workspaceID,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
})
return await next
},
async dispose() {
const directory = Instance.directory
const project = Instance.project
Log.Default.info("disposing instance", { directory })
await Promise.all([State.dispose(directory), disposeInstance(directory)])
cache.delete(directory)
emit(directory)
GlobalBus.emit("event", {
directory,
project: project.id,
workspace: WorkspaceContext.workspaceID,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
})
},
async disposeAll() {
if (disposal.all) return disposal.all

View File

@@ -137,6 +137,8 @@ export namespace Project {
const emitUpdated = (data: Info) =>
Effect.sync(() =>
GlobalBus.emit("event", {
directory: "global",
project: data.id,
payload: { type: Event.Updated.type, properties: data },
}),
)

View File

@@ -9,6 +9,9 @@ import { Filesystem } from "@/util/filesystem"
import { Instance } from "@/project/instance"
import { InstanceBootstrap } from "@/project/bootstrap"
import { InstanceRoutes } from "./instance"
import { Session } from "@/session"
import { SessionID } from "@/session/schema"
import { WorkspaceContext } from "@/control-plane/workspace-context"
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
@@ -26,6 +29,16 @@ function local(method: string, path: string) {
return false
}
async function getSessionWorkspace(url: URL) {
if (url.pathname === "/session/status") return null
const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
if (!id) return null
const session = await Session.get(SessionID.make(id)).catch(() => undefined)
return session?.workspaceID
}
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
const routes = lazy(() => InstanceRoutes(upgrade))
@@ -42,13 +55,12 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
)
const url = new URL(c.req.url)
const workspaceParam = url.searchParams.get("workspace") || c.req.header("x-opencode-workspace")
// TODO: If session is being routed, force it to lookup the
// project/workspace
const sessionWorkspaceID = await getSessionWorkspace(url)
const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace")
// If no workspace is provided we use the "project" workspace
if (!workspaceParam) {
// If no workspace is provided we use the project
if (!workspaceID) {
return Instance.provide({
directory,
init: InstanceBootstrap,
@@ -58,8 +70,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
})
}
const workspaceID = WorkspaceID.make(workspaceParam)
const workspace = await Workspace.get(workspaceID)
const workspace = await Workspace.get(WorkspaceID.make(workspaceID))
if (!workspace) {
return new Response(`Workspace not found: ${workspaceID}`, {
status: 500,
@@ -73,12 +84,16 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
const target = await adaptor.target(workspace)
if (target.type === "local") {
return Instance.provide({
directory: target.directory,
init: InstanceBootstrap,
async fn() {
return routes().fetch(c.req.raw, c.env)
},
return WorkspaceContext.provide({
workspaceID: WorkspaceID.make(workspaceID),
fn: () =>
Instance.provide({
directory: target.directory,
init: InstanceBootstrap,
async fn() {
return routes().fetch(c.req.raw, c.env)
},
}),
})
}

View File

@@ -105,6 +105,8 @@ export const GlobalRoutes = lazy(() =>
z
.object({
directory: z.string(),
project: z.string().optional(),
workspace: z.string().optional(),
payload: BusEvent.payloads(),
})
.meta({

View File

@@ -47,6 +47,7 @@ import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { TaskTool } from "@/tool/task"
import { Config } from "@/config/config"
import { SessionRunState } from "./run-state"
// @ts-ignore
@@ -88,6 +89,7 @@ export namespace SessionPrompt {
const compaction = yield* SessionCompaction.Service
const plugin = yield* Plugin.Service
const commands = yield* Command.Service
const config = yield* Config.Service
const permission = yield* Permission.Service
const fsys = yield* AppFileSystem.Service
const mcp = yield* MCP.Service
@@ -140,6 +142,17 @@ export namespace SessionPrompt {
return parts
})
let prompt!: Interface["prompt"]
const taskTool = () =>
TaskTool.build({
agent: agents,
config,
cancel: SessionPrompt.cancel,
resolvePromptParts: SessionPrompt.resolvePromptParts,
prompt: SessionPrompt.prompt,
})
const title = Effect.fn("SessionPrompt.ensureTitle")(function* (input: {
session: Session.Info
history: MessageV2.WithParts[]
@@ -391,6 +404,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
providerID: input.model.providerID,
agent: input.agent,
})) {
const toolDef = item.id === TaskTool.id ? yield* Tool.init(taskTool()) : item
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
tools[item.id] = tool({
id: item.id as any,
@@ -405,7 +419,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
{ args },
)
const result = yield* Effect.promise(() => item.execute(args, ctx))
const result = yield* Effect.promise(() => toolDef.execute(args, ctx))
const output = {
...result,
attachments: result.attachments?.map((attachment) => ({
@@ -521,7 +535,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}) {
const { task, model, lastUser, sessionID, session, msgs } = input
const ctx = yield* InstanceState.context
const { task: taskTool } = yield* registry.named()
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
id: MessageID.ascending(),
@@ -578,8 +591,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}
let error: Error | undefined
const taskDef = yield* Tool.init(taskTool())
const result = yield* Effect.promise((signal) =>
taskTool
taskDef
.execute(taskArgs, {
agent: task.agent,
messageID: assistantMessage.id,
@@ -1267,26 +1281,24 @@ NOTE: At any point in time through this workflow you should feel free to ask the
return { info, parts }
}, Effect.scoped)
const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.prompt")(
function* (input: PromptInput) {
const session = yield* sessions.get(input.sessionID)
yield* revert.cleanup(session)
const message = yield* createUserMessage(input)
yield* sessions.touch(input.sessionID)
prompt = Effect.fn("SessionPrompt.prompt")(function* (input: PromptInput) {
const session = yield* sessions.get(input.sessionID)
yield* revert.cleanup(session)
const message = yield* createUserMessage(input)
yield* sessions.touch(input.sessionID)
const permissions: Permission.Ruleset = []
for (const [t, enabled] of Object.entries(input.tools ?? {})) {
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
}
if (permissions.length > 0) {
session.permission = permissions
yield* sessions.setPermission({ sessionID: session.id, permission: permissions })
}
const permissions: Permission.Ruleset = []
for (const [t, enabled] of Object.entries(input.tools ?? {})) {
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
}
if (permissions.length > 0) {
session.permission = permissions
yield* sessions.setPermission({ sessionID: session.id, permission: permissions })
}
if (input.noReply === true) return message
return yield* loop({ sessionID: input.sessionID })
},
)
if (input.noReply === true) return message
return yield* loop({ sessionID: input.sessionID })
})
const lastAssistant = (sessionID: SessionID) =>
Effect.promise(async () => {
@@ -1667,28 +1679,30 @@ NOTE: At any point in time through this workflow you should feel free to ask the
)
const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(SessionRunState.defaultLayer),
Layer.provide(SessionStatus.defaultLayer),
Layer.provide(SessionCompaction.defaultLayer),
Layer.provide(SessionProcessor.defaultLayer),
Layer.provide(Command.defaultLayer),
Layer.provide(Permission.defaultLayer),
Layer.provide(MCP.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(FileTime.defaultLayer),
Layer.provide(ToolRegistry.defaultLayer),
Layer.provide(Truncate.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(SessionRevert.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
),
layer
.pipe(
Layer.provide(SessionRunState.defaultLayer),
Layer.provide(SessionStatus.defaultLayer),
Layer.provide(SessionCompaction.defaultLayer),
Layer.provide(SessionProcessor.defaultLayer),
Layer.provide(Command.defaultLayer),
Layer.provide(Permission.defaultLayer),
Layer.provide(MCP.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(FileTime.defaultLayer),
Layer.provide(ToolRegistry.defaultLayer),
Layer.provide(Truncate.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(SessionRevert.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Bus.layer),
)
.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)),
)
const { runPromise } = makeRuntime(Service, defaultLayer)

View File

@@ -1,132 +1,65 @@
import z from "zod"
import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
import { Tool } from "./tool"
import * as McpExa from "./mcp-exa"
import DESCRIPTION from "./codesearch.txt"
import { abortAfterAny } from "../util/abort"
const API_CONFIG = {
BASE_URL: "https://mcp.exa.ai",
ENDPOINTS: {
CONTEXT: "/mcp",
},
} as const
export const CodeSearchTool = Tool.defineEffect(
"codesearch",
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
interface McpCodeRequest {
jsonrpc: string
id: number
method: string
params: {
name: string
arguments: {
query: string
tokensNum: number
}
}
}
return {
description: DESCRIPTION,
parameters: z.object({
query: z
.string()
.describe(
"Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'",
),
tokensNum: z
.number()
.min(1000)
.max(50000)
.default(5000)
.describe(
"Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.",
),
}),
execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
ctx.ask({
permission: "codesearch",
patterns: [params.query],
always: ["*"],
metadata: {
query: params.query,
tokensNum: params.tokensNum,
},
}),
)
interface McpCodeResponse {
jsonrpc: string
result: {
content: Array<{
type: string
text: string
}>
}
}
const result = yield* McpExa.call(
http,
"get_code_context_exa",
McpExa.CodeArgs,
{
query: params.query,
tokensNum: params.tokensNum || 5000,
},
"30 seconds",
)
export const CodeSearchTool = Tool.define("codesearch", {
description: DESCRIPTION,
parameters: z.object({
query: z
.string()
.describe(
"Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'",
),
tokensNum: z
.number()
.min(1000)
.max(50000)
.default(5000)
.describe(
"Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.",
),
}),
async execute(params, ctx) {
await ctx.ask({
permission: "codesearch",
patterns: [params.query],
always: ["*"],
metadata: {
query: params.query,
tokensNum: params.tokensNum,
},
})
const codeRequest: McpCodeRequest = {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "get_code_context_exa",
arguments: {
query: params.query,
tokensNum: params.tokensNum || 5000,
},
},
}
const { signal, clearTimeout } = abortAfterAny(30000, ctx.abort)
try {
const headers: Record<string, string> = {
accept: "application/json, text/event-stream",
"content-type": "application/json",
}
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONTEXT}`, {
method: "POST",
headers,
body: JSON.stringify(codeRequest),
signal,
})
clearTimeout()
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Code search error (${response.status}): ${errorText}`)
}
const responseText = await response.text()
// Parse SSE response
const lines = responseText.split("\n")
for (const line of lines) {
if (line.startsWith("data: ")) {
const data: McpCodeResponse = JSON.parse(line.substring(6))
if (data.result && data.result.content && data.result.content.length > 0) {
return {
output: data.result.content[0].text,
title: `Code search: ${params.query}`,
metadata: {},
}
return {
output:
result ??
"No code snippets or documentation found. Please try a different query, be more specific about the library or programming concept, or check the spelling of framework names.",
title: `Code search: ${params.query}`,
metadata: {},
}
}
}
return {
output:
"No code snippets or documentation found. Please try a different query, be more specific about the library or programming concept, or check the spelling of framework names.",
title: `Code search: ${params.query}`,
metadata: {},
}
} catch (error) {
clearTimeout()
if (error instanceof Error && error.name === "AbortError") {
throw new Error("Code search request timed out")
}
throw error
}).pipe(Effect.runPromise),
}
},
})
}),
)

View File

@@ -1,12 +1,13 @@
import z from "zod"
import { Effect } from "effect"
import { Tool } from "./tool"
import path from "path"
import { LSP } from "../lsp"
import DESCRIPTION from "./lsp.txt"
import { Instance } from "../project/instance"
import { pathToFileURL } from "url"
import { assertExternalDirectory } from "./external-directory"
import { Filesystem } from "../util/filesystem"
import { assertExternalDirectoryEffect } from "./external-directory"
import { AppFileSystem } from "../filesystem"
const operations = [
"goToDefinition",
@@ -20,78 +21,71 @@ const operations = [
"outgoingCalls",
] as const
export const LspTool = Tool.define("lsp", {
description: DESCRIPTION,
parameters: z.object({
operation: z.enum(operations).describe("The LSP operation to perform"),
filePath: z.string().describe("The absolute or relative path to the file"),
line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"),
character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"),
}),
execute: async (args, ctx) => {
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
await assertExternalDirectory(ctx, file)
await ctx.ask({
permission: "lsp",
patterns: ["*"],
always: ["*"],
metadata: {},
})
const uri = pathToFileURL(file).href
const position = {
file,
line: args.line - 1,
character: args.character - 1,
}
const relPath = path.relative(Instance.worktree, file)
const title = `${args.operation} ${relPath}:${args.line}:${args.character}`
const exists = await Filesystem.exists(file)
if (!exists) {
throw new Error(`File not found: ${file}`)
}
const available = await LSP.hasClients(file)
if (!available) {
throw new Error("No LSP server available for this file type.")
}
await LSP.touchFile(file, true)
const result: unknown[] = await (async () => {
switch (args.operation) {
case "goToDefinition":
return LSP.definition(position)
case "findReferences":
return LSP.references(position)
case "hover":
return LSP.hover(position)
case "documentSymbol":
return LSP.documentSymbol(uri)
case "workspaceSymbol":
return LSP.workspaceSymbol("")
case "goToImplementation":
return LSP.implementation(position)
case "prepareCallHierarchy":
return LSP.prepareCallHierarchy(position)
case "incomingCalls":
return LSP.incomingCalls(position)
case "outgoingCalls":
return LSP.outgoingCalls(position)
}
})()
const output = (() => {
if (result.length === 0) return `No results found for ${args.operation}`
return JSON.stringify(result, null, 2)
})()
export const LspTool = Tool.defineEffect(
"lsp",
Effect.gen(function* () {
const lsp = yield* LSP.Service
const fs = yield* AppFileSystem.Service
return {
title,
metadata: { result },
output,
description: DESCRIPTION,
parameters: z.object({
operation: z.enum(operations).describe("The LSP operation to perform"),
filePath: z.string().describe("The absolute or relative path to the file"),
line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"),
character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"),
}),
execute: (
args: { operation: (typeof operations)[number]; filePath: string; line: number; character: number },
ctx: Tool.Context,
) =>
Effect.gen(function* () {
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
yield* assertExternalDirectoryEffect(ctx, file)
yield* Effect.promise(() => ctx.ask({ permission: "lsp", patterns: ["*"], always: ["*"], metadata: {} }))
const uri = pathToFileURL(file).href
const position = { file, line: args.line - 1, character: args.character - 1 }
const relPath = path.relative(Instance.worktree, file)
const title = `${args.operation} ${relPath}:${args.line}:${args.character}`
const exists = yield* fs.existsSafe(file)
if (!exists) throw new Error(`File not found: ${file}`)
const available = yield* lsp.hasClients(file)
if (!available) throw new Error("No LSP server available for this file type.")
yield* lsp.touchFile(file, true)
const result: unknown[] = yield* (() => {
switch (args.operation) {
case "goToDefinition":
return lsp.definition(position)
case "findReferences":
return lsp.references(position)
case "hover":
return lsp.hover(position)
case "documentSymbol":
return lsp.documentSymbol(uri)
case "workspaceSymbol":
return lsp.workspaceSymbol("")
case "goToImplementation":
return lsp.implementation(position)
case "prepareCallHierarchy":
return lsp.prepareCallHierarchy(position)
case "incomingCalls":
return lsp.incomingCalls(position)
case "outgoingCalls":
return lsp.outgoingCalls(position)
}
})()
return {
title,
metadata: { result },
output: result.length === 0 ? `No results found for ${args.operation}` : JSON.stringify(result, null, 2),
}
}).pipe(Effect.runPromise),
}
},
})
}),
)

View File

@@ -0,0 +1,76 @@
import { Duration, Effect, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
const URL = "https://mcp.exa.ai/mcp"
const McpResult = Schema.Struct({
result: Schema.Struct({
content: Schema.Array(
Schema.Struct({
type: Schema.String,
text: Schema.String,
}),
),
}),
})
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult))
const parseSse = Effect.fn("McpExa.parseSse")(function* (body: string) {
for (const line of body.split("\n")) {
if (!line.startsWith("data: ")) continue
const data = yield* decode(line.substring(6))
if (data.result.content[0]?.text) return data.result.content[0].text
}
return undefined
})
export const SearchArgs = Schema.Struct({
query: Schema.String,
type: Schema.String,
numResults: Schema.Number,
livecrawl: Schema.String,
contextMaxCharacters: Schema.optional(Schema.Number),
})
export const CodeArgs = Schema.Struct({
query: Schema.String,
tokensNum: Schema.Number,
})
const McpRequest = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
Schema.Struct({
jsonrpc: Schema.Literal("2.0"),
id: Schema.Literal(1),
method: Schema.Literal("tools/call"),
params: Schema.Struct({
name: Schema.String,
arguments: args,
}),
})
export const call = <F extends Schema.Struct.Fields>(
http: HttpClient.HttpClient,
tool: string,
args: Schema.Struct<F>,
value: Schema.Struct.Type<F>,
timeout: Duration.Input,
) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.post(URL).pipe(
HttpClientRequest.accept("application/json, text/event-stream"),
HttpClientRequest.schemaBodyJson(McpRequest(args))({
jsonrpc: "2.0" as const,
id: 1 as const,
method: "tools/call" as const,
params: { name: tool, arguments: value },
}),
)
const response = yield* HttpClient.filterStatusOk(http)
.execute(request)
.pipe(
Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error(`${tool} request timed out`)) }),
)
const body = yield* response.text
return yield* parseSse(body)
})

View File

@@ -1,5 +1,6 @@
import z from "zod"
import path from "path"
import { Effect } from "effect"
import { Tool } from "./tool"
import { Question } from "../question"
import { Session } from "../session"
@@ -9,123 +10,71 @@ import { Instance } from "../project/instance"
import { type SessionID, MessageID, PartID } from "../session/schema"
import EXIT_DESCRIPTION from "./plan-exit.txt"
async function getLastModel(sessionID: SessionID) {
for await (const item of MessageV2.stream(sessionID)) {
function getLastModel(sessionID: SessionID) {
for (const item of MessageV2.stream(sessionID)) {
if (item.info.role === "user" && item.info.model) return item.info.model
}
return Provider.defaultModel()
return undefined
}
export const PlanExitTool = Tool.define("plan_exit", {
description: EXIT_DESCRIPTION,
parameters: z.object({}),
async execute(_params, ctx) {
const session = await Session.get(ctx.sessionID)
const plan = path.relative(Instance.worktree, Session.plan(session))
const answers = await Question.ask({
sessionID: ctx.sessionID,
questions: [
{
question: `Plan at ${plan} is complete. Would you like to switch to the build agent and start implementing?`,
header: "Build Agent",
custom: false,
options: [
{ label: "Yes", description: "Switch to build agent and start implementing the plan" },
{ label: "No", description: "Stay with plan agent to continue refining the plan" },
],
},
],
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
const answer = answers[0]?.[0]
if (answer === "No") throw new Question.RejectedError()
const model = await getLastModel(ctx.sessionID)
const userMsg: MessageV2.User = {
id: MessageID.ascending(),
sessionID: ctx.sessionID,
role: "user",
time: {
created: Date.now(),
},
agent: "build",
model,
}
await Session.updateMessage(userMsg)
await Session.updatePart({
id: PartID.ascending(),
messageID: userMsg.id,
sessionID: ctx.sessionID,
type: "text",
text: `The plan at ${plan} has been approved, you can now edit files. Execute the plan`,
synthetic: true,
} satisfies MessageV2.TextPart)
export const PlanExitTool = Tool.defineEffect(
"plan_exit",
Effect.gen(function* () {
const session = yield* Session.Service
const question = yield* Question.Service
const provider = yield* Provider.Service
return {
title: "Switching to build agent",
output: "User approved switching to build agent. Wait for further instructions.",
metadata: {},
description: EXIT_DESCRIPTION,
parameters: z.object({}),
execute: (_params: {}, ctx: Tool.Context) =>
Effect.gen(function* () {
const info = yield* session.get(ctx.sessionID)
const plan = path.relative(Instance.worktree, Session.plan(info))
const answers = yield* question.ask({
sessionID: ctx.sessionID,
questions: [
{
question: `Plan at ${plan} is complete. Would you like to switch to the build agent and start implementing?`,
header: "Build Agent",
custom: false,
options: [
{ label: "Yes", description: "Switch to build agent and start implementing the plan" },
{ label: "No", description: "Stay with plan agent to continue refining the plan" },
],
},
],
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
if (answers[0]?.[0] === "No") yield* new Question.RejectedError()
const model = getLastModel(ctx.sessionID) ?? (yield* provider.defaultModel())
const msg: MessageV2.User = {
id: MessageID.ascending(),
sessionID: ctx.sessionID,
role: "user",
time: { created: Date.now() },
agent: "build",
model,
}
yield* session.updateMessage(msg)
yield* session.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID: ctx.sessionID,
type: "text",
text: `The plan at ${plan} has been approved, you can now edit files. Execute the plan`,
synthetic: true,
} satisfies MessageV2.TextPart)
return {
title: "Switching to build agent",
output: "User approved switching to build agent. Wait for further instructions.",
metadata: {},
}
}).pipe(Effect.runPromise),
}
},
})
/*
export const PlanEnterTool = Tool.define("plan_enter", {
description: ENTER_DESCRIPTION,
parameters: z.object({}),
async execute(_params, ctx) {
const session = await Session.get(ctx.sessionID)
const plan = path.relative(Instance.worktree, Session.plan(session))
const answers = await Question.ask({
sessionID: ctx.sessionID,
questions: [
{
question: `Would you like to switch to the plan agent and create a plan saved to ${plan}?`,
header: "Plan Mode",
custom: false,
options: [
{ label: "Yes", description: "Switch to plan agent for research and planning" },
{ label: "No", description: "Stay with build agent to continue making changes" },
],
},
],
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
const answer = answers[0]?.[0]
if (answer === "No") throw new Question.RejectedError()
const model = await getLastModel(ctx.sessionID)
const userMsg: MessageV2.User = {
id: MessageID.ascending(),
sessionID: ctx.sessionID,
role: "user",
time: {
created: Date.now(),
},
agent: "plan",
model,
}
await Session.updateMessage(userMsg)
await Session.updatePart({
id: PartID.ascending(),
messageID: userMsg.id,
sessionID: ctx.sessionID,
type: "text",
text: "User has requested to enter plan mode. Switch to plan mode and begin planning.",
synthetic: true,
} satisfies MessageV2.TextPart)
return {
title: "Switching to plan agent",
output: `User confirmed to switch to plan mode. A new message has been created to switch you to plan mode. The plan file will be at ${plan}. Begin planning.`,
metadata: {},
}
},
})
*/
}),
)

View File

@@ -20,27 +20,26 @@ export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Quest
return {
description: DESCRIPTION,
parameters,
async execute(params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) {
const answers = await question
.ask({
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) =>
Effect.gen(function* () {
const answers = yield* question.ask({
sessionID: ctx.sessionID,
questions: params.questions,
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
.pipe(Effect.runPromise)
const formatted = params.questions
.map((q, i) => `"${q.question}"="${answers[i]?.length ? answers[i].join(", ") : "Unanswered"}"`)
.join(", ")
const formatted = params.questions
.map((q, i) => `"${q.question}"="${answers[i]?.length ? answers[i].join(", ") : "Unanswered"}"`)
.join(", ")
return {
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`,
metadata: {
answers,
},
}
},
return {
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`,
metadata: {
answers,
},
}
}).pipe(Effect.runPromise),
}
}),
)

View File

@@ -1,4 +1,5 @@
import { PlanExitTool } from "./plan"
import { Session } from "../session"
import { QuestionTool } from "./question"
import { BashTool } from "./bash"
import { EditTool } from "./edit"
@@ -16,6 +17,7 @@ import { Config } from "../config/config"
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
import z from "zod"
import { Plugin } from "../plugin"
import { Provider } from "../provider/provider"
import { ProviderID, type ModelID } from "../provider/schema"
import { WebSearchTool } from "./websearch"
import { CodeSearchTool } from "./codesearch"
@@ -28,6 +30,7 @@ import { Glob } from "../util/glob"
import path from "path"
import { pathToFileURL } from "url"
import { Effect, Layer, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Env } from "../env"
@@ -40,6 +43,7 @@ import { AppFileSystem } from "../filesystem"
import { Agent } from "../agent/agent"
import { Skill } from "../skill"
import { Permission } from "@/permission"
import type { TaskMetadata } from "./task"
export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
@@ -76,10 +80,13 @@ export namespace ToolRegistry {
| Todo.Service
| Agent.Service
| Skill.Service
| Session.Service
| Provider.Service
| LSP.Service
| FileTime.Service
| Instruction.Service
| AppFileSystem.Service
| HttpClient.HttpClient
> = Layer.effect(
Service,
Effect.gen(function* () {
@@ -88,10 +95,15 @@ export namespace ToolRegistry {
const agents = yield* Agent.Service
const skill = yield* Skill.Service
const task = yield* TaskTool
const task: Tool.Info<typeof TaskTool.parameters, TaskMetadata> = yield* TaskTool
const read = yield* ReadTool
const question = yield* QuestionTool
const todo = yield* TodoWriteTool
const lsptool = yield* LspTool
const plan = yield* PlanExitTool
const webfetch = yield* WebFetchTool
const websearch = yield* WebSearchTool
const codesearch = yield* CodeSearchTool
const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -157,15 +169,15 @@ export namespace ToolRegistry {
edit: Tool.init(EditTool),
write: Tool.init(WriteTool),
task: Tool.init(task),
fetch: Tool.init(WebFetchTool),
fetch: Tool.init(webfetch),
todo: Tool.init(todo),
search: Tool.init(WebSearchTool),
code: Tool.init(CodeSearchTool),
search: Tool.init(websearch),
code: Tool.init(codesearch),
skill: Tool.init(SkillTool),
patch: Tool.init(ApplyPatchTool),
question: Tool.init(question),
lsp: Tool.init(LspTool),
plan: Tool.init(PlanExitTool),
lsp: Tool.init(lsptool),
plan: Tool.init(plan),
})
return {
@@ -297,10 +309,13 @@ export namespace ToolRegistry {
Layer.provide(Todo.defaultLayer),
Layer.provide(Skill.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(FileTime.defaultLayer),
Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer),
),
)

View File

@@ -5,10 +5,9 @@ import { Session } from "../session"
import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2"
import { Agent } from "../agent/agent"
import { SessionPrompt } from "../session/prompt"
import { Config } from "../config/config"
import type { SessionPrompt } from "../session/prompt"
import { Effect } from "effect"
import { Log } from "@/util/log"
const id = "task"
@@ -25,153 +24,180 @@ const parameters = z.object({
command: z.string().describe("The command that triggered this task").optional(),
})
export const TaskTool = Tool.defineEffect(
id,
Effect.gen(function* () {
const agent = yield* Agent.Service
const config = yield* Config.Service
type Metadata = {
sessionId: SessionID
model: {
modelID: MessageV2.Assistant["modelID"]
providerID: MessageV2.Assistant["providerID"]
}
}
const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
const cfg = yield* config.get()
export type TaskMetadata = Metadata
if (!ctx.extra?.bypassAgentCheck) {
yield* Effect.promise(() =>
ctx.ask({
permission: id,
patterns: [params.subagent_type],
always: ["*"],
metadata: {
description: params.description,
subagent_type: params.subagent_type,
},
}),
)
}
type Runtime = {
agent: Agent.Interface
config: Config.Interface
cancel: (sessionID: SessionID) => Promise<void>
resolvePromptParts: (template: string) => Promise<SessionPrompt.PromptInput["parts"]>
prompt: (input: SessionPrompt.PromptInput) => Promise<MessageV2.WithParts>
}
const next = yield* agent.get(params.subagent_type)
if (!next) {
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
}
const unbound: Tool.DefWithoutID<typeof parameters, Metadata> = {
description: DESCRIPTION,
parameters,
async execute() {
throw new Error("Task tool execution is only available from the prompt runtime")
},
}
const canTask = next.permission.some((rule) => rule.permission === id)
const canTodo = next.permission.some((rule) => rule.permission === "todowrite")
const build = (runtime: Runtime) => {
const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
const cfg = yield* runtime.config.get()
const taskID = params.task_id
const session = taskID
? yield* Effect.promise(() => {
const id = SessionID.make(taskID)
return Session.get(id).catch(() => undefined)
})
: undefined
const nextSession =
session ??
(yield* Effect.promise(() =>
Session.create({
parentID: ctx.sessionID,
title: params.description + ` (@${next.name} subagent)`,
permission: [
...(canTodo
? []
: [
{
permission: "todowrite" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(canTask
? []
: [
{
permission: id,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(cfg.experimental?.primary_tools?.map((item) => ({
pattern: "*",
action: "allow" as const,
permission: item,
})) ?? []),
],
}),
))
const msg = yield* Effect.sync(() => MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }))
if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message"))
const model = next.model ?? {
modelID: msg.info.modelID,
providerID: msg.info.providerID,
}
ctx.metadata({
title: params.description,
metadata: {
sessionId: nextSession.id,
model,
},
})
const messageID = MessageID.ascending()
function cancel() {
SessionPrompt.cancel(nextSession.id)
}
return yield* Effect.acquireUseRelease(
Effect.sync(() => {
ctx.abort.addEventListener("abort", cancel)
if (!ctx.extra?.bypassAgentCheck) {
yield* Effect.promise(() =>
ctx.ask({
permission: id,
patterns: [params.subagent_type],
always: ["*"],
metadata: {
description: params.description,
subagent_type: params.subagent_type,
},
}),
() =>
Effect.gen(function* () {
const parts = yield* Effect.promise(() => SessionPrompt.resolvePromptParts(params.prompt))
const result = yield* Effect.promise(() =>
SessionPrompt.prompt({
messageID,
sessionID: nextSession.id,
model: {
modelID: model.modelID,
providerID: model.providerID,
},
agent: next.name,
tools: {
...(canTodo ? {} : { todowrite: false }),
...(canTask ? {} : { task: false }),
...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])),
},
parts,
}),
)
return {
title: params.description,
metadata: {
sessionId: nextSession.id,
model,
},
output: [
`task_id: ${nextSession.id} (for resuming to continue this task if needed)`,
"",
"<task_result>",
result.parts.findLast((item) => item.type === "text")?.text ?? "",
"</task_result>",
].join("\n"),
}
}),
() =>
Effect.sync(() => {
ctx.abort.removeEventListener("abort", cancel)
}),
)
}
const next = yield* runtime.agent.get(params.subagent_type)
if (!next) {
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
}
const canTask = next.permission.some((rule) => rule.permission === id)
const canTodo = next.permission.some((rule) => rule.permission === "todowrite")
const taskID = params.task_id
const session = taskID
? yield* Effect.promise(() => {
const id = SessionID.make(taskID)
return Session.get(id).catch(() => undefined)
})
: undefined
const nextSession =
session ??
(yield* Effect.promise(() =>
Session.create({
parentID: ctx.sessionID,
title: params.description + ` (@${next.name} subagent)`,
permission: [
...(canTodo
? []
: [
{
permission: "todowrite" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(canTask
? []
: [
{
permission: id,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(cfg.experimental?.primary_tools?.map((item) => ({
pattern: "*",
action: "allow" as const,
permission: item,
})) ?? []),
],
}),
))
const msg = yield* Effect.sync(() => MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }))
if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message"))
const model = next.model ?? {
modelID: msg.info.modelID,
providerID: msg.info.providerID,
}
ctx.metadata({
title: params.description,
metadata: {
sessionId: nextSession.id,
model,
},
})
return {
description: DESCRIPTION,
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
return Effect.runPromise(run(params, ctx))
},
const messageID = MessageID.ascending()
function cancel() {
return runtime.cancel(nextSession.id)
}
}),
)
return yield* Effect.acquireUseRelease(
Effect.sync(() => {
ctx.abort.addEventListener("abort", cancel)
}),
() =>
Effect.gen(function* () {
const parts = yield* Effect.promise(() => runtime.resolvePromptParts(params.prompt))
const result = yield* Effect.promise(() =>
runtime.prompt({
messageID,
sessionID: nextSession.id,
model: {
modelID: model.modelID,
providerID: model.providerID,
},
agent: next.name,
tools: {
...(canTodo ? {} : { todowrite: false }),
...(canTask ? {} : { task: false }),
...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])),
},
parts,
}),
)
return {
title: params.description,
metadata: {
sessionId: nextSession.id,
model,
},
output: [
`task_id: ${nextSession.id} (for resuming to continue this task if needed)`,
"",
"<task_result>",
result.parts.findLast((item) => item.type === "text")?.text ?? "",
"</task_result>",
].join("\n"),
}
}),
() =>
Effect.sync(() => {
ctx.abort.removeEventListener("abort", cancel)
}),
)
})
return Tool.define(id, {
description: DESCRIPTION,
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
return Effect.runPromise(run(params, ctx))
},
})
}
export const TaskTool = Object.assign(Effect.succeed(Tool.define(id, unbound)), {
id,
description: DESCRIPTION,
parameters,
build,
})

View File

@@ -1,170 +1,163 @@
import z from "zod"
import { Effect } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Tool } from "./tool"
import TurndownService from "turndown"
import DESCRIPTION from "./webfetch.txt"
import { abortAfterAny } from "../util/abort"
import { iife } from "@/util/iife"
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
const MAX_TIMEOUT = 120 * 1000 // 2 minutes
export const WebFetchTool = Tool.define("webfetch", {
description: DESCRIPTION,
parameters: z.object({
url: z.string().describe("The URL to fetch content from"),
format: z
.enum(["text", "markdown", "html"])
.default("markdown")
.describe("The format to return the content in (text, markdown, or html). Defaults to markdown."),
timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(),
}),
async execute(params, ctx) {
// Validate URL
if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) {
throw new Error("URL must start with http:// or https://")
}
await ctx.ask({
permission: "webfetch",
patterns: [params.url],
always: ["*"],
metadata: {
url: params.url,
format: params.format,
timeout: params.timeout,
},
})
const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
const { signal, clearTimeout } = abortAfterAny(timeout, ctx.abort)
// Build Accept header based on requested format with q parameters for fallbacks
let acceptHeader = "*/*"
switch (params.format) {
case "markdown":
acceptHeader = "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1"
break
case "text":
acceptHeader = "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1"
break
case "html":
acceptHeader = "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1"
break
default:
acceptHeader =
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"
}
const headers = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
Accept: acceptHeader,
"Accept-Language": "en-US,en;q=0.9",
}
const response = await iife(async () => {
try {
const initial = await fetch(params.url, { signal, headers })
// Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch)
return initial.status === 403 && initial.headers.get("cf-mitigated") === "challenge"
? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } })
: initial
} finally {
clearTimeout()
}
})
if (!response.ok) {
throw new Error(`Request failed with status code: ${response.status}`)
}
// Check content length
const contentLength = response.headers.get("content-length")
if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) {
throw new Error("Response too large (exceeds 5MB limit)")
}
const arrayBuffer = await response.arrayBuffer()
if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) {
throw new Error("Response too large (exceeds 5MB limit)")
}
const contentType = response.headers.get("content-type") || ""
const mime = contentType.split(";")[0]?.trim().toLowerCase() || ""
const title = `${params.url} (${contentType})`
// Check if response is an image
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
if (isImage) {
const base64Content = Buffer.from(arrayBuffer).toString("base64")
return {
title,
output: "Image fetched successfully",
metadata: {},
attachments: [
{
type: "file",
mime,
url: `data:${mime};base64,${base64Content}`,
},
],
}
}
const content = new TextDecoder().decode(arrayBuffer)
// Handle content based on requested format and actual content type
switch (params.format) {
case "markdown":
if (contentType.includes("text/html")) {
const markdown = convertHTMLToMarkdown(content)
return {
output: markdown,
title,
metadata: {},
}
}
return {
output: content,
title,
metadata: {},
}
case "text":
if (contentType.includes("text/html")) {
const text = await extractTextFromHTML(content)
return {
output: text,
title,
metadata: {},
}
}
return {
output: content,
title,
metadata: {},
}
case "html":
return {
output: content,
title,
metadata: {},
}
default:
return {
output: content,
title,
metadata: {},
}
}
},
const parameters = z.object({
url: z.string().describe("The URL to fetch content from"),
format: z
.enum(["text", "markdown", "html"])
.default("markdown")
.describe("The format to return the content in (text, markdown, or html). Defaults to markdown."),
timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(),
})
export const WebFetchTool = Tool.defineEffect(
"webfetch",
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const httpOk = HttpClient.filterStatusOk(http)
return {
description: DESCRIPTION,
parameters,
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) {
throw new Error("URL must start with http:// or https://")
}
yield* Effect.promise(() =>
ctx.ask({
permission: "webfetch",
patterns: [params.url],
always: ["*"],
metadata: {
url: params.url,
format: params.format,
timeout: params.timeout,
},
}),
)
const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
// Build Accept header based on requested format with q parameters for fallbacks
let acceptHeader = "*/*"
switch (params.format) {
case "markdown":
acceptHeader = "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1"
break
case "text":
acceptHeader = "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1"
break
case "html":
acceptHeader =
"text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1"
break
default:
acceptHeader =
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"
}
const headers = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
Accept: acceptHeader,
"Accept-Language": "en-US,en;q=0.9",
}
const request = HttpClientRequest.get(params.url).pipe(HttpClientRequest.setHeaders(headers))
// Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch)
const response = yield* httpOk.execute(request).pipe(
Effect.catchIf(
(err) =>
err.reason._tag === "StatusCodeError" &&
err.reason.response.status === 403 &&
err.reason.response.headers["cf-mitigated"] === "challenge",
() =>
httpOk.execute(
HttpClientRequest.get(params.url).pipe(
HttpClientRequest.setHeaders({ ...headers, "User-Agent": "opencode" }),
),
),
),
Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error("Request timed out")) }),
)
// Check content length
const contentLength = response.headers["content-length"]
if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) {
throw new Error("Response too large (exceeds 5MB limit)")
}
const arrayBuffer = yield* response.arrayBuffer
if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) {
throw new Error("Response too large (exceeds 5MB limit)")
}
const contentType = response.headers["content-type"] || ""
const mime = contentType.split(";")[0]?.trim().toLowerCase() || ""
const title = `${params.url} (${contentType})`
// Check if response is an image
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
if (isImage) {
const base64Content = Buffer.from(arrayBuffer).toString("base64")
return {
title,
output: "Image fetched successfully",
metadata: {},
attachments: [
{
type: "file" as const,
mime,
url: `data:${mime};base64,${base64Content}`,
},
],
}
}
const content = new TextDecoder().decode(arrayBuffer)
// Handle content based on requested format and actual content type
switch (params.format) {
case "markdown":
if (contentType.includes("text/html")) {
const markdown = convertHTMLToMarkdown(content)
return {
output: markdown,
title,
metadata: {},
}
}
return { output: content, title, metadata: {} }
case "text":
if (contentType.includes("text/html")) {
const text = yield* Effect.promise(() => extractTextFromHTML(content))
return { output: text, title, metadata: {} }
}
return { output: content, title, metadata: {} }
case "html":
return { output: content, title, metadata: {} }
default:
return { output: content, title, metadata: {} }
}
}).pipe(Effect.runPromise),
}
}),
)
async function extractTextFromHTML(html: string) {
let text = ""
let skipContent = false

View File

@@ -1,15 +1,9 @@
import z from "zod"
import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
import { Tool } from "./tool"
import * as McpExa from "./mcp-exa"
import DESCRIPTION from "./websearch.txt"
import { abortAfterAny } from "../util/abort"
const API_CONFIG = {
BASE_URL: "https://mcp.exa.ai",
ENDPOINTS: {
SEARCH: "/mcp",
},
DEFAULT_NUM_RESULTS: 8,
} as const
const Parameters = z.object({
query: z.string().describe("Websearch query"),
@@ -30,121 +24,53 @@ const Parameters = z.object({
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
})
interface McpSearchRequest {
jsonrpc: string
id: number
method: string
params: {
name: string
arguments: {
query: string
numResults?: number
livecrawl?: "fallback" | "preferred"
type?: "auto" | "fast" | "deep"
contextMaxCharacters?: number
}
}
}
export const WebSearchTool = Tool.defineEffect(
"websearch",
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
interface McpSearchResponse {
jsonrpc: string
result: {
content: Array<{
type: string
text: string
}>
}
}
return {
get description() {
return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
},
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
ctx.ask({
permission: "websearch",
patterns: [params.query],
always: ["*"],
metadata: {
query: params.query,
numResults: params.numResults,
livecrawl: params.livecrawl,
type: params.type,
contextMaxCharacters: params.contextMaxCharacters,
},
}),
)
export const WebSearchTool = Tool.define("websearch", async () => {
return {
get description() {
return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
},
parameters: Parameters,
async execute(params, ctx) {
await ctx.ask({
permission: "websearch",
patterns: [params.query],
always: ["*"],
metadata: {
query: params.query,
numResults: params.numResults,
livecrawl: params.livecrawl,
type: params.type,
contextMaxCharacters: params.contextMaxCharacters,
},
})
const result = yield* McpExa.call(
http,
"web_search_exa",
McpExa.SearchArgs,
{
query: params.query,
type: params.type || "auto",
numResults: params.numResults || 8,
livecrawl: params.livecrawl || "fallback",
contextMaxCharacters: params.contextMaxCharacters,
},
"25 seconds",
)
const searchRequest: McpSearchRequest = {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "web_search_exa",
arguments: {
query: params.query,
type: params.type || "auto",
numResults: params.numResults || API_CONFIG.DEFAULT_NUM_RESULTS,
livecrawl: params.livecrawl || "fallback",
contextMaxCharacters: params.contextMaxCharacters,
},
},
}
const { signal, clearTimeout } = abortAfterAny(25000, ctx.abort)
try {
const headers: Record<string, string> = {
accept: "application/json, text/event-stream",
"content-type": "application/json",
}
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.SEARCH}`, {
method: "POST",
headers,
body: JSON.stringify(searchRequest),
signal,
})
clearTimeout()
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Search error (${response.status}): ${errorText}`)
}
const responseText = await response.text()
// Parse SSE response
const lines = responseText.split("\n")
for (const line of lines) {
if (line.startsWith("data: ")) {
const data: McpSearchResponse = JSON.parse(line.substring(6))
if (data.result && data.result.content && data.result.content.length > 0) {
return {
output: data.result.content[0].text,
title: `Web search: ${params.query}`,
metadata: {},
}
}
return {
output: result ?? "No search results found. Please try a different query.",
title: `Web search: ${params.query}`,
metadata: {},
}
}
return {
output: "No search results found. Please try a different query.",
title: `Web search: ${params.query}`,
metadata: {},
}
} catch (error) {
clearTimeout()
if (error instanceof Error && error.name === "AbortError") {
throw new Error("Search request timed out")
}
throw error
}
},
}
})
}).pipe(Effect.runPromise),
}
}),
)

View File

@@ -246,6 +246,7 @@ export namespace Worktree {
const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
const ctx = yield* InstanceState.context
const workspaceID = yield* InstanceState.workspaceID
const projectID = ctx.project.id
const extra = startCommand?.trim()
@@ -255,6 +256,8 @@ export namespace Worktree {
log.error("worktree checkout failed", { directory: info.directory, message })
GlobalBus.emit("event", {
directory: info.directory,
project: ctx.project.id,
workspace: workspaceID,
payload: { type: Event.Failed.type, properties: { message } },
})
return
@@ -272,6 +275,8 @@ export namespace Worktree {
log.error("worktree bootstrap failed", { directory: info.directory, message })
GlobalBus.emit("event", {
directory: info.directory,
project: ctx.project.id,
workspace: workspaceID,
payload: { type: Event.Failed.type, properties: { message } },
})
return false
@@ -281,6 +286,8 @@ export namespace Worktree {
GlobalBus.emit("event", {
directory: info.directory,
project: ctx.project.id,
workspace: workspaceID,
payload: {
type: Event.Ready.type,
properties: { name: info.name, branch: info.branch },

View File

@@ -0,0 +1,293 @@
/** @jsxImportSource @opentui/solid */
import { afterEach, describe, expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { onMount } from "solid-js"
import { ArgsProvider } from "../../../src/cli/cmd/tui/context/args"
import { ExitProvider } from "../../../src/cli/cmd/tui/context/exit"
import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project"
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
import { SyncProvider, useSync } from "../../../src/cli/cmd/tui/context/sync"
const sighup = new Set(process.listeners("SIGHUP"))
afterEach(() => {
for (const fn of process.listeners("SIGHUP")) {
if (!sighup.has(fn)) process.off("SIGHUP", fn)
}
})
function json(data: unknown) {
return new Response(JSON.stringify(data), {
headers: {
"content-type": "application/json",
},
})
}
async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(10)
}
}
function data(workspace?: string | null) {
const tag = workspace ?? "root"
return {
session: {
id: "ses_1",
title: `session-${tag}`,
workspaceID: workspace ?? undefined,
time: {
updated: 1,
},
},
message: {
info: {
id: "msg_1",
sessionID: "ses_1",
role: "assistant",
time: {
created: 1,
completed: 1,
},
},
parts: [
{
id: "part_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "text",
text: `part-${tag}`,
},
],
},
todo: [
{
id: `todo-${tag}`,
content: `todo-${tag}`,
status: "pending",
priority: "medium",
},
],
diff: [
{
file: `${tag}.ts`,
patch: "",
additions: 0,
deletions: 0,
},
],
}
}
type Hit = {
path: string
workspace?: string
}
function createFetch(log: Hit[]) {
return Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const req = new Request(input, init)
const url = new URL(req.url)
const workspace = url.searchParams.get("workspace") ?? req.headers.get("x-opencode-workspace") ?? undefined
log.push({
path: url.pathname,
workspace,
})
if (url.pathname === "/config/providers") {
return json({ providers: [], default: {} })
}
if (url.pathname === "/provider") {
return json({ all: [], default: {}, connected: [] })
}
if (url.pathname === "/experimental/console") {
return json({})
}
if (url.pathname === "/agent") {
return json([])
}
if (url.pathname === "/config") {
return json({})
}
if (url.pathname === "/project/current") {
return json({ id: `proj-${workspace ?? "root"}` })
}
if (url.pathname === "/path") {
return json({
state: `/tmp/${workspace ?? "root"}/state`,
config: `/tmp/${workspace ?? "root"}/config`,
worktree: "/tmp/worktree",
directory: `/tmp/${workspace ?? "root"}`,
})
}
if (url.pathname === "/session") {
return json([])
}
if (url.pathname === "/command") {
return json([])
}
if (url.pathname === "/lsp") {
return json([])
}
if (url.pathname === "/mcp") {
return json({})
}
if (url.pathname === "/experimental/resource") {
return json({})
}
if (url.pathname === "/formatter") {
return json([])
}
if (url.pathname === "/session/status") {
return json({})
}
if (url.pathname === "/provider/auth") {
return json({})
}
if (url.pathname === "/vcs") {
return json({ branch: "main" })
}
if (url.pathname === "/experimental/workspace") {
return json([{ id: "ws_a" }, { id: "ws_b" }])
}
if (url.pathname === "/session/ses_1") {
return json(data(workspace).session)
}
if (url.pathname === "/session/ses_1/message") {
return json([data(workspace).message])
}
if (url.pathname === "/session/ses_1/todo") {
return json(data(workspace).todo)
}
if (url.pathname === "/session/ses_1/diff") {
return json(data(workspace).diff)
}
throw new Error(`unexpected request: ${req.method} ${url.pathname}`)
},
{ preconnect: fetch.preconnect.bind(fetch) },
) satisfies typeof fetch
}
async function mount(log: Hit[]) {
let project!: ReturnType<typeof useProject>
let sync!: ReturnType<typeof useSync>
let done!: () => void
const ready = new Promise<void>((resolve) => {
done = resolve
})
const app = await testRender(() => (
<SDKProvider
url="http://test"
directory="/tmp/root"
fetch={createFetch(log)}
events={{ subscribe: async () => () => {} }}
>
<ArgsProvider continue={false}>
<ExitProvider>
<ProjectProvider>
<SyncProvider>
<Probe
onReady={(ctx) => {
project = ctx.project
sync = ctx.sync
done()
}}
/>
</SyncProvider>
</ProjectProvider>
</ExitProvider>
</ArgsProvider>
</SDKProvider>
))
await ready
return { app, project, sync }
}
async function waitBoot(log: Hit[], workspace?: string) {
await wait(() => log.some((item) => item.path === "/experimental/workspace"))
if (!workspace) return
await wait(() => log.some((item) => item.path === "/project/current" && item.workspace === workspace))
}
function Probe(props: {
onReady: (ctx: { project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }) => void
}) {
const project = useProject()
const sync = useSync()
onMount(() => {
props.onReady({ project, sync })
})
return <box />
}
describe("SyncProvider", () => {
test("re-runs bootstrap requests when the active workspace changes", async () => {
const log: Hit[] = []
const { app, project } = await mount(log)
try {
await waitBoot(log)
log.length = 0
project.workspace.set("ws_a")
await waitBoot(log, "ws_a")
expect(log.some((item) => item.path === "/path" && item.workspace === "ws_a")).toBe(true)
expect(log.some((item) => item.path === "/config" && item.workspace === "ws_a")).toBe(true)
expect(log.some((item) => item.path === "/session" && item.workspace === "ws_a")).toBe(true)
expect(log.some((item) => item.path === "/command" && item.workspace === "ws_a")).toBe(true)
} finally {
app.renderer.destroy()
}
})
test("clears full-sync cache when the active workspace changes", async () => {
const log: Hit[] = []
const { app, project, sync } = await mount(log)
try {
await waitBoot(log)
log.length = 0
project.workspace.set("ws_a")
await waitBoot(log, "ws_a")
expect(project.workspace.current()).toBe("ws_a")
log.length = 0
await sync.session.sync("ses_1")
expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_a")).toHaveLength(1)
expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_a")
expect(sync.data.message.ses_1[0]?.id).toBe("msg_1")
expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_a" })
expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_a.ts")
log.length = 0
project.workspace.set("ws_b")
await waitBoot(log, "ws_b")
expect(project.workspace.current()).toBe("ws_b")
log.length = 0
await sync.session.sync("ses_1")
await wait(() => log.some((item) => item.path === "/session/ses_1" && item.workspace === "ws_b"))
expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_b")).toHaveLength(1)
expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_b")
expect(sync.data.message.ses_1[0]?.id).toBe("msg_1")
expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_b" })
expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_b.ts")
} finally {
app.renderer.destroy()
}
})
})

View File

@@ -0,0 +1,175 @@
/** @jsxImportSource @opentui/solid */
import { describe, expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
import { onMount } from "solid-js"
import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project"
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
import { useEvent } from "../../../src/cli/cmd/tui/context/event"
async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(10)
}
}
function event(payload: Event, input: { directory: string; workspace?: string }): GlobalEvent {
return {
directory: input.directory,
workspace: input.workspace,
payload,
}
}
function vcs(branch: string): Event {
return {
type: "vcs.branch.updated",
properties: {
branch,
},
}
}
function update(version: string): Event {
return {
type: "installation.update-available",
properties: {
version,
},
}
}
function createSource() {
let fn: ((event: GlobalEvent) => void) | undefined
return {
source: {
subscribe: async (handler: (event: GlobalEvent) => void) => {
fn = handler
return () => {
if (fn === handler) fn = undefined
}
},
},
emit(evt: GlobalEvent) {
if (!fn) throw new Error("event source not ready")
fn(evt)
},
}
}
async function mount() {
const source = createSource()
const seen: Event[] = []
let project!: ReturnType<typeof useProject>
let done!: () => void
const ready = new Promise<void>((resolve) => {
done = resolve
})
const app = await testRender(() => (
<SDKProvider url="http://test" directory="/tmp/root" events={source.source}>
<ProjectProvider>
<Probe
onReady={(ctx) => {
project = ctx.project
done()
}}
seen={seen}
/>
</ProjectProvider>
</SDKProvider>
))
await ready
return { app, emit: source.emit, project, seen }
}
function Probe(props: { seen: Event[]; onReady: (ctx: { project: ReturnType<typeof useProject> }) => void }) {
const project = useProject()
const event = useEvent()
onMount(() => {
event.subscribe((evt) => {
props.seen.push(evt)
})
props.onReady({ project })
})
return <box />
}
describe("useEvent", () => {
test("delivers matching directory events without an active workspace", async () => {
const { app, emit, seen } = await mount()
try {
emit(event(vcs("main"), { directory: "/tmp/root" }))
await wait(() => seen.length === 1)
expect(seen).toEqual([vcs("main")])
} finally {
app.renderer.destroy()
}
})
test("ignores non-matching directory events without an active workspace", async () => {
const { app, emit, seen } = await mount()
try {
emit(event(vcs("other"), { directory: "/tmp/other" }))
await Bun.sleep(30)
expect(seen).toHaveLength(0)
} finally {
app.renderer.destroy()
}
})
test("delivers matching workspace events when a workspace is active", async () => {
const { app, emit, project, seen } = await mount()
try {
project.workspace.set("ws_a")
emit(event(vcs("ws"), { directory: "/tmp/other", workspace: "ws_a" }))
await wait(() => seen.length === 1)
expect(seen).toEqual([vcs("ws")])
} finally {
app.renderer.destroy()
}
})
test("ignores non-matching workspace events when a workspace is active", async () => {
const { app, emit, project, seen } = await mount()
try {
project.workspace.set("ws_a")
emit(event(vcs("ws"), { directory: "/tmp/root", workspace: "ws_b" }))
await Bun.sleep(30)
expect(seen).toHaveLength(0)
} finally {
app.renderer.destroy()
}
})
test("delivers truly global events even when a workspace is active", async () => {
const { app, emit, project, seen } = await mount()
try {
project.workspace.set("ws_a")
emit(event(update("1.2.3"), { directory: "global" }))
await wait(() => seen.length === 1)
expect(seen).toEqual([update("1.2.3")])
} finally {
app.renderer.destroy()
}
})
})

View File

@@ -1,5 +1,7 @@
import { describe, test, expect } from "bun:test"
import path from "path"
import { Effect } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { Instance } from "../../src/project/instance"
import { WebFetchTool } from "../../src/tool/webfetch"
import { SessionID, MessageID } from "../../src/session/schema"
@@ -30,7 +32,11 @@ describe("memory: abort controller leak", () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const tool = await WebFetchTool.init()
const tool = await WebFetchTool.pipe(
Effect.flatMap((info) => Effect.promise(() => info.init())),
Effect.provide(FetchHttpClient.layer),
Effect.runPromise,
)
// Warm up
await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {})

View File

@@ -1,4 +1,5 @@
import { NodeFileSystem } from "@effect/platform-node"
import { FetchHttpClient } from "effect/unstable/http"
import { expect } from "bun:test"
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import path from "path"
@@ -32,6 +33,7 @@ import { SessionStatus } from "../../src/session/status"
import { Skill } from "../../src/skill"
import { Shell } from "../../src/shell/shell"
import { Snapshot } from "../../src/snapshot"
import { TaskTool } from "../../src/tool/task"
import { ToolRegistry } from "../../src/tool/registry"
import { Truncate } from "../../src/tool/truncate"
import { Log } from "../../src/util/log"
@@ -169,6 +171,7 @@ function makeHttp() {
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
const registry = ToolRegistry.layer.pipe(
Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provideMerge(todo),
Layer.provideMerge(question),
Layer.provideMerge(deps),
@@ -725,23 +728,31 @@ it.live(
Effect.gen(function* () {
const ready = defer<void>()
const aborted = defer<void>()
const registry = yield* ToolRegistry.Service
const { task } = yield* registry.named()
const original = task.execute
task.execute = async (_args, ctx) => {
ready.resolve()
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
await new Promise<void>(() => {})
const original = TaskTool.build
TaskTool.build = ((runtime: Parameters<typeof TaskTool.build>[0]) => {
const base = original(runtime)
return {
title: "",
metadata: {
sessionId: SessionID.make("task"),
model: ref,
id: base.id,
async init() {
const next = await base.init()
next.execute = async (_args: any, ctx: any) => {
ready.resolve()
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
await new Promise<void>(() => {})
return {
title: "",
metadata: {
sessionId: SessionID.make("task"),
model: ref,
},
output: "",
}
}
return next
},
output: "",
}
}
yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original)))
}) as typeof TaskTool.build
yield* Effect.addFinalizer(() => Effect.sync(() => void (TaskTool.build = original)))
const { prompt, chat } = yield* boot()
const msg = yield* user(chat.id, "hello")

View File

@@ -12,7 +12,8 @@
* tools internally during multi-step processing before emitting events.
*/
import { expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import fs from "fs/promises"
import path from "path"
import { Session } from "../../src/session"
@@ -28,7 +29,6 @@ import { TestLLMServer } from "../lib/llm-server"
// Same layer setup as prompt-effect.test.ts
import { NodeFileSystem } from "@effect/platform-node"
import { Layer } from "effect"
import { Agent as AgentSvc } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { Command } from "../../src/command"
@@ -134,6 +134,7 @@ function makeHttp() {
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
const registry = ToolRegistry.layer.pipe(
Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provideMerge(todo),
Layer.provideMerge(question),
Layer.provideMerge(deps),

View File

@@ -10,6 +10,7 @@ import { SessionPrompt } from "../../src/session/prompt"
import { MessageID, PartID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { TaskTool } from "../../src/tool/task"
import { Tool } from "../../src/tool/tool"
import { ToolRegistry } from "../../src/tool/registry"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -23,6 +24,15 @@ const ref = {
modelID: ModelID.make("test-model"),
}
const bindTask = (agent: Agent.Interface, config: Config.Interface) =>
TaskTool.build({
agent,
config,
cancel: (sessionID) => SessionPrompt.cancel(sessionID),
resolvePromptParts: (template) => SessionPrompt.resolvePromptParts(template),
prompt: (input) => SessionPrompt.prompt(input),
})
const it = testEffect(
Layer.mergeAll(
Agent.defaultLayer,
@@ -175,11 +185,12 @@ describe("tool.task", () => {
it.live("execute resumes an existing task session from task_id", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const config = yield* Config.Service
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init())
const def = yield* Tool.init(bindTask(agent, config))
const resolve = SessionPrompt.resolvePromptParts
const prompt = SessionPrompt.prompt
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
@@ -229,9 +240,10 @@ describe("tool.task", () => {
it.live("execute asks by default and skips checks when bypassed", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const config = yield* Config.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init())
const def = yield* Tool.init(bindTask(agent, config))
const resolve = SessionPrompt.resolvePromptParts
const prompt = SessionPrompt.prompt
const calls: unknown[] = []
@@ -288,10 +300,11 @@ describe("tool.task", () => {
it.live("execute creates a child when task_id does not exist", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const config = yield* Config.Service
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init())
const def = yield* Tool.init(bindTask(agent, config))
const resolve = SessionPrompt.resolvePromptParts
const prompt = SessionPrompt.prompt
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
@@ -342,10 +355,11 @@ describe("tool.task", () => {
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const config = yield* Config.Service
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init())
const def = yield* Tool.init(bindTask(agent, config))
const resolve = SessionPrompt.resolvePromptParts
const prompt = SessionPrompt.prompt
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined

View File

@@ -1,5 +1,7 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Effect } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { Instance } from "../../src/project/instance"
import { WebFetchTool } from "../../src/tool/webfetch"
import { SessionID, MessageID } from "../../src/session/schema"
@@ -22,6 +24,14 @@ async function withFetch(fetch: (req: Request) => Response | Promise<Response>,
await fn(server.url)
}
function initTool() {
return WebFetchTool.pipe(
Effect.flatMap((info) => Effect.promise(() => info.init())),
Effect.provide(FetchHttpClient.layer),
Effect.runPromise,
)
}
describe("tool.webfetch", () => {
test("returns image responses as file attachments", async () => {
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
@@ -31,7 +41,7 @@ describe("tool.webfetch", () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const webfetch = await WebFetchTool.init()
const webfetch = await initTool()
const result = await webfetch.execute(
{ url: new URL("/image.png", url).toString(), format: "markdown" },
ctx,
@@ -63,7 +73,7 @@ describe("tool.webfetch", () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const webfetch = await WebFetchTool.init()
const webfetch = await initTool()
const result = await webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx)
expect(result.output).toContain("<svg")
expect(result.attachments).toBeUndefined()
@@ -84,7 +94,7 @@ describe("tool.webfetch", () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const webfetch = await WebFetchTool.init()
const webfetch = await initTool()
const result = await webfetch.execute({ url: new URL("/file.txt", url).toString(), format: "text" }, ctx)
expect(result.output).toBe("hello from webfetch")
expect(result.attachments).toBeUndefined()

View File

@@ -1011,6 +1011,8 @@ export type Event =
export type GlobalEvent = {
directory: string
project?: string
workspace?: string
payload: Event
}

View File

@@ -9926,6 +9926,12 @@
"directory": {
"type": "string"
},
"project": {
"type": "string"
},
"workspace": {
"type": "string"
},
"payload": {
"$ref": "#/components/schemas/Event"
}