Compare commits

..
Author SHA1 Message Date
Kit Langton 3d7501a6a3 refactor(task): split task spec from prompt execution 2026-04-10 13:42:58 -04:00
38 changed files with 1334 additions and 1542 deletions
+4 -1
View File
@@ -202,7 +202,7 @@ Top-level API groups exposed to `tui(api, options, meta)`:
- `api.kv.get`, `set`, `ready` - `api.kv.get`, `set`, `ready`
- `api.state` - `api.state`
- `api.theme.current`, `selected`, `has`, `set`, `install`, `mode`, `ready` - `api.theme.current`, `selected`, `has`, `set`, `install`, `mode`, `ready`
- `api.client` - `api.client`, `api.scopedClient(workspaceID?)`, `api.workspace.current()`, `api.workspace.set(workspaceID?)`
- `api.event.on(type, handler)` - `api.event.on(type, handler)`
- `api.renderer` - `api.renderer`
- `api.slots.register(plugin)` - `api.slots.register(plugin)`
@@ -270,6 +270,7 @@ Command behavior:
- `provider` - `provider`
- `path.{state,config,worktree,directory}` - `path.{state,config,worktree,directory}`
- `vcs?.branch` - `vcs?.branch`
- `workspace.list()` / `workspace.get(workspaceID)`
- `session.count()` - `session.count()`
- `session.diff(sessionID)` - `session.diff(sessionID)`
- `session.todo(sessionID)` - `session.todo(sessionID)`
@@ -281,6 +282,8 @@ Command behavior:
- `lsp()` - `lsp()`
- `mcp()` - `mcp()`
- `api.client` always reflects the current runtime client. - `api.client` always reflects the current runtime client.
- `api.scopedClient(workspaceID?)` creates or reuses a client bound to a workspace.
- `api.workspace.set(...)` rebinds the active workspace; `api.client` follows that rebind.
- `api.event.on(type, handler)` subscribes to the TUI event stream and returns an unsubscribe function. - `api.event.on(type, handler)` subscribes to the TUI event stream and returns an unsubscribe function.
- `api.renderer` exposes the raw `CliRenderer`. - `api.renderer` exposes the raw `CliRenderer`.
+18 -1
View File
@@ -22,7 +22,7 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
import { ErrorComponent } from "@tui/component/error-component" import { ErrorComponent } from "@tui/component/error-component"
import { PluginRouteMissing } from "@tui/component/plugin-route-missing" import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
import { ProjectProvider, useProject } from "@tui/context/project" import { ProjectProvider } from "@tui/context/project"
import { useEvent } from "@tui/context/event" import { useEvent } from "@tui/context/event"
import { SDKProvider, useSDK } from "@tui/context/sdk" import { SDKProvider, useSDK } from "@tui/context/sdk"
import { StartupLoading } from "@tui/component/startup-loading" import { StartupLoading } from "@tui/component/startup-loading"
@@ -36,6 +36,7 @@ import { DialogHelp } from "./ui/dialog-help"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent" import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list" import { DialogSessionList } from "@tui/component/dialog-session-list"
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
import { DialogConsoleOrg } from "@tui/component/dialog-console-org" import { DialogConsoleOrg } from "@tui/component/dialog-console-org"
import { KeybindProvider, useKeybind } from "@tui/context/keybind" import { KeybindProvider, useKeybind } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme" import { ThemeProvider, useTheme } from "@tui/context/theme"
@@ -464,6 +465,22 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
dialog.replace(() => <DialogSessionList />) dialog.replace(() => <DialogSessionList />)
}, },
}, },
...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
? [
{
title: "Manage workspaces",
value: "workspace.list",
category: "Workspace",
suggested: true,
slash: {
name: "workspaces",
},
onSelect: () => {
dialog.replace(() => <DialogWorkspaceList />)
},
},
]
: []),
{ {
title: "New session", title: "New session",
suggested: route.data.type === "session", suggested: route.data.type === "session",
@@ -2,31 +2,25 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select" import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route" import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { createMemo, createResource, createSignal, onMount } from "solid-js" import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { useProject } from "@tui/context/project"
import { useKeybind } from "../context/keybind" import { useKeybind } from "../context/keybind"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk" import { useSDK } from "../context/sdk"
import { Flag } from "@/flag/flag"
import { DialogSessionRename } from "./dialog-session-rename" import { DialogSessionRename } from "./dialog-session-rename"
import { Keybind } from "@/util/keybind" import { useKV } from "../context/kv"
import { createDebouncedSignal } from "../util/signal" import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create"
import { Spinner } from "./spinner" import { Spinner } from "./spinner"
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
export function DialogSessionList() { export function DialogSessionList() {
const dialog = useDialog() const dialog = useDialog()
const route = useRoute() const route = useRoute()
const sync = useSync() const sync = useSync()
const project = useProject()
const keybind = useKeybind() const keybind = useKeybind()
const { theme } = useTheme() const { theme } = useTheme()
const sdk = useSDK() const sdk = useSDK()
const toast = useToast() const kv = useKV()
const [toDelete, setToDelete] = createSignal<string>() const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150) const [search, setSearch] = createDebouncedSignal("", 150)
@@ -37,24 +31,8 @@ export function DialogSessionList() {
}) })
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => searchResults() ?? sync.data.session)
function createWorkspace() { const sessions = createMemo(() => searchResults() ?? sync.data.session)
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(workspaceID) =>
openWorkspaceSession({
dialog,
route,
sdk,
sync,
toast,
workspaceID,
})
}
/>
))
}
const options = createMemo(() => { const options = createMemo(() => {
const today = new Date().toDateString() const today = new Date().toDateString()
@@ -62,43 +40,6 @@ export function DialogSessionList() {
.filter((x) => x.parentID === undefined) .filter((x) => x.parentID === undefined)
.toSorted((a, b) => b.time.updated - a.time.updated) .toSorted((a, b) => b.time.updated - a.time.updated)
.map((x) => { .map((x) => {
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
let workspaceStatus: WorkspaceStatus | null = null
if (x.workspaceID) {
workspaceStatus = project.workspace.status(x.workspaceID) || "error"
}
let footer = ""
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
if (x.workspaceID) {
let desc = "unknown"
if (workspace) {
desc = `${workspace.type}: ${workspace.name}`
}
footer = (
<>
{desc}{" "}
<span
style={{
fg:
workspaceStatus === "error"
? theme.error
: workspaceStatus === "disconnected"
? theme.textMuted
: theme.success,
}}
>
</span>
</>
)
}
} else {
footer = Locale.time(x.time.updated)
}
const date = new Date(x.time.updated) const date = new Date(x.time.updated)
let category = date.toDateString() let category = date.toDateString()
if (category === today) { if (category === today) {
@@ -112,7 +53,7 @@ export function DialogSessionList() {
bg: isDeleting ? theme.error : undefined, bg: isDeleting ? theme.error : undefined,
value: x.id, value: x.id,
category, category,
footer, footer: Locale.time(x.time.updated),
gutter: isWorking ? <Spinner /> : undefined, gutter: isWorking ? <Spinner /> : undefined,
} }
}) })
@@ -161,15 +102,6 @@ export function DialogSessionList() {
dialog.replace(() => <DialogSessionRename session={option.value} />) dialog.replace(() => <DialogSessionRename session={option.value} />)
}, },
}, },
{
keybind: Keybind.parse("ctrl+w")[0],
title: "new workspace",
side: "right",
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
onTrigger: () => {
createWorkspace()
},
},
]} ]}
/> />
) )
@@ -1,121 +0,0 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { useProject } from "@tui/context/project"
import { createMemo, createSignal, onMount } from "solid-js"
import { setTimeout as sleep } from "node:timers/promises"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
return createOpencodeClient({
baseUrl: sdk.url,
fetch: sdk.fetch,
directory: sync.path.directory || sdk.directory,
experimental_workspaceID: workspaceID,
})
}
export async function openWorkspaceSession(input: {
dialog: ReturnType<typeof useDialog>
route: ReturnType<typeof useRoute>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
workspaceID: string
}) {
const client = scoped(input.sdk, input.sync, input.workspaceID)
while (true) {
const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined)
if (!result) {
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
})
return
}
if (result.response.status >= 500 && result.response.status < 600) {
await sleep(1000)
continue
}
if (!result.data) {
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
})
return
}
input.route.navigate({
type: "session",
sessionID: result.data.id,
})
input.dialog.clear()
return
}
}
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
const dialog = useDialog()
const sync = useSync()
const project = useProject()
const sdk = useSDK()
const toast = useToast()
const [creating, setCreating] = createSignal<string>()
onMount(() => {
dialog.setSize("medium")
})
const options = createMemo(() => {
const type = creating()
if (type) {
return [
{
title: `Creating ${type} workspace...`,
value: "creating" as const,
description: "This can take a while for remote environments",
},
]
}
return [
{
title: "Worktree",
value: "worktree" as const,
description: "Create a local git worktree",
},
]
})
const create = async (type: string) => {
if (creating()) return
setCreating(type)
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => undefined)
const workspace = result?.data
if (!workspace) {
setCreating(undefined)
toast.show({
message: "Failed to create workspace",
variant: "error",
})
return
}
await project.workspace.sync()
await props.onSelect(workspace.id)
setCreating(undefined)
}
return (
<DialogSelect
title={creating() ? "Creating Workspace" : "New Workspace"}
skipFilter={true}
options={options()}
onSelect={(option) => {
if (option.value === "creating") return
void create(option.value)
}}
/>
)
}
@@ -0,0 +1,319 @@
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"
import { createOpencodeClient, type Session } from "@opencode-ai/sdk/v2"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
import { useKeybind } from "../context/keybind"
import { DialogSessionList } from "./workspace/dialog-session-list"
import { setTimeout as sleep } from "node:timers/promises"
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID?: string) {
return createOpencodeClient({
baseUrl: sdk.url,
fetch: sdk.fetch,
directory: sync.path.directory || sdk.directory,
experimental_workspaceID: workspaceID,
})
}
async function openWorkspace(input: {
dialog: ReturnType<typeof useDialog>
route: ReturnType<typeof useRoute>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
workspaceID: string
forceCreate?: boolean
}) {
const cacheSession = (session: Session) => {
input.sync.set(
"session",
[...input.sync.data.session.filter((item) => item.id !== session.id), session].toSorted((a, b) =>
a.id.localeCompare(b.id),
),
)
}
const client = scoped(input.sdk, input.sync, input.workspaceID)
const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 })
const session = listed?.data?.[0]
if (session?.id) {
cacheSession(session)
input.route.navigate({
type: "session",
sessionID: session.id,
})
input.dialog.clear()
return
}
let created: Session | undefined
while (!created) {
const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined)
if (!result) {
input.toast.show({
message: "Failed to open workspace",
variant: "error",
})
return
}
if (result.response.status >= 500 && result.response.status < 600) {
await sleep(1000)
continue
}
if (!result.data) {
input.toast.show({
message: "Failed to open workspace",
variant: "error",
})
return
}
created = result.data
}
cacheSession(created)
input.route.navigate({
type: "session",
sessionID: created.id,
})
input.dialog.clear()
}
function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> }) {
const dialog = useDialog()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const [creating, setCreating] = createSignal<string>()
onMount(() => {
dialog.setSize("medium")
})
const options = createMemo(() => {
const type = creating()
if (type) {
return [
{
title: `Creating ${type} workspace...`,
value: "creating" as const,
description: "This can take a while for remote environments",
},
]
}
return [
{
title: "Worktree",
value: "worktree" as const,
description: "Create a local git worktree",
},
]
})
const createWorkspace = async (type: string) => {
if (creating()) return
setCreating(type)
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
console.log(err)
return undefined
})
console.log(JSON.stringify(result, null, 2))
const workspace = result?.data
if (!workspace) {
setCreating(undefined)
toast.show({
message: "Failed to create workspace",
variant: "error",
})
return
}
await sync.workspace.sync()
await props.onSelect(workspace.id)
setCreating(undefined)
}
return (
<DialogSelect
title={creating() ? "Creating Workspace" : "New Workspace"}
skipFilter={true}
options={options()}
onSelect={(option) => {
if (option.value === "creating") return
void createWorkspace(option.value)
}}
/>
)
}
export function DialogWorkspaceList() {
const dialog = useDialog()
const project = useProject()
const route = useRoute()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const keybind = useKeybind()
const [toDelete, setToDelete] = createSignal<string>()
const [counts, setCounts] = createSignal<Record<string, number | null | undefined>>({})
const open = (workspaceID: string, forceCreate?: boolean) =>
openWorkspace({
dialog,
route,
sdk,
sync,
toast,
workspaceID,
forceCreate,
})
async function selectWorkspace(workspaceID: string | null) {
if (workspaceID == null) {
project.workspace.set(undefined)
if (localCount() > 0) {
dialog.replace(() => <DialogSessionList localOnly={true} />)
return
}
route.navigate({
type: "home",
})
dialog.clear()
return
}
const count = counts()[workspaceID]
if (count && count > 0) {
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
return
}
if (count === 0) {
await open(workspaceID)
return
}
const client = scoped(sdk, sync, workspaceID)
const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined)
if (listed?.data?.length) {
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
return
}
await open(workspaceID)
}
const currentWorkspaceID = createMemo(() => project.workspace.current())
const localCount = createMemo(
() => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length,
)
let run = 0
createEffect(() => {
const workspaces = sync.data.workspaceList
const next = ++run
if (!workspaces.length) {
setCounts({})
return
}
setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined])))
void Promise.all(
workspaces.map(async (workspace) => {
const client = scoped(sdk, sync, workspace.id)
const result = await client.session.list({ roots: true }).catch(() => undefined)
return [workspace.id, result ? (result.data?.length ?? 0) : null] as const
}),
).then((entries) => {
if (run !== next) return
setCounts(Object.fromEntries(entries))
})
})
const options = createMemo(() => [
{
title: "Local",
value: null,
category: "Workspace",
description: "Use the local machine",
footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`,
},
...sync.data.workspaceList.map((workspace) => {
const count = counts()[workspace.id]
return {
title:
toDelete() === workspace.id
? `Delete ${workspace.id}? Press ${keybind.print("session_delete")} again`
: workspace.id,
value: workspace.id,
category: workspace.type,
description: workspace.branch ? `Branch ${workspace.branch}` : undefined,
footer:
count === undefined
? "Loading sessions..."
: count === null
? "Sessions unavailable"
: `${count} session${count === 1 ? "" : "s"}`,
}
}),
{
title: "+ New workspace",
value: "__create__",
category: "Actions",
description: "Create a new workspace",
},
])
onMount(() => {
dialog.setSize("large")
void sync.workspace.sync()
})
return (
<DialogSelect
title="Workspaces"
skipFilter={true}
options={options()}
current={currentWorkspaceID()}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
setToDelete(undefined)
if (option.value === "__create__") {
dialog.replace(() => <DialogWorkspaceCreate onSelect={(workspaceID) => open(workspaceID, true)} />)
return
}
void selectWorkspace(option.value)
}}
keybind={[
{
keybind: keybind.all.session_delete?.[0],
title: "delete",
onTrigger: async (option) => {
if (option.value === "__create__" || option.value === null) return
if (toDelete() !== option.value) {
setToDelete(option.value)
return
}
const result = await sdk.client.experimental.workspace.remove({ id: option.value }).catch(() => undefined)
setToDelete(undefined)
if (result?.error) {
toast.show({
message: "Failed to delete workspace",
variant: "error",
})
return
}
if (currentWorkspaceID() === option.value) {
project.workspace.set(undefined)
route.navigate({
type: "home",
})
}
await sync.workspace.sync()
},
},
]}
/>
)
}
@@ -0,0 +1,151 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
import { Locale } from "@/util/locale"
import { useKeybind } from "../../context/keybind"
import { useTheme } from "../../context/theme"
import { useSDK } from "../../context/sdk"
import { DialogSessionRename } from "../dialog-session-rename"
import { useKV } from "../../context/kv"
import { createDebouncedSignal } from "../../util/signal"
import { Spinner } from "../spinner"
import { useToast } from "../../ui/toast"
export function DialogSessionList(props: { workspaceID?: string; localOnly?: boolean } = {}) {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const keybind = useKeybind()
const { theme } = useTheme()
const sdk = useSDK()
const kv = useKV()
const toast = useToast()
const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
const [listed, listedActions] = createResource(
() => props.workspaceID,
async (workspaceID) => {
if (!workspaceID) return undefined
const result = await sdk.client.session.list({ roots: true })
return result.data ?? []
},
)
const [searchResults] = createResource(search, async (query) => {
if (!query || props.localOnly) return undefined
const result = await sdk.client.session.list({
search: query,
limit: 30,
...(props.workspaceID ? { roots: true } : {}),
})
return result.data ?? []
})
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => {
if (searchResults()) return searchResults()!
if (props.workspaceID) return listed() ?? []
if (props.localOnly) return sync.data.session.filter((session) => !session.workspaceID)
return sync.data.session
})
const options = createMemo(() => {
const today = new Date().toDateString()
return sessions()
.filter((x) => {
if (x.parentID !== undefined) return false
if (props.workspaceID && listed()) return true
if (props.workspaceID) return x.workspaceID === props.workspaceID
if (props.localOnly) return !x.workspaceID
return true
})
.toSorted((a, b) => b.time.updated - a.time.updated)
.map((x) => {
const date = new Date(x.time.updated)
let category = date.toDateString()
if (category === today) {
category = "Today"
}
const isDeleting = toDelete() === x.id
const status = sync.data.session_status?.[x.id]
const isWorking = status?.type === "busy"
return {
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
bg: isDeleting ? theme.error : undefined,
value: x.id,
category,
footer: Locale.time(x.time.updated),
gutter: isWorking ? <Spinner /> : undefined,
}
})
})
onMount(() => {
dialog.setSize("large")
})
return (
<DialogSelect
title={props.workspaceID ? `Workspace Sessions` : props.localOnly ? "Local Sessions" : "Sessions"}
options={options()}
skipFilter={!props.localOnly}
current={currentSessionID()}
onFilter={setSearch}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
route.navigate({
type: "session",
sessionID: option.value,
})
dialog.clear()
}}
keybind={[
{
keybind: keybind.all.session_delete?.[0],
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
const deleted = await sdk.client.session
.delete({
sessionID: option.value,
})
.then(() => true)
.catch(() => false)
setToDelete(undefined)
if (!deleted) {
toast.show({
message: "Failed to delete session",
variant: "error",
})
return
}
if (props.workspaceID) {
listedActions.mutate((sessions) => sessions?.filter((session) => session.id !== option.value))
return
}
sync.set(
"session",
sync.data.session.filter((session) => session.id !== option.value),
)
return
}
setToDelete(option.value)
},
},
{
keybind: keybind.all.session_rename?.[0],
title: "rename",
onTrigger: async (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
},
]}
/>
)
}
@@ -1,11 +1,9 @@
import { batch } from "solid-js" import { batch } from "solid-js"
import type { Path, Workspace } from "@opencode-ai/sdk/v2" import type { Path } from "@opencode-ai/sdk"
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
export const { use: useProject, provider: ProjectProvider } = createSimpleContext({ export const { use: useProject, provider: ProjectProvider } = createSimpleContext({
name: "Project", name: "Project",
init: () => { init: () => {
@@ -16,22 +14,17 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
}, },
instance: { instance: {
path: { path: {
home: "",
state: "", state: "",
config: "", config: "",
worktree: "", worktree: "",
directory: sdk.directory ?? "", directory: sdk.directory ?? "",
} satisfies Path, } satisfies Path,
}, },
workspace: { workspace: undefined as string | undefined,
current: undefined as string | undefined,
list: [] as Workspace[],
status: {} as Record<string, WorkspaceStatus>,
},
}) })
async function sync() { async function sync() {
const workspace = store.workspace.current const workspace = store.workspace
const [path, project] = await Promise.all([ const [path, project] = await Promise.all([
sdk.client.path.get({ workspace }), sdk.client.path.get({ workspace }),
sdk.client.project.current({ workspace }), sdk.client.project.current({ workspace }),
@@ -43,27 +36,6 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
}) })
} }
async function syncWorkspace() {
const listed = await sdk.client.experimental.workspace.list().catch(() => undefined)
if (!listed?.data) return
const status = await sdk.client.experimental.workspace.status().catch(() => undefined)
const next = Object.fromEntries((status?.data ?? []).map((item) => [item.workspaceID, item.status]))
batch(() => {
setStore("workspace", "list", reconcile(listed.data))
setStore("workspace", "status", reconcile(next))
if (!listed.data.some((item) => item.id === store.workspace.current)) {
setStore("workspace", "current", undefined)
}
})
}
sdk.event.on("event", (event) => {
if (event.payload.type === "workspace.status") {
setStore("workspace", "status", event.payload.properties.workspaceID, event.payload.properties.status)
}
})
return { return {
data: store, data: store,
project() { project() {
@@ -79,26 +51,13 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
}, },
workspace: { workspace: {
current() { current() {
return store.workspace.current return store.workspace
}, },
set(next?: string | null) { set(next?: string | null) {
const workspace = next ?? undefined const workspace = next ?? undefined
if (store.workspace.current === workspace) return if (store.workspace === workspace) return
setStore("workspace", "current", workspace) setStore("workspace", workspace)
}, },
list() {
return store.workspace.list
},
get(workspaceID: string) {
return store.workspace.list.find((item) => item.id === workspaceID)
},
status(workspaceID: string) {
return store.workspace.status[workspaceID]
},
statuses() {
return store.workspace.status
},
sync: syncWorkspace,
}, },
sync, sync,
} }
@@ -17,6 +17,7 @@ import type {
ProviderListResponse, ProviderListResponse,
ProviderAuthMethod, ProviderAuthMethod,
VcsInfo, VcsInfo,
Workspace,
} from "@opencode-ai/sdk/v2" } from "@opencode-ai/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { useProject } from "@tui/context/project" import { useProject } from "@tui/context/project"
@@ -74,6 +75,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
[key: string]: McpResource [key: string]: McpResource
} }
formatter: FormatterStatus[] formatter: FormatterStatus[]
workspaceList: Workspace[]
vcs: VcsInfo | undefined vcs: VcsInfo | undefined
}>({ }>({
provider_next: { provider_next: {
@@ -101,6 +103,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
mcp: {}, mcp: {},
mcp_resource: {}, mcp_resource: {},
formatter: [], formatter: [],
workspaceList: [],
vcs: undefined, vcs: undefined,
}) })
@@ -108,6 +111,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const project = useProject() const project = useProject()
const sdk = useSDK() 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)
}
}
event.subscribe((event) => { event.subscribe((event) => {
switch (event.type) { switch (event.type) {
case "server.instance.disposed": case "server.instance.disposed":
@@ -355,7 +368,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const workspace = project.workspace.current() const workspace = project.workspace.current()
const start = Date.now() - 30 * 24 * 60 * 60 * 1000 const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const sessionListPromise = sdk.client.session const sessionListPromise = sdk.client.session
.list({ start: start }) .list({ start: start, workspace })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
// blocking - include session.list when continuing a session // blocking - include session.list when continuing a session
@@ -430,7 +443,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}), }),
sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", 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))), sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
project.workspace.sync(), syncWorkspaces(),
]).then(() => { ]).then(() => {
setStore("status", "complete") setStore("status", "complete")
}) })
@@ -509,6 +522,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
fullSyncedSessions.add(sessionID) fullSyncedSessions.add(sessionID)
}, },
}, },
workspace: {
list() {
return store.workspaceList
},
get(workspaceID: string) {
return store.workspaceList.find((item) => item.id === workspaceID)
},
sync: syncWorkspaces,
},
bootstrap, bootstrap,
} }
return result return result
@@ -146,6 +146,14 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
branch: sync.data.vcs.branch, branch: sync.data.vcs.branch,
} }
}, },
workspace: {
list() {
return sync.data.workspaceList
},
get(workspaceID) {
return sync.workspace.get(workspaceID)
},
},
session: { session: {
count() { count() {
return sync.data.session.length return sync.data.session.length
@@ -26,7 +26,6 @@ export interface DialogSelectProps<T> {
keybind?: { keybind?: {
keybind?: Keybind.Info keybind?: Keybind.Info
title: string title: string
side?: "left" | "right"
disabled?: boolean disabled?: boolean
onTrigger: (option: DialogSelectOption<T>) => void onTrigger: (option: DialogSelectOption<T>) => void
}[] }[]
@@ -43,7 +42,6 @@ export interface DialogSelectOption<T = any> {
disabled?: boolean disabled?: boolean
bg?: RGBA bg?: RGBA
gutter?: JSX.Element gutter?: JSX.Element
margin?: JSX.Element
onSelect?: (ctx: DialogContext) => void onSelect?: (ctx: DialogContext) => void
} }
@@ -236,8 +234,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
props.ref?.(ref) props.ref?.(ref)
const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? []) const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? [])
const left = createMemo(() => keybinds().filter((item) => item.side !== "right"))
const right = createMemo(() => keybinds().filter((item) => item.side === "right"))
return ( return (
<box gap={1} paddingBottom={1}> <box gap={1} paddingBottom={1}>
@@ -316,7 +312,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<box <box
id={JSON.stringify(option.value)} id={JSON.stringify(option.value)}
flexDirection="row" flexDirection="row"
position="relative"
onMouseMove={() => { onMouseMove={() => {
setStore("input", "mouse") setStore("input", "mouse")
}} }}
@@ -340,11 +335,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
paddingRight={3} paddingRight={3}
gap={1} gap={1}
> >
<Show when={!current() && option.margin}>
<box position="absolute" left={1} flexShrink={0}>
{option.margin}
</box>
</Show>
<Option <Option
title={option.title} title={option.title}
footer={flatten() ? (option.category ?? option.footer) : option.footer} footer={flatten() ? (option.category ?? option.footer) : option.footer}
@@ -363,38 +353,17 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
</scrollbox> </scrollbox>
</Show> </Show>
<Show when={keybinds().length} fallback={<box flexShrink={0} />}> <Show when={keybinds().length} fallback={<box flexShrink={0} />}>
<box <box paddingRight={2} paddingLeft={4} flexDirection="row" gap={2} flexShrink={0} paddingTop={1}>
paddingRight={2} <For each={keybinds()}>
paddingLeft={4} {(item) => (
flexDirection="row" <text>
justifyContent="space-between" <span style={{ fg: theme.text }}>
flexShrink={0} <b>{item.title}</b>{" "}
paddingTop={1} </span>
> <span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
<box flexDirection="row" gap={2}> </text>
<For each={left()}> )}
{(item) => ( </For>
<text>
<span style={{ fg: theme.text }}>
<b>{item.title}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
</text>
)}
</For>
</box>
<box flexDirection="row" gap={2}>
<For each={right()}>
{(item) => (
<text>
<span style={{ fg: theme.text }}>
<b>{item.title}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
</text>
)}
</For>
</box>
</box> </box>
</Show> </Show>
</box> </box>
+41 -102
View File
@@ -5,9 +5,7 @@ import { Database, eq } from "@/storage/db"
import { Project } from "@/project/project" import { Project } from "@/project/project"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { SyncEvent } from "@/sync"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { Filesystem } from "@/util/filesystem"
import { ProjectID } from "@/project/schema" import { ProjectID } from "@/project/schema"
import { WorkspaceTable } from "./workspace.sql" import { WorkspaceTable } from "./workspace.sql"
import { getAdaptor } from "./adaptors" import { getAdaptor } from "./adaptors"
@@ -16,18 +14,6 @@ import { WorkspaceID } from "./schema"
import { parseSSE } from "./sse" import { parseSSE } from "./sse"
export namespace Workspace { export namespace Workspace {
export const Info = WorkspaceInfo.meta({
ref: "Workspace",
})
export type Info = z.infer<typeof Info>
export const ConnectionStatus = z.object({
workspaceID: WorkspaceID.zod,
status: z.enum(["connected", "connecting", "disconnected", "error"]),
error: z.string().optional(),
})
export type ConnectionStatus = z.infer<typeof ConnectionStatus>
export const Event = { export const Event = {
Ready: BusEvent.define( Ready: BusEvent.define(
"workspace.ready", "workspace.ready",
@@ -41,9 +27,13 @@ export namespace Workspace {
message: z.string(), message: z.string(),
}), }),
), ),
Status: BusEvent.define("workspace.status", ConnectionStatus),
} }
export const Info = WorkspaceInfo.meta({
ref: "Workspace",
})
export type Info = z.infer<typeof Info>
function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
return { return {
id: row.id, id: row.id,
@@ -95,9 +85,6 @@ export namespace Workspace {
}) })
await adaptor.create(config) await adaptor.create(config)
startSync(info)
return info return info
}) })
@@ -105,24 +92,18 @@ export namespace Workspace {
const rows = Database.use((db) => const rows = Database.use((db) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(), db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
) )
const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id)) return rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
for (const space of spaces) startSync(space)
return spaces
} }
export const get = fn(WorkspaceID.zod, async (id) => { export const get = fn(WorkspaceID.zod, async (id) => {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return if (!row) return
const space = fromRow(row) return fromRow(row)
startSync(space)
return space
}) })
export const remove = fn(WorkspaceID.zod, async (id) => { export const remove = fn(WorkspaceID.zod, async (id) => {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (row) { if (row) {
stopSync(id)
const info = fromRow(row) const info = fromRow(row)
const adaptor = await getAdaptor(row.type) const adaptor = await getAdaptor(row.type)
adaptor.remove(info) adaptor.remove(info)
@@ -130,100 +111,58 @@ export namespace Workspace {
return info return info
} }
}) })
const connections = new Map<WorkspaceID, ConnectionStatus>()
const aborts = new Map<WorkspaceID, AbortController>()
function setStatus(id: WorkspaceID, status: ConnectionStatus["status"], error?: string) {
const prev = connections.get(id)
if (prev?.status === status && prev?.error === error) return
const next = { workspaceID: id, status, error }
connections.set(id, next)
GlobalBus.emit("event", {
directory: "global",
workspace: id,
payload: {
type: Event.Status.type,
properties: next,
},
})
}
export function status(): ConnectionStatus[] {
return [...connections.values()]
}
const log = Log.create({ service: "workspace-sync" }) const log = Log.create({ service: "workspace-sync" })
async function workspaceEventLoop(space: Info, signal: AbortSignal) { async function workspaceEventLoop(space: Info, stop: AbortSignal) {
log.info("starting sync: " + space.id) while (!stop.aborted) {
while (!signal.aborted) {
log.info("connecting to sync: " + space.id)
setStatus(space.id, "connecting")
const adaptor = await getAdaptor(space.type) const adaptor = await getAdaptor(space.type)
const target = await adaptor.target(space) const target = await Promise.resolve(adaptor.target(space))
if (target.type === "local") return if (target.type === "local") {
return
}
const res = await fetch(target.url + "/sync/event", { method: "GET", signal }).catch((err: unknown) => { const baseURL = String(target.url).replace(/\/?$/, "/")
setStatus(space.id, "error", String(err))
return undefined const res = await fetch(new URL(baseURL + "/event"), {
method: "GET",
signal: stop,
}) })
if (!res || !res.ok || !res.body) {
log.info("failed to connect to sync: " + res?.status)
setStatus(space.id, "error", res ? `HTTP ${res.status}` : "no response") if (!res.ok || !res.body) {
await sleep(1000) await sleep(1000)
continue continue
} }
setStatus(space.id, "connected")
await parseSSE(res.body, signal, (evt) => {
const event = evt as SyncEvent.SerializedEvent
try { // await parseSSE(res.body, stop, (event) => {
if (!event.type.startsWith("server.")) { // GlobalBus.emit("event", {
SyncEvent.replay(event) // directory: space.id,
} // payload: event,
} catch (err) { // })
log.warn("failed to replay sync event", { // })
workspaceID: space.id,
error: err, // Wait 250ms and retry if SSE connection fails
})
}
})
setStatus(space.id, "disconnected")
log.info("disconnected to sync: " + space.id)
await sleep(250) await sleep(250)
} }
} }
function startSync(space: Info) { export function startSyncing(project: Project.Info) {
if (space.type === "worktree") { const stop = new AbortController()
void Filesystem.exists(space.directory!).then((exists) => { const spaces = list(project).filter((space) => space.type !== "worktree")
setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist")
})
return
}
if (aborts.has(space.id)) return spaces.forEach((space) => {
const abort = new AbortController() void workspaceEventLoop(space, stop.signal).catch((error) => {
aborts.set(space.id, abort) log.warn("workspace sync listener failed", {
setStatus(space.id, "disconnected") workspaceID: space.id,
error,
void workspaceEventLoop(space, abort.signal).catch((error) => { })
setStatus(space.id, "error", String(error))
log.warn("workspace sync listener failed", {
workspaceID: space.id,
error,
}) })
}) })
}
function stopSync(id: WorkspaceID) { return {
aborts.get(id)?.abort() async stop() {
aborts.delete(id) stop.abort()
connections.delete(id) },
}
} }
} }
+1 -1
View File
@@ -8,7 +8,7 @@ import { WorkspaceContext } from "@/control-plane/workspace-context"
export const memoMap = Layer.makeMemoMapUnsafe() export const memoMap = Layer.makeMemoMapUnsafe()
export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> { function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
try { try {
const ctx = Instance.current const ctx = Instance.current
const workspaceID = WorkspaceContext.workspaceID const workspaceID = WorkspaceContext.workspaceID
-70
View File
@@ -3,17 +3,10 @@ import path from "path"
import { Global } from "../global" import { Global } from "../global"
import fs from "fs/promises" import fs from "fs/promises"
import z from "zod" import z from "zod"
import { Effect, Layer, ServiceMap } from "effect"
import * as Stream from "effect/Stream"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import type { PlatformError } from "effect/PlatformError"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { lazy } from "../util/lazy" import { lazy } from "../util/lazy"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import { AppFileSystem } from "../filesystem"
import { Process } from "../util/process" import { Process } from "../util/process"
import { which } from "../util/which" import { which } from "../util/which"
import { text } from "node:stream/consumers" import { text } from "node:stream/consumers"
@@ -281,69 +274,6 @@ export namespace Ripgrep {
input.signal?.throwIfAborted() input.signal?.throwIfAborted()
} }
export interface Interface {
readonly files: (input: {
cwd: string
glob?: string[]
hidden?: boolean
follow?: boolean
maxDepth?: number
}) => Stream.Stream<string, PlatformError>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Ripgrep") {}
export const layer: Layer.Layer<Service, never, ChildProcessSpawner | AppFileSystem.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const afs = yield* AppFileSystem.Service
const files = Effect.fn("Ripgrep.files")(function* (input: {
cwd: string
glob?: string[]
hidden?: boolean
follow?: boolean
maxDepth?: number
}) {
const rgPath = yield* Effect.promise(() => filepath())
const isDir = yield* afs.isDir(input.cwd)
if (!isDir) {
return yield* Effect.die(
Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
code: "ENOENT" as const,
errno: -2,
path: input.cwd,
}),
)
}
const args = [rgPath, "--files", "--glob=!.git/*"]
if (input.follow) args.push("--follow")
if (input.hidden !== false) args.push("--hidden")
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
if (input.glob) {
for (const g of input.glob) {
args.push(`--glob=${g}`)
}
}
return spawner
.streamLines(ChildProcess.make(args[0], args.slice(1), { cwd: input.cwd }))
.pipe(Stream.filter((line: string) => line.length > 0))
})
return Service.of({
files: (input) => Stream.unwrap(files(input)),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
export async function tree(input: { cwd: string; limit?: number; signal?: AbortSignal }) { export async function tree(input: { cwd: string; limit?: number; signal?: AbortSignal }) {
log.info("tree", input) log.info("tree", input)
const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal })) const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal }))
-7
View File
@@ -51,13 +51,6 @@ export namespace Format {
formatters[item.name] = item formatters[item.name] = item
} }
for (const [name, item] of Object.entries(cfg.formatter ?? {})) { for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
// Ruff and uv are both the same formatter, so disabling either should disable both.
if (["ruff", "uv"].includes(name) && (cfg.formatter?.ruff?.disabled || cfg.formatter?.uv?.disabled)) {
// TODO combine formatters so shared backends like Ruff/uv don't need linked disable handling here.
delete formatters.ruff
delete formatters.uv
continue
}
if (item.disabled) { if (item.disabled) {
delete formatters[name] delete formatters[name]
continue continue
+2 -20
View File
@@ -29,20 +29,13 @@ function local(method: string, path: string) {
return false return false
} }
function getSessionID(url: URL) { async function getSessionWorkspace(url: URL) {
if (url.pathname === "/session/status") return null if (url.pathname === "/session/status") return null
const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
if (!id) return null if (!id) return null
return SessionID.make(id) const session = await Session.get(SessionID.make(id)).catch(() => undefined)
}
async function getSessionWorkspace(url: URL) {
const id = getSessionID(url)
if (!id) return null
const session = await Session.get(id).catch(() => undefined)
return session?.workspaceID return session?.workspaceID
} }
@@ -78,18 +71,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
} }
const workspace = await Workspace.get(WorkspaceID.make(workspaceID)) const workspace = await Workspace.get(WorkspaceID.make(workspaceID))
if (!workspace) { if (!workspace) {
// Special-case deleting a session in case user's data in a
// weird state. Allow them to forcefully delete a synced session
// even if the remote workspace is not in their data.
//
// The lets the `DELETE /session/:id` endpoint through and we've
// made sure that it will run without an instance
if (url.pathname.match(/\/session\/[^/]+$/) && c.req.method === "DELETE") {
return routes().fetch(c.req.raw, c.env)
}
return new Response(`Workspace not found: ${workspaceID}`, { return new Response(`Workspace not found: ${workspaceID}`, {
status: 500, status: 500,
headers: { headers: {
@@ -62,28 +62,6 @@ export const WorkspaceRoutes = lazy(() =>
return c.json(Workspace.list(Instance.project)) return c.json(Workspace.list(Instance.project))
}, },
) )
.get(
"/status",
describeRoute({
summary: "Workspace status",
description: "Get connection status for workspaces in the current project.",
operationId: "experimental.workspace.status",
responses: {
200: {
description: "Workspace status",
content: {
"application/json": {
schema: resolver(z.array(Workspace.ConnectionStatus)),
},
},
},
},
}),
async (c) => {
const ids = new Set(Workspace.list(Instance.project).map((item) => item.id))
return c.json(Workspace.status().filter((item) => ids.has(item.workspaceID)))
},
)
.delete( .delete(
"/:id", "/:id",
describeRoute({ describeRoute({
+4 -13
View File
@@ -413,35 +413,26 @@ export namespace Session {
}) })
const children = Effect.fn("Session.children")(function* (parentID: SessionID) { const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
const ctx = yield* InstanceState.context
const rows = yield* db((d) => const rows = yield* db((d) =>
d d
.select() .select()
.from(SessionTable) .from(SessionTable)
.where(and(eq(SessionTable.parent_id, parentID))) .where(and(eq(SessionTable.project_id, ctx.project.id), eq(SessionTable.parent_id, parentID)))
.all(), .all(),
) )
return rows.map(fromRow) return rows.map(fromRow)
}) })
const remove: Interface["remove"] = Effect.fnUntraced(function* (sessionID: SessionID) { const remove: (sessionID: SessionID) => Effect.Effect<void> = Effect.fnUntraced(function* (sessionID: SessionID) {
try { try {
const session = yield* get(sessionID) const session = yield* get(sessionID)
const kids = yield* children(sessionID) const kids = yield* children(sessionID)
for (const child of kids) { for (const child of kids) {
yield* remove(child.id) yield* remove(child.id)
} }
// `remove` needs to work in all cases, such as a broken
// sessions that run cleanup. In certain cases these will
// run without any instance state, so we need to turn off
// publishing of events in that case
const hasInstance = yield* InstanceState.directory.pipe(
Effect.as(true),
Effect.catchCause(() => Effect.succeed(false)),
)
yield* Effect.sync(() => { yield* Effect.sync(() => {
SyncEvent.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance }) SyncEvent.run(Event.Deleted, { sessionID, info: session })
SyncEvent.remove(sessionID) SyncEvent.remove(sessionID)
}) })
} catch (e) { } catch (e) {
+57 -43
View File
@@ -47,6 +47,7 @@ import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { TaskTool } from "@/tool/task" import { TaskTool } from "@/tool/task"
import { Config } from "@/config/config"
import { SessionRunState } from "./run-state" import { SessionRunState } from "./run-state"
// @ts-ignore // @ts-ignore
@@ -88,6 +89,7 @@ export namespace SessionPrompt {
const compaction = yield* SessionCompaction.Service const compaction = yield* SessionCompaction.Service
const plugin = yield* Plugin.Service const plugin = yield* Plugin.Service
const commands = yield* Command.Service const commands = yield* Command.Service
const config = yield* Config.Service
const permission = yield* Permission.Service const permission = yield* Permission.Service
const fsys = yield* AppFileSystem.Service const fsys = yield* AppFileSystem.Service
const mcp = yield* MCP.Service const mcp = yield* MCP.Service
@@ -140,6 +142,17 @@ export namespace SessionPrompt {
return parts 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: { const title = Effect.fn("SessionPrompt.ensureTitle")(function* (input: {
session: Session.Info session: Session.Info
history: MessageV2.WithParts[] 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, providerID: input.model.providerID,
agent: input.agent, agent: input.agent,
})) { })) {
const toolDef = item.id === TaskTool.id ? yield* Tool.init(taskTool()) : item
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
tools[item.id] = tool({ tools[item.id] = tool({
id: item.id as any, 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 }, { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
{ args }, { args },
) )
const result = yield* Effect.promise(() => item.execute(args, ctx)) const result = yield* Effect.promise(() => toolDef.execute(args, ctx))
const output = { const output = {
...result, ...result,
attachments: result.attachments?.map((attachment) => ({ 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 { task, model, lastUser, sessionID, session, msgs } = input
const ctx = yield* InstanceState.context 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 taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({ const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
id: MessageID.ascending(), 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 let error: Error | undefined
const taskDef = yield* Tool.init(taskTool())
const result = yield* Effect.promise((signal) => const result = yield* Effect.promise((signal) =>
taskTool taskDef
.execute(taskArgs, { .execute(taskArgs, {
agent: task.agent, agent: task.agent,
messageID: assistantMessage.id, 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 } return { info, parts }
}, Effect.scoped) }, Effect.scoped)
const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.prompt")( prompt = Effect.fn("SessionPrompt.prompt")(function* (input: PromptInput) {
function* (input: PromptInput) { const session = yield* sessions.get(input.sessionID)
const session = yield* sessions.get(input.sessionID) yield* revert.cleanup(session)
yield* revert.cleanup(session) const message = yield* createUserMessage(input)
const message = yield* createUserMessage(input) yield* sessions.touch(input.sessionID)
yield* sessions.touch(input.sessionID)
const permissions: Permission.Ruleset = [] const permissions: Permission.Ruleset = []
for (const [t, enabled] of Object.entries(input.tools ?? {})) { for (const [t, enabled] of Object.entries(input.tools ?? {})) {
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" }) permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
} }
if (permissions.length > 0) { if (permissions.length > 0) {
session.permission = permissions session.permission = permissions
yield* sessions.setPermission({ sessionID: session.id, permission: permissions }) yield* sessions.setPermission({ sessionID: session.id, permission: permissions })
} }
if (input.noReply === true) return message if (input.noReply === true) return message
return yield* loop({ sessionID: input.sessionID }) return yield* loop({ sessionID: input.sessionID })
}, })
)
const lastAssistant = (sessionID: SessionID) => const lastAssistant = (sessionID: SessionID) =>
Effect.promise(async () => { 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(() => const defaultLayer = Layer.suspend(() =>
layer.pipe( layer
Layer.provide(SessionRunState.defaultLayer), .pipe(
Layer.provide(SessionStatus.defaultLayer), Layer.provide(SessionRunState.defaultLayer),
Layer.provide(SessionCompaction.defaultLayer), Layer.provide(SessionStatus.defaultLayer),
Layer.provide(SessionProcessor.defaultLayer), Layer.provide(SessionCompaction.defaultLayer),
Layer.provide(Command.defaultLayer), Layer.provide(SessionProcessor.defaultLayer),
Layer.provide(Permission.defaultLayer), Layer.provide(Command.defaultLayer),
Layer.provide(MCP.defaultLayer), Layer.provide(Permission.defaultLayer),
Layer.provide(LSP.defaultLayer), Layer.provide(MCP.defaultLayer),
Layer.provide(FileTime.defaultLayer), Layer.provide(LSP.defaultLayer),
Layer.provide(ToolRegistry.defaultLayer), Layer.provide(FileTime.defaultLayer),
Layer.provide(Truncate.defaultLayer), Layer.provide(ToolRegistry.defaultLayer),
Layer.provide(Provider.defaultLayer), Layer.provide(Truncate.defaultLayer),
Layer.provide(Instruction.defaultLayer), Layer.provide(Provider.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Config.defaultLayer),
Layer.provide(Plugin.defaultLayer), Layer.provide(Instruction.defaultLayer),
Layer.provide(Session.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(SessionRevert.defaultLayer), Layer.provide(Plugin.defaultLayer),
Layer.provide(Agent.defaultLayer), Layer.provide(Session.defaultLayer),
Layer.provide(Bus.layer), Layer.provide(SessionRevert.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(Agent.defaultLayer),
), Layer.provide(Bus.layer),
)
.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)),
) )
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
+4 -6
View File
@@ -165,7 +165,7 @@ export namespace SyncEvent {
// and it validets all the sequence ids // and it validets all the sequence ids
// * when loading events from db, apply zod validation to ensure shape // * when loading events from db, apply zod validation to ensure shape
export function replay(event: SerializedEvent, options?: { publish: boolean }) { export function replay(event: SerializedEvent, options?: { republish: boolean }) {
const def = registry.get(event.type) const def = registry.get(event.type)
if (!def) { if (!def) {
throw new Error(`Unknown event type: ${event.type}`) throw new Error(`Unknown event type: ${event.type}`)
@@ -189,10 +189,10 @@ export namespace SyncEvent {
throw new Error(`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`) throw new Error(`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`)
} }
process(def, event, { publish: !!options?.publish }) process(def, event, { publish: !!options?.republish })
} }
export function run<Def extends Definition>(def: Def, data: Event<Def>["data"], options?: { publish?: boolean }) { export function run<Def extends Definition>(def: Def, data: Event<Def>["data"]) {
const agg = (data as Record<string, string>)[def.aggregate] const agg = (data as Record<string, string>)[def.aggregate]
// This should never happen: we've enforced it via typescript in // This should never happen: we've enforced it via typescript in
// the definition // the definition
@@ -204,8 +204,6 @@ export namespace SyncEvent {
throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`) throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`)
} }
const { publish = true } = options || {}
// Note that this is an "immediate" transaction which is critical. // Note that this is an "immediate" transaction which is critical.
// We need to make sure we can safely read and write with nothing // We need to make sure we can safely read and write with nothing
// else changing the data from under us // else changing the data from under us
@@ -220,7 +218,7 @@ export namespace SyncEvent {
const seq = row?.seq != null ? row.seq + 1 : 0 const seq = row?.seq != null ? row.seq + 1 : 0
const event = { id, seq, aggregateID: agg, data } const event = { id, seq, aggregateID: agg, data }
process(def, event, { publish }) process(def, event, { publish: true })
}, },
{ {
behavior: "immediate", behavior: "immediate",
+224 -237
View File
@@ -8,7 +8,8 @@ import { Instance } from "../project/instance"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { Language, type Node } from "web-tree-sitter" import { Language, type Node } from "web-tree-sitter"
import { AppFileSystem } from "@/filesystem" import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Shell } from "@/shell/shell" import { Shell } from "@/shell/shell"
@@ -16,9 +17,9 @@ import { Shell } from "@/shell/shell"
import { BashArity } from "@/permission/arity" import { BashArity } from "@/permission/arity"
import { Truncate } from "./truncate" import { Truncate } from "./truncate"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
import { Effect, Stream } from "effect" import { Cause, Effect, Exit, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
const MAX_METADATA_LENGTH = 30_000 const MAX_METADATA_LENGTH = 30_000
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
@@ -182,6 +183,34 @@ function prefix(text: string) {
return text.slice(0, match.index) return text.slice(0, match.index)
} }
async function cygpath(shell: string, text: string) {
const out = await Process.text([shell, "-lc", 'cygpath -w -- "$1"', "_", text], { nothrow: true })
if (out.code !== 0) return
const file = out.text.trim()
if (!file) return
return Filesystem.normalizePath(file)
}
async function resolvePath(text: string, root: string, shell: string) {
if (process.platform === "win32") {
if (Shell.posix(shell) && text.startsWith("/") && Filesystem.windowsPath(text) === text) {
const file = await cygpath(shell, text)
if (file) return file
}
return Filesystem.normalizePath(path.resolve(root, Filesystem.windowsPath(text)))
}
return path.resolve(root, text)
}
async function argPath(arg: string, cwd: string, ps: boolean, shell: string) {
const text = ps ? expand(arg, cwd, shell) : home(unquote(arg))
const file = text && prefix(text)
if (!file || dynamic(file, ps)) return
const next = ps ? provider(file) : file
if (!next) return
return resolvePath(next, cwd, shell)
}
function pathArgs(list: Part[], ps: boolean) { function pathArgs(list: Part[], ps: boolean) {
if (!ps) { if (!ps) {
return list return list
@@ -209,43 +238,78 @@ function pathArgs(list: Part[], ps: boolean) {
return out return out
} }
async function collect(root: Node, cwd: string, ps: boolean, shell: string): Promise<Scan> {
const scan: Scan = {
dirs: new Set<string>(),
patterns: new Set<string>(),
always: new Set<string>(),
}
for (const node of commands(root)) {
const command = parts(node)
const tokens = command.map((item) => item.text)
const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0]
if (cmd && FILES.has(cmd)) {
for (const arg of pathArgs(command, ps)) {
const resolved = await argPath(arg, cwd, ps, shell)
log.info("resolved path", { arg, resolved })
if (!resolved || Instance.containsPath(resolved)) continue
const dir = (await Filesystem.isDir(resolved)) ? resolved : path.dirname(resolved)
scan.dirs.add(dir)
}
}
if (tokens.length && (!cmd || !CWD.has(cmd))) {
scan.patterns.add(source(node))
scan.always.add(BashArity.prefix(tokens).join(" ") + " *")
}
}
return scan
}
function preview(text: string) { function preview(text: string) {
if (text.length <= MAX_METADATA_LENGTH) return text if (text.length <= MAX_METADATA_LENGTH) return text
return text.slice(0, MAX_METADATA_LENGTH) + "\n\n..." return text.slice(0, MAX_METADATA_LENGTH) + "\n\n..."
} }
const parse = Effect.fn("BashTool.parse")(function* (command: string, ps: boolean) { async function parse(command: string, ps: boolean) {
const tree = yield* Effect.promise(() => parser().then((p) => (ps ? p.ps : p.bash).parse(command))) const tree = await parser().then((p) => (ps ? p.ps : p.bash).parse(command))
if (!tree) throw new Error("Failed to parse command") if (!tree) throw new Error("Failed to parse command")
return tree.rootNode return tree.rootNode
}) }
const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) { async function ask(ctx: Tool.Context, scan: Scan) {
if (scan.dirs.size > 0) { if (scan.dirs.size > 0) {
const globs = Array.from(scan.dirs).map((dir) => { const globs = Array.from(scan.dirs).map((dir) => {
if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*")) if (process.platform === "win32") return Filesystem.normalizePathPattern(path.join(dir, "*"))
return path.join(dir, "*") return path.join(dir, "*")
}) })
yield* Effect.promise(() => await ctx.ask({
ctx.ask({ permission: "external_directory",
permission: "external_directory", patterns: globs,
patterns: globs, always: globs,
always: globs, metadata: {},
metadata: {}, })
}),
)
} }
if (scan.patterns.size === 0) return if (scan.patterns.size === 0) return
yield* Effect.promise(() => await ctx.ask({
ctx.ask({ permission: "bash",
permission: "bash", patterns: Array.from(scan.patterns),
patterns: Array.from(scan.patterns), always: Array.from(scan.always),
always: Array.from(scan.always), metadata: {},
metadata: {}, })
}), }
)
}) async function shellEnv(ctx: Tool.Context, cwd: string) {
const extra = await Plugin.trigger("shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, { env: {} })
return {
...process.env,
...extra.env,
}
}
function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
if (process.platform === "win32" && PS.has(name)) { if (process.platform === "win32" && PS.has(name)) {
@@ -266,6 +330,100 @@ function cmd(shell: string, name: string, command: string, cwd: string, env: Nod
}) })
} }
async function run(
input: {
shell: string
name: string
command: string
cwd: string
env: NodeJS.ProcessEnv
timeout: number
description: string
},
ctx: Tool.Context,
) {
let output = ""
let expired = false
let aborted = false
ctx.metadata({
metadata: {
output: "",
description: input.description,
},
})
const exit = await CrossSpawnSpawner.runPromiseExit((spawner) =>
Effect.gen(function* () {
const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env))
yield* Effect.forkScoped(
Stream.runForEach(Stream.decodeText(handle.all), (chunk) =>
Effect.sync(() => {
output += chunk
ctx.metadata({
metadata: {
output: preview(output),
description: input.description,
},
})
}),
),
)
const abort = Effect.callback<void>((resume) => {
if (ctx.abort.aborted) return resume(Effect.void)
const handler = () => resume(Effect.void)
ctx.abort.addEventListener("abort", handler, { once: true })
return Effect.sync(() => ctx.abort.removeEventListener("abort", handler))
})
const timeout = Effect.sleep(`${input.timeout + 100} millis`)
const exit = yield* Effect.raceAll([
handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))),
abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))),
timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))),
])
if (exit.kind === "abort") {
aborted = true
yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
}
if (exit.kind === "timeout") {
expired = true
yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
}
return exit.kind === "exit" ? exit.code : null
}).pipe(Effect.scoped, Effect.orDie),
)
let code: number | null = null
if (Exit.isSuccess(exit)) {
code = exit.value
} else if (!Cause.hasInterruptsOnly(exit.cause)) {
throw Cause.squash(exit.cause)
}
const meta: string[] = []
if (expired) meta.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
if (aborted) meta.push("User aborted the command")
if (meta.length > 0) {
output += "\n\n<bash_metadata>\n" + meta.join("\n") + "\n</bash_metadata>"
}
return {
title: input.description,
metadata: {
output: preview(output),
exit: code,
description: input.description,
},
output,
}
}
const parser = lazy(async () => { const parser = lazy(async () => {
const { Parser } = await import("web-tree-sitter") const { Parser } = await import("web-tree-sitter")
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, { const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
@@ -294,218 +452,47 @@ const parser = lazy(async () => {
}) })
// TODO: we may wanna rename this tool so it works better on other shells // TODO: we may wanna rename this tool so it works better on other shells
export const BashTool = Tool.defineEffect( export const BashTool = Tool.define("bash", async () => {
"bash", const shell = Shell.acceptable()
Effect.gen(function* () { const name = Shell.name(shell)
const spawner = yield* ChildProcessSpawner const chain =
const fs = yield* AppFileSystem.Service name === "powershell"
const plugin = yield* Plugin.Service ? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
log.info("bash tool using shell", { shell })
const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) { return {
const lines = yield* spawner description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
.lines(ChildProcess.make(shell, ["-lc", 'cygpath -w -- "$1"', "_", text])) .replaceAll("${os}", process.platform)
.pipe(Effect.catch(() => Effect.succeed([] as string[]))) .replaceAll("${shell}", name)
const file = lines[0]?.trim() .replaceAll("${chaining}", chain)
if (!file) return .replaceAll("${maxLines}", String(Truncate.MAX_LINES))
return AppFileSystem.normalizePath(file) .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
}) parameters: Parameters,
async execute(params, ctx) {
const resolvePath = Effect.fn("BashTool.resolvePath")(function* (text: string, root: string, shell: string) { const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory
if (process.platform === "win32") { if (params.timeout !== undefined && params.timeout < 0) {
if (Shell.posix(shell) && text.startsWith("/") && AppFileSystem.windowsPath(text) === text) { throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
const file = yield* cygpath(shell, text)
if (file) return file
}
return AppFileSystem.normalizePath(path.resolve(root, AppFileSystem.windowsPath(text)))
} }
return path.resolve(root, text) const timeout = params.timeout ?? DEFAULT_TIMEOUT
}) const ps = PS.has(name)
const root = await parse(params.command, ps)
const scan = await collect(root, cwd, ps, shell)
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
await ask(ctx, scan)
const argPath = Effect.fn("BashTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) { return run(
const text = ps ? expand(arg, cwd, shell) : home(unquote(arg)) {
const file = text && prefix(text) shell,
if (!file || dynamic(file, ps)) return name,
const next = ps ? provider(file) : file command: params.command,
if (!next) return cwd,
return yield* resolvePath(next, cwd, shell) env: await shellEnv(ctx, cwd),
}) timeout,
description: params.description,
const collect = Effect.fn("BashTool.collect")(function* (root: Node, cwd: string, ps: boolean, shell: string) { },
const scan: Scan = { ctx,
dirs: new Set<string>(),
patterns: new Set<string>(),
always: new Set<string>(),
}
for (const node of commands(root)) {
const command = parts(node)
const tokens = command.map((item) => item.text)
const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0]
if (cmd && FILES.has(cmd)) {
for (const arg of pathArgs(command, ps)) {
const resolved = yield* argPath(arg, cwd, ps, shell)
log.info("resolved path", { arg, resolved })
if (!resolved || Instance.containsPath(resolved)) continue
const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved)
scan.dirs.add(dir)
}
}
if (tokens.length && (!cmd || !CWD.has(cmd))) {
scan.patterns.add(source(node))
scan.always.add(BashArity.prefix(tokens).join(" ") + " *")
}
}
return scan
})
const shellEnv = Effect.fn("BashTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) {
const extra = yield* plugin.trigger(
"shell.env",
{ cwd, sessionID: ctx.sessionID, callID: ctx.callID },
{ env: {} },
) )
return { },
...process.env, }
...extra.env, })
}
})
const run = Effect.fn("BashTool.run")(function* (
input: {
shell: string
name: string
command: string
cwd: string
env: NodeJS.ProcessEnv
timeout: number
description: string
},
ctx: Tool.Context,
) {
let output = ""
let expired = false
let aborted = false
ctx.metadata({
metadata: {
output: "",
description: input.description,
},
})
const code: number | null = yield* Effect.scoped(
Effect.gen(function* () {
const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env))
yield* Effect.forkScoped(
Stream.runForEach(Stream.decodeText(handle.all), (chunk) =>
Effect.sync(() => {
output += chunk
ctx.metadata({
metadata: {
output: preview(output),
description: input.description,
},
})
}),
),
)
const abort = Effect.callback<void>((resume) => {
if (ctx.abort.aborted) return resume(Effect.void)
const handler = () => resume(Effect.void)
ctx.abort.addEventListener("abort", handler, { once: true })
return Effect.sync(() => ctx.abort.removeEventListener("abort", handler))
})
const timeout = Effect.sleep(`${input.timeout + 100} millis`)
const exit = yield* Effect.raceAll([
handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))),
abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))),
timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))),
])
if (exit.kind === "abort") {
aborted = true
yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
}
if (exit.kind === "timeout") {
expired = true
yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
}
return exit.kind === "exit" ? exit.code : null
}),
).pipe(Effect.orDie)
const meta: string[] = []
if (expired) meta.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
if (aborted) meta.push("User aborted the command")
if (meta.length > 0) {
output += "\n\n<bash_metadata>\n" + meta.join("\n") + "\n</bash_metadata>"
}
return {
title: input.description,
metadata: {
output: preview(output),
exit: code,
description: input.description,
},
output,
}
})
return async () => {
const shell = Shell.acceptable()
const name = Shell.name(shell)
const chain =
name === "powershell"
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
log.info("bash tool using shell", { shell })
return {
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
.replaceAll("${os}", process.platform)
.replaceAll("${shell}", name)
.replaceAll("${chaining}", chain)
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
const cwd = params.workdir
? yield* resolvePath(params.workdir, Instance.directory, shell)
: Instance.directory
if (params.timeout !== undefined && params.timeout < 0) {
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
}
const timeout = params.timeout ?? DEFAULT_TIMEOUT
const ps = PS.has(name)
const root = yield* parse(params.command, ps)
const scan = yield* collect(root, cwd, ps, shell)
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
yield* ask(ctx, scan)
return yield* run(
{
shell,
name,
command: params.command,
cwd,
env: yield* shellEnv(ctx, cwd),
timeout,
description: params.description,
},
ctx,
)
}).pipe(Effect.orDie, Effect.runPromise),
}
}
}),
)
+68 -86
View File
@@ -1,96 +1,78 @@
import z from "zod" import z from "zod"
import path from "path" import path from "path"
import { Effect, Option } from "effect"
import * as Stream from "effect/Stream"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Filesystem } from "../util/filesystem"
import DESCRIPTION from "./glob.txt" import DESCRIPTION from "./glob.txt"
import { Ripgrep } from "../file/ripgrep" import { Ripgrep } from "../file/ripgrep"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { assertExternalDirectoryEffect } from "./external-directory" import { assertExternalDirectory } from "./external-directory"
import { AppFileSystem } from "../filesystem"
export const GlobTool = Tool.defineEffect( export const GlobTool = Tool.define("glob", {
"glob", description: DESCRIPTION,
Effect.gen(function* () { parameters: z.object({
const rg = yield* Ripgrep.Service pattern: z.string().describe("The glob pattern to match files against"),
const fs = yield* AppFileSystem.Service path: z
.string()
.optional()
.describe(
`The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
),
}),
async execute(params, ctx) {
await ctx.ask({
permission: "glob",
patterns: [params.pattern],
always: ["*"],
metadata: {
pattern: params.pattern,
path: params.path,
},
})
let search = params.path ?? Instance.directory
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
await assertExternalDirectory(ctx, search, { kind: "directory" })
const limit = 100
const files = []
let truncated = false
for await (const file of Ripgrep.files({
cwd: search,
glob: [params.pattern],
signal: ctx.abort,
})) {
if (files.length >= limit) {
truncated = true
break
}
const full = path.resolve(search, file)
const stats = Filesystem.stat(full)?.mtime.getTime() ?? 0
files.push({
path: full,
mtime: stats,
})
}
files.sort((a, b) => b.mtime - a.mtime)
const output = []
if (files.length === 0) output.push("No files found")
if (files.length > 0) {
output.push(...files.map((f) => f.path))
if (truncated) {
output.push("")
output.push(
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
)
}
}
return { return {
description: DESCRIPTION, title: path.relative(Instance.worktree, search),
parameters: z.object({ metadata: {
pattern: z.string().describe("The glob pattern to match files against"), count: files.length,
path: z truncated,
.string() },
.optional() output: output.join("\n"),
.describe(
`The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
),
}),
execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
ctx.ask({
permission: "glob",
patterns: [params.pattern],
always: ["*"],
metadata: {
pattern: params.pattern,
path: params.path,
},
}),
)
let search = params.path ?? Instance.directory
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
const limit = 100
let truncated = false
const files = yield* rg.files({ cwd: search, glob: [params.pattern] }).pipe(
Stream.mapEffect((file) =>
Effect.gen(function* () {
const full = path.resolve(search, file)
const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
const mtime =
info?.mtime.pipe(
Option.map((d) => d.getTime()),
Option.getOrElse(() => 0),
) ?? 0
return { path: full, mtime }
}),
),
Stream.take(limit + 1),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
if (files.length > limit) {
truncated = true
files.length = limit
}
files.sort((a, b) => b.mtime - a.mtime)
const output = []
if (files.length === 0) output.push("No files found")
if (files.length > 0) {
output.push(...files.map((f) => f.path))
if (truncated) {
output.push("")
output.push(
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
)
}
}
return {
title: path.relative(Instance.worktree, search),
metadata: {
count: files.length,
truncated,
},
output: output.join("\n"),
}
}).pipe(Effect.orDie, Effect.runPromise),
} }
}), },
) })
+82 -97
View File
@@ -1,12 +1,10 @@
import z from "zod" import z from "zod"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import { Tool } from "./tool" import { Tool } from "./tool"
import * as path from "path" import * as path from "path"
import DESCRIPTION from "./ls.txt" import DESCRIPTION from "./ls.txt"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Ripgrep } from "../file/ripgrep" import { Ripgrep } from "../file/ripgrep"
import { assertExternalDirectoryEffect } from "./external-directory" import { assertExternalDirectory } from "./external-directory"
export const IGNORE_PATTERNS = [ export const IGNORE_PATTERNS = [
"node_modules/", "node_modules/",
@@ -37,100 +35,87 @@ export const IGNORE_PATTERNS = [
const LIMIT = 100 const LIMIT = 100
export const ListTool = Tool.defineEffect( export const ListTool = Tool.define("list", {
"list", description: DESCRIPTION,
Effect.gen(function* () { parameters: z.object({
const rg = yield* Ripgrep.Service path: z.string().describe("The absolute path to the directory to list (must be absolute, not relative)").optional(),
ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
}),
async execute(params, ctx) {
const searchPath = path.resolve(Instance.directory, params.path || ".")
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
await ctx.ask({
permission: "list",
patterns: [searchPath],
always: ["*"],
metadata: {
path: searchPath,
},
})
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
const files = []
for await (const file of Ripgrep.files({ cwd: searchPath, glob: ignoreGlobs, signal: ctx.abort })) {
files.push(file)
if (files.length >= LIMIT) break
}
// Build directory structure
const dirs = new Set<string>()
const filesByDir = new Map<string, string[]>()
for (const file of files) {
const dir = path.dirname(file)
const parts = dir === "." ? [] : dir.split("/")
// Add all parent directories
for (let i = 0; i <= parts.length; i++) {
const dirPath = i === 0 ? "." : parts.slice(0, i).join("/")
dirs.add(dirPath)
}
// Add file to its directory
if (!filesByDir.has(dir)) filesByDir.set(dir, [])
filesByDir.get(dir)!.push(path.basename(file))
}
function renderDir(dirPath: string, depth: number): string {
const indent = " ".repeat(depth)
let output = ""
if (depth > 0) {
output += `${indent}${path.basename(dirPath)}/\n`
}
const childIndent = " ".repeat(depth + 1)
const children = Array.from(dirs)
.filter((d) => path.dirname(d) === dirPath && d !== dirPath)
.sort()
// Render subdirectories first
for (const child of children) {
output += renderDir(child, depth + 1)
}
// Render files
const files = filesByDir.get(dirPath) || []
for (const file of files.sort()) {
output += `${childIndent}${file}\n`
}
return output
}
const output = `${searchPath}/\n` + renderDir(".", 0)
return { return {
description: DESCRIPTION, title: path.relative(Instance.worktree, searchPath),
parameters: z.object({ metadata: {
path: z count: files.length,
.string() truncated: files.length >= LIMIT,
.describe("The absolute path to the directory to list (must be absolute, not relative)") },
.optional(), output,
ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
}),
execute: (params: { path?: string; ignore?: string[] }, ctx: Tool.Context) =>
Effect.gen(function* () {
const searchPath = path.resolve(Instance.directory, params.path || ".")
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
yield* Effect.promise(() =>
ctx.ask({
permission: "list",
patterns: [searchPath],
always: ["*"],
metadata: {
path: searchPath,
},
}),
)
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
const files = yield* rg.files({ cwd: searchPath, glob: ignoreGlobs }).pipe(
Stream.take(LIMIT),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
// Build directory structure
const dirs = new Set<string>()
const filesByDir = new Map<string, string[]>()
for (const file of files) {
const dir = path.dirname(file)
const parts = dir === "." ? [] : dir.split("/")
// Add all parent directories
for (let i = 0; i <= parts.length; i++) {
const dirPath = i === 0 ? "." : parts.slice(0, i).join("/")
dirs.add(dirPath)
}
// Add file to its directory
if (!filesByDir.has(dir)) filesByDir.set(dir, [])
filesByDir.get(dir)!.push(path.basename(file))
}
function renderDir(dirPath: string, depth: number): string {
const indent = " ".repeat(depth)
let output = ""
if (depth > 0) {
output += `${indent}${path.basename(dirPath)}/\n`
}
const childIndent = " ".repeat(depth + 1)
const children = Array.from(dirs)
.filter((d) => path.dirname(d) === dirPath && d !== dirPath)
.sort()
// Render subdirectories first
for (const child of children) {
output += renderDir(child, depth + 1)
}
// Render files
const files = filesByDir.get(dirPath) || []
for (const file of files.sort()) {
output += `${childIndent}${file}\n`
}
return output
}
const output = `${searchPath}/\n` + renderDir(".", 0)
return {
title: path.relative(Instance.worktree, searchPath),
metadata: {
count: files.length,
truncated: files.length >= LIMIT,
},
output,
}
}).pipe(Effect.orDie, Effect.runPromise),
} }
}), },
) })
+38 -50
View File
@@ -4,55 +4,43 @@ import { EditTool } from "./edit"
import DESCRIPTION from "./multiedit.txt" import DESCRIPTION from "./multiedit.txt"
import path from "path" import path from "path"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Effect } from "effect"
const Parameters = z.object({ export const MultiEditTool = Tool.define("multiedit", {
filePath: z.string().describe("The absolute path to the file to modify"), description: DESCRIPTION,
edits: z parameters: z.object({
.array( filePath: z.string().describe("The absolute path to the file to modify"),
z.object({ edits: z
filePath: z.string().describe("The absolute path to the file to modify"), .array(
oldString: z.string().describe("The text to replace"), z.object({
newString: z.string().describe("The text to replace it with (must be different from oldString)"), filePath: z.string().describe("The absolute path to the file to modify"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"), oldString: z.string().describe("The text to replace"),
}), newString: z.string().describe("The text to replace it with (must be different from oldString)"),
) replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
.describe("Array of edit operations to perform sequentially on the file"), }),
}) )
.describe("Array of edit operations to perform sequentially on the file"),
export const MultiEditTool = Tool.defineEffect(
"multiedit",
Effect.gen(function* () {
const tool = yield* Tool.init(EditTool)
return {
description: DESCRIPTION,
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
const results = []
for (const [, edit] of params.edits.entries()) {
const result = yield* Effect.promise(() =>
tool.execute(
{
filePath: params.filePath,
oldString: edit.oldString,
newString: edit.newString,
replaceAll: edit.replaceAll,
},
ctx,
),
)
results.push(result)
}
return {
title: path.relative(Instance.worktree, params.filePath),
metadata: {
results: results.map((r) => r.metadata),
},
output: results.at(-1)!.output,
}
}).pipe(Effect.orDie, Effect.runPromise),
}
}), }),
) async execute(params, ctx) {
const tool = await EditTool.init()
const results = []
for (const [, edit] of params.edits.entries()) {
const result = await tool.execute(
{
filePath: params.filePath,
oldString: edit.oldString,
newString: edit.newString,
replaceAll: edit.replaceAll,
},
ctx,
)
results.push(result)
}
return {
title: path.relative(Instance.worktree, params.filePath),
metadata: {
results: results.map((r) => r.metadata),
},
output: results.at(-1)!.output,
}
},
})
+4 -12
View File
@@ -31,9 +31,6 @@ import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { Effect, Layer, ServiceMap } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { Ripgrep } from "../file/ripgrep"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { Env } from "../env" import { Env } from "../env"
@@ -46,6 +43,7 @@ import { AppFileSystem } from "../filesystem"
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
import { Skill } from "../skill" import { Skill } from "../skill"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import type { TaskMetadata } from "./task"
export namespace ToolRegistry { export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" }) const log = Log.create({ service: "tool.registry" })
@@ -89,8 +87,6 @@ export namespace ToolRegistry {
| Instruction.Service | Instruction.Service
| AppFileSystem.Service | AppFileSystem.Service
| HttpClient.HttpClient | HttpClient.HttpClient
| ChildProcessSpawner
| Ripgrep.Service
> = Layer.effect( > = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
@@ -99,7 +95,7 @@ export namespace ToolRegistry {
const agents = yield* Agent.Service const agents = yield* Agent.Service
const skill = yield* Skill.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 read = yield* ReadTool
const question = yield* QuestionTool const question = yield* QuestionTool
const todo = yield* TodoWriteTool const todo = yield* TodoWriteTool
@@ -107,9 +103,7 @@ export namespace ToolRegistry {
const plan = yield* PlanExitTool const plan = yield* PlanExitTool
const webfetch = yield* WebFetchTool const webfetch = yield* WebFetchTool
const websearch = yield* WebSearchTool const websearch = yield* WebSearchTool
const bash = yield* BashTool
const codesearch = yield* CodeSearchTool const codesearch = yield* CodeSearchTool
const globtool = yield* GlobTool
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) { Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -168,9 +162,9 @@ export namespace ToolRegistry {
const tool = yield* Effect.all({ const tool = yield* Effect.all({
invalid: Tool.init(InvalidTool), invalid: Tool.init(InvalidTool),
bash: Tool.init(bash), bash: Tool.init(BashTool),
read: Tool.init(read), read: Tool.init(read),
glob: Tool.init(globtool), glob: Tool.init(GlobTool),
grep: Tool.init(GrepTool), grep: Tool.init(GrepTool),
edit: Tool.init(EditTool), edit: Tool.init(EditTool),
write: Tool.init(WriteTool), write: Tool.init(WriteTool),
@@ -322,8 +316,6 @@ export namespace ToolRegistry {
Layer.provide(Instruction.defaultLayer), Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
), ),
) )
+168 -142
View File
@@ -5,10 +5,9 @@ import { Session } from "../session"
import { SessionID, MessageID } from "../session/schema" import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2" import { MessageV2 } from "../session/message-v2"
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
import { SessionPrompt } from "../session/prompt"
import { Config } from "../config/config" import { Config } from "../config/config"
import type { SessionPrompt } from "../session/prompt"
import { Effect } from "effect" import { Effect } from "effect"
import { Log } from "@/util/log"
const id = "task" const id = "task"
@@ -25,153 +24,180 @@ const parameters = z.object({
command: z.string().describe("The command that triggered this task").optional(), command: z.string().describe("The command that triggered this task").optional(),
}) })
export const TaskTool = Tool.defineEffect( type Metadata = {
id, sessionId: SessionID
Effect.gen(function* () { model: {
const agent = yield* Agent.Service modelID: MessageV2.Assistant["modelID"]
const config = yield* Config.Service providerID: MessageV2.Assistant["providerID"]
}
}
const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) { export type TaskMetadata = Metadata
const cfg = yield* config.get()
if (!ctx.extra?.bypassAgentCheck) { type Runtime = {
yield* Effect.promise(() => agent: Agent.Interface
ctx.ask({ config: Config.Interface
permission: id, cancel: (sessionID: SessionID) => Promise<void>
patterns: [params.subagent_type], resolvePromptParts: (template: string) => Promise<SessionPrompt.PromptInput["parts"]>
always: ["*"], prompt: (input: SessionPrompt.PromptInput) => Promise<MessageV2.WithParts>
metadata: { }
description: params.description,
subagent_type: params.subagent_type,
},
}),
)
}
const next = yield* agent.get(params.subagent_type) const unbound: Tool.DefWithoutID<typeof parameters, Metadata> = {
if (!next) { description: DESCRIPTION,
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)) 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 build = (runtime: Runtime) => {
const canTodo = next.permission.some((rule) => rule.permission === "todowrite") 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 if (!ctx.extra?.bypassAgentCheck) {
const session = taskID yield* Effect.promise(() =>
? yield* Effect.promise(() => { ctx.ask({
const id = SessionID.make(taskID) permission: id,
return Session.get(id).catch(() => undefined) patterns: [params.subagent_type],
}) always: ["*"],
: undefined metadata: {
const nextSession = description: params.description,
session ?? subagent_type: params.subagent_type,
(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)
}), }),
() =>
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 { const messageID = MessageID.ascending()
description: DESCRIPTION,
parameters, function cancel() {
async execute(params: z.infer<typeof parameters>, ctx) { return runtime.cancel(nextSession.id)
return Effect.runPromise(run(params, ctx))
},
} }
}),
) 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,
})
@@ -244,6 +244,7 @@ describe("SyncProvider", () => {
expect(log.some((item) => item.path === "/path" && item.workspace === "ws_a")).toBe(true) 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 === "/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) expect(log.some((item) => item.path === "/command" && item.workspace === "ws_a")).toBe(true)
} finally { } finally {
app.renderer.destroy() app.renderer.destroy()
@@ -1,6 +1,4 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
@@ -54,52 +52,3 @@ describe("file.ripgrep", () => {
expect(hits).toEqual([]) expect(hits).toEqual([])
}) })
}) })
describe("Ripgrep.Service", () => {
test("files returns stream of filenames", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "a.txt"), "hello")
await Bun.write(path.join(dir, "b.txt"), "world")
},
})
const files = await Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return yield* rg.files({ cwd: tmp.path }).pipe(
Stream.runCollect,
Effect.map((chunk) => [...chunk].sort()),
)
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
expect(files).toEqual(["a.txt", "b.txt"])
})
test("files respects glob filter", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "keep.ts"), "yes")
await Bun.write(path.join(dir, "skip.txt"), "no")
},
})
const files = await Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return yield* rg.files({ cwd: tmp.path, glob: ["*.ts"] }).pipe(
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
expect(files).toEqual(["keep.ts"])
})
test("files dies on nonexistent directory", async () => {
const exit = await Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return yield* rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect)
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromiseExit)
expect(exit._tag).toBe("Failure")
})
})
+6 -1
View File
@@ -93,6 +93,7 @@ type Opts = {
provider?: HostPluginApi["state"]["provider"] provider?: HostPluginApi["state"]["provider"]
path?: HostPluginApi["state"]["path"] path?: HostPluginApi["state"]["path"]
vcs?: HostPluginApi["state"]["vcs"] vcs?: HostPluginApi["state"]["vcs"]
workspace?: Partial<HostPluginApi["state"]["workspace"]>
session?: Partial<HostPluginApi["state"]["session"]> session?: Partial<HostPluginApi["state"]["session"]>
part?: HostPluginApi["state"]["part"] part?: HostPluginApi["state"]["part"]
lsp?: HostPluginApi["state"]["lsp"] lsp?: HostPluginApi["state"]["lsp"]
@@ -276,11 +277,15 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
return opts.state?.provider ?? [] return opts.state?.provider ?? []
}, },
get path() { get path() {
return opts.state?.path ?? { home: "", state: "", config: "", worktree: "", directory: "" } return opts.state?.path ?? { state: "", config: "", worktree: "", directory: "" }
}, },
get vcs() { get vcs() {
return opts.state?.vcs return opts.state?.vcs
}, },
workspace: {
list: opts.state?.workspace?.list ?? (() => []),
get: opts.state?.workspace?.get ?? (() => undefined),
},
session: { session: {
count: opts.state?.session?.count ?? (() => 0), count: opts.state?.session?.count ?? (() => 0),
diff: opts.state?.session?.diff ?? (() => []), diff: opts.state?.session?.diff ?? (() => []),
@@ -64,46 +64,6 @@ describe("Format", () => {
), ),
) )
it.live("status() excludes uv when ruff is disabled", () =>
provideTmpdirInstance(
() =>
Format.Service.use((fmt) =>
Effect.gen(function* () {
const statuses = yield* fmt.status()
expect(statuses.find((item) => item.name === "ruff")).toBeUndefined()
expect(statuses.find((item) => item.name === "uv")).toBeUndefined()
}),
),
{
config: {
formatter: {
ruff: { disabled: true },
},
},
},
),
)
it.live("status() excludes ruff when uv is disabled", () =>
provideTmpdirInstance(
() =>
Format.Service.use((fmt) =>
Effect.gen(function* () {
const statuses = yield* fmt.status()
expect(statuses.find((item) => item.name === "ruff")).toBeUndefined()
expect(statuses.find((item) => item.name === "uv")).toBeUndefined()
}),
),
{
config: {
formatter: {
uv: { disabled: true },
},
},
},
),
)
it.live("service initializes without error", () => provideTmpdirInstance(() => Format.Service.use(() => Effect.void))) it.live("service initializes without error", () => provideTmpdirInstance(() => Format.Service.use(() => Effect.void)))
it.live("status() initializes formatter state per directory", () => it.live("status() initializes formatter state per directory", () =>
@@ -33,11 +33,11 @@ import { SessionStatus } from "../../src/session/status"
import { Skill } from "../../src/skill" import { Skill } from "../../src/skill"
import { Shell } from "../../src/shell/shell" import { Shell } from "../../src/shell/shell"
import { Snapshot } from "../../src/snapshot" import { Snapshot } from "../../src/snapshot"
import { TaskTool } from "../../src/tool/task"
import { ToolRegistry } from "../../src/tool/registry" import { ToolRegistry } from "../../src/tool/registry"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool/truncate"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep"
import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { reply, TestLLMServer } from "../lib/llm-server" import { reply, TestLLMServer } from "../lib/llm-server"
@@ -172,8 +172,6 @@ function makeHttp() {
const registry = ToolRegistry.layer.pipe( const registry = ToolRegistry.layer.pipe(
Layer.provide(Skill.defaultLayer), Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provideMerge(todo), Layer.provideMerge(todo),
Layer.provideMerge(question), Layer.provideMerge(question),
Layer.provideMerge(deps), Layer.provideMerge(deps),
@@ -730,23 +728,31 @@ it.live(
Effect.gen(function* () { Effect.gen(function* () {
const ready = defer<void>() const ready = defer<void>()
const aborted = defer<void>() const aborted = defer<void>()
const registry = yield* ToolRegistry.Service const original = TaskTool.build
const { task } = yield* registry.named() TaskTool.build = ((runtime: Parameters<typeof TaskTool.build>[0]) => {
const original = task.execute const base = original(runtime)
task.execute = async (_args, ctx) => {
ready.resolve()
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
await new Promise<void>(() => {})
return { return {
title: "", id: base.id,
metadata: { async init() {
sessionId: SessionID.make("task"), const next = await base.init()
model: ref, 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: "",
} }
} }) as typeof TaskTool.build
yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original))) yield* Effect.addFinalizer(() => Effect.sync(() => void (TaskTool.build = original)))
const { prompt, chat } = yield* boot() const { prompt, chat } = yield* boot()
const msg = yield* user(chat.id, "hello") const msg = yield* user(chat.id, "hello")
@@ -6,7 +6,6 @@ import { Log } from "../../src/util/log"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID } from "../../src/session/schema" import { MessageID, PartID } from "../../src/session/schema"
import { tmpdir } from "../fixture/fixture"
const projectRoot = path.join(__dirname, "../..") const projectRoot = path.join(__dirname, "../..")
Log.init({ print: false }) Log.init({ print: false })
@@ -141,25 +140,3 @@ describe("step-finish token propagation via Bus event", () => {
{ timeout: 30000 }, { timeout: 30000 },
) )
}) })
describe("Session", () => {
test("remove works without an instance", async () => {
await using tmp = await tmpdir({ git: true })
const session = await Instance.provide({
directory: tmp.path,
fn: async () => Session.create({ title: "remove-without-instance" }),
})
await expect(async () => {
await Session.remove(session.id)
}).not.toThrow()
let missing = false
await Session.get(session.id).catch(() => {
missing = true
})
expect(missing).toBe(true)
})
})
@@ -53,7 +53,6 @@ import { ToolRegistry } from "../../src/tool/registry"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool/truncate"
import { AppFileSystem } from "../../src/filesystem" import { AppFileSystem } from "../../src/filesystem"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep"
Log.init({ print: false }) Log.init({ print: false })
@@ -136,8 +135,6 @@ function makeHttp() {
const registry = ToolRegistry.layer.pipe( const registry = ToolRegistry.layer.pipe(
Layer.provide(Skill.defaultLayer), Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provideMerge(todo), Layer.provideMerge(todo),
Layer.provideMerge(question), Layer.provideMerge(question),
Layer.provideMerge(deps), Layer.provideMerge(deps),
+38 -50
View File
@@ -1,5 +1,4 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Effect, Layer, ManagedRuntime } from "effect"
import os from "os" import os from "os"
import path from "path" import path from "path"
import { Shell } from "../../src/shell/shell" import { Shell } from "../../src/shell/shell"
@@ -10,17 +9,6 @@ import { tmpdir } from "../fixture/fixture"
import type { Permission } from "../../src/permission" import type { Permission } from "../../src/permission"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool/truncate"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { AppFileSystem } from "../../src/filesystem"
import { Plugin } from "../../src/plugin"
const runtime = ManagedRuntime.make(
Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer, Plugin.defaultLayer),
)
function initBash() {
return runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => Effect.promise(() => info.init()))))
}
const ctx = { const ctx = {
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
@@ -130,7 +118,7 @@ describe("tool.bash", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const result = await bash.execute( const result = await bash.execute(
{ {
command: "echo test", command: "echo test",
@@ -151,7 +139,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -172,7 +160,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -196,7 +184,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -220,7 +208,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*" const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*"
@@ -254,7 +242,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/") const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
@@ -285,7 +273,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -313,7 +301,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini` const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`
await bash.execute( await bash.execute(
@@ -343,7 +331,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -371,7 +359,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -400,7 +388,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -428,7 +416,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -460,7 +448,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "") const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "")
@@ -493,7 +481,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -520,7 +508,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -550,7 +538,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -580,7 +568,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -609,7 +597,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -634,7 +622,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -657,7 +645,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -685,7 +673,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const want = Filesystem.normalizePathPattern(path.join(outerTmp.path, "*")) const want = Filesystem.normalizePathPattern(path.join(outerTmp.path, "*"))
for (const dir of forms(outerTmp.path)) { for (const dir of forms(outerTmp.path)) {
@@ -719,7 +707,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const want = glob(path.join(os.tmpdir(), "*")) const want = glob(path.join(os.tmpdir(), "*"))
@@ -749,7 +737,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const want = glob(path.join(os.tmpdir(), "*")) const want = glob(path.join(os.tmpdir(), "*"))
@@ -784,7 +772,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const filepath = path.join(outerTmp.path, "outside.txt") const filepath = path.join(outerTmp.path, "outside.txt")
@@ -815,7 +803,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -835,7 +823,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -856,7 +844,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -876,7 +864,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -897,7 +885,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute({ command: "ls -la", description: "List" }, capture(requests)) await bash.execute({ command: "ls -la", description: "List" }, capture(requests))
const bashReq = requests.find((r) => r.permission === "bash") const bashReq = requests.find((r) => r.permission === "bash")
@@ -913,7 +901,7 @@ describe("tool.bash abort", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const controller = new AbortController() const controller = new AbortController()
const collected: string[] = [] const collected: string[] = []
const result = bash.execute( const result = bash.execute(
@@ -945,7 +933,7 @@ describe("tool.bash abort", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const result = await bash.execute( const result = await bash.execute(
{ {
command: `echo started && sleep 60`, command: `echo started && sleep 60`,
@@ -964,7 +952,7 @@ describe("tool.bash abort", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const result = await bash.execute( const result = await bash.execute(
{ {
command: `echo stdout_msg && echo stderr_msg >&2`, command: `echo stdout_msg && echo stderr_msg >&2`,
@@ -983,7 +971,7 @@ describe("tool.bash abort", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const result = await bash.execute( const result = await bash.execute(
{ {
command: `exit 42`, command: `exit 42`,
@@ -1000,7 +988,7 @@ describe("tool.bash abort", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const updates: string[] = [] const updates: string[] = []
const result = await bash.execute( const result = await bash.execute(
{ {
@@ -1028,7 +1016,7 @@ describe("tool.bash truncation", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const lineCount = Truncate.MAX_LINES + 500 const lineCount = Truncate.MAX_LINES + 500
const result = await bash.execute( const result = await bash.execute(
{ {
@@ -1048,7 +1036,7 @@ describe("tool.bash truncation", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const byteCount = Truncate.MAX_BYTES + 10000 const byteCount = Truncate.MAX_BYTES + 10000
const result = await bash.execute( const result = await bash.execute(
{ {
@@ -1068,7 +1056,7 @@ describe("tool.bash truncation", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const result = await bash.execute( const result = await bash.execute(
{ {
command: "echo hello", command: "echo hello",
@@ -1086,7 +1074,7 @@ describe("tool.bash truncation", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await initBash() const bash = await BashTool.init()
const lineCount = Truncate.MAX_LINES + 100 const lineCount = Truncate.MAX_LINES + 100
const result = await bash.execute( const result = await bash.execute(
{ {
+22 -8
View File
@@ -10,6 +10,7 @@ import { SessionPrompt } from "../../src/session/prompt"
import { MessageID, PartID } from "../../src/session/schema" import { MessageID, PartID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import { TaskTool } from "../../src/tool/task" import { TaskTool } from "../../src/tool/task"
import { Tool } from "../../src/tool/tool"
import { ToolRegistry } from "../../src/tool/registry" import { ToolRegistry } from "../../src/tool/registry"
import { provideTmpdirInstance } from "../fixture/fixture" import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@@ -23,6 +24,15 @@ const ref = {
modelID: ModelID.make("test-model"), 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( const it = testEffect(
Layer.mergeAll( Layer.mergeAll(
Agent.defaultLayer, Agent.defaultLayer,
@@ -175,11 +185,12 @@ describe("tool.task", () => {
it.live("execute resumes an existing task session from task_id", () => it.live("execute resumes an existing task session from task_id", () =>
provideTmpdirInstance(() => provideTmpdirInstance(() =>
Effect.gen(function* () { Effect.gen(function* () {
const agent = yield* Agent.Service
const config = yield* Config.Service
const sessions = yield* Session.Service const sessions = yield* Session.Service
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" }) const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
const tool = yield* TaskTool const def = yield* Tool.init(bindTask(agent, config))
const def = yield* Effect.promise(() => tool.init())
const resolve = SessionPrompt.resolvePromptParts const resolve = SessionPrompt.resolvePromptParts
const prompt = SessionPrompt.prompt const prompt = SessionPrompt.prompt
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined 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", () => it.live("execute asks by default and skips checks when bypassed", () =>
provideTmpdirInstance(() => provideTmpdirInstance(() =>
Effect.gen(function* () { Effect.gen(function* () {
const agent = yield* Agent.Service
const config = yield* Config.Service
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const tool = yield* TaskTool const def = yield* Tool.init(bindTask(agent, config))
const def = yield* Effect.promise(() => tool.init())
const resolve = SessionPrompt.resolvePromptParts const resolve = SessionPrompt.resolvePromptParts
const prompt = SessionPrompt.prompt const prompt = SessionPrompt.prompt
const calls: unknown[] = [] const calls: unknown[] = []
@@ -288,10 +300,11 @@ describe("tool.task", () => {
it.live("execute creates a child when task_id does not exist", () => it.live("execute creates a child when task_id does not exist", () =>
provideTmpdirInstance(() => provideTmpdirInstance(() =>
Effect.gen(function* () { Effect.gen(function* () {
const agent = yield* Agent.Service
const config = yield* Config.Service
const sessions = yield* Session.Service const sessions = yield* Session.Service
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const tool = yield* TaskTool const def = yield* Tool.init(bindTask(agent, config))
const def = yield* Effect.promise(() => tool.init())
const resolve = SessionPrompt.resolvePromptParts const resolve = SessionPrompt.resolvePromptParts
const prompt = SessionPrompt.prompt const prompt = SessionPrompt.prompt
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
@@ -342,10 +355,11 @@ describe("tool.task", () => {
provideTmpdirInstance( provideTmpdirInstance(
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const agent = yield* Agent.Service
const config = yield* Config.Service
const sessions = yield* Session.Service const sessions = yield* Session.Service
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const tool = yield* TaskTool const def = yield* Tool.init(bindTask(agent, config))
const def = yield* Effect.promise(() => tool.init())
const resolve = SessionPrompt.resolvePromptParts const resolve = SessionPrompt.resolvePromptParts
const prompt = SessionPrompt.prompt const prompt = SessionPrompt.prompt
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
+4
View File
@@ -272,6 +272,10 @@ export type TuiState = {
directory: string directory: string
} }
readonly vcs: { branch?: string } | undefined readonly vcs: { branch?: string } | undefined
readonly workspace: {
list: () => ReadonlyArray<Workspace>
get: (workspaceID: string) => Workspace | undefined
}
session: { session: {
count: () => number count: () => number
diff: (sessionID: string) => ReadonlyArray<TuiSidebarFileItem> diff: (sessionID: string) => ReadonlyArray<TuiSidebarFileItem>
-31
View File
@@ -34,7 +34,6 @@ import type {
ExperimentalWorkspaceListResponses, ExperimentalWorkspaceListResponses,
ExperimentalWorkspaceRemoveErrors, ExperimentalWorkspaceRemoveErrors,
ExperimentalWorkspaceRemoveResponses, ExperimentalWorkspaceRemoveResponses,
ExperimentalWorkspaceStatusResponses,
FileListResponses, FileListResponses,
FilePartInput, FilePartInput,
FilePartSource, FilePartSource,
@@ -1164,36 +1163,6 @@ export class Workspace extends HeyApiClient {
}) })
} }
/**
* Workspace status
*
* Get connection status for workspaces in the current project.
*/
public status<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<ExperimentalWorkspaceStatusResponses, unknown, ThrowOnError>({
url: "/experimental/workspace/status",
...options,
...params,
})
}
/** /**
* Remove workspace * Remove workspace
* *
-34
View File
@@ -330,15 +330,6 @@ export type EventWorkspaceFailed = {
} }
} }
export type EventWorkspaceStatus = {
type: "workspace.status"
properties: {
workspaceID: string
status: "connected" | "connecting" | "disconnected" | "error"
error?: string
}
}
export type QuestionOption = { export type QuestionOption = {
/** /**
* Display text (1-5 words, concise) * Display text (1-5 words, concise)
@@ -997,7 +988,6 @@ export type Event =
| EventCommandExecuted | EventCommandExecuted
| EventWorkspaceReady | EventWorkspaceReady
| EventWorkspaceFailed | EventWorkspaceFailed
| EventWorkspaceStatus
| EventQuestionAsked | EventQuestionAsked
| EventQuestionReplied | EventQuestionReplied
| EventQuestionRejected | EventQuestionRejected
@@ -2867,30 +2857,6 @@ export type ExperimentalWorkspaceCreateResponses = {
export type ExperimentalWorkspaceCreateResponse = export type ExperimentalWorkspaceCreateResponse =
ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses] ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses]
export type ExperimentalWorkspaceStatusData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/experimental/workspace/status"
}
export type ExperimentalWorkspaceStatusResponses = {
/**
* Workspace status
*/
200: Array<{
workspaceID: string
status: "connected" | "connecting" | "disconnected" | "error"
error?: string
}>
}
export type ExperimentalWorkspaceStatusResponse =
ExperimentalWorkspaceStatusResponses[keyof ExperimentalWorkspaceStatusResponses]
export type ExperimentalWorkspaceRemoveData = { export type ExperimentalWorkspaceRemoveData = {
body?: never body?: never
path: { path: {
-88
View File
@@ -1656,64 +1656,6 @@
] ]
} }
}, },
"/experimental/workspace/status": {
"get": {
"operationId": "experimental.workspace.status",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "workspace",
"schema": {
"type": "string"
}
}
],
"summary": "Workspace status",
"description": "Get connection status for workspaces in the current project.",
"responses": {
"200": {
"description": "Workspace status",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"workspaceID": {
"type": "string",
"pattern": "^wrk.*"
},
"status": {
"type": "string",
"enum": ["connected", "connecting", "disconnected", "error"]
},
"error": {
"type": "string"
}
},
"required": ["workspaceID", "status"]
}
}
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.status({\n ...\n})"
}
]
}
},
"/experimental/workspace/{id}": { "/experimental/workspace/{id}": {
"delete": { "delete": {
"operationId": "experimental.workspace.remove", "operationId": "experimental.workspace.remove",
@@ -8024,33 +7966,6 @@
}, },
"required": ["type", "properties"] "required": ["type", "properties"]
}, },
"Event.workspace.status": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "workspace.status"
},
"properties": {
"type": "object",
"properties": {
"workspaceID": {
"type": "string",
"pattern": "^wrk.*"
},
"status": {
"type": "string",
"enum": ["connected", "connecting", "disconnected", "error"]
},
"error": {
"type": "string"
}
},
"required": ["workspaceID", "status"]
}
},
"required": ["type", "properties"]
},
"QuestionOption": { "QuestionOption": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -9943,9 +9858,6 @@
{ {
"$ref": "#/components/schemas/Event.workspace.failed" "$ref": "#/components/schemas/Event.workspace.failed"
}, },
{
"$ref": "#/components/schemas/Event.workspace.status"
},
{ {
"$ref": "#/components/schemas/Event.question.asked" "$ref": "#/components/schemas/Event.question.asked"
}, },