Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d7501a6a3 |
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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),
|
|
||||||
}
|
}
|
||||||
}),
|
},
|
||||||
)
|
})
|
||||||
|
|||||||
@@ -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),
|
|
||||||
}
|
}
|
||||||
}),
|
},
|
||||||
)
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
@@ -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
@@ -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")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user