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,16 +353,8 @@ 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}
|
|
||||||
flexDirection="row"
|
|
||||||
justifyContent="space-between"
|
|
||||||
flexShrink={0}
|
|
||||||
paddingTop={1}
|
|
||||||
>
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
<For each={left()}>
|
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<text>
|
<text>
|
||||||
<span style={{ fg: theme.text }}>
|
<span style={{ fg: theme.text }}>
|
||||||
@@ -383,19 +365,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</box>
|
</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>
|
|
||||||
</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)
|
|
||||||
setStatus(space.id, "disconnected")
|
|
||||||
|
|
||||||
void workspaceEventLoop(space, abort.signal).catch((error) => {
|
|
||||||
setStatus(space.id, "error", String(error))
|
|
||||||
log.warn("workspace sync listener failed", {
|
log.warn("workspace sync listener failed", {
|
||||||
workspaceID: space.id,
|
workspaceID: space.id,
|
||||||
error,
|
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,8 +1281,7 @@ 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)
|
||||||
@@ -1285,8 +1298,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
|
|
||||||
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,7 +1679,8 @@ 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
|
||||||
|
.pipe(
|
||||||
Layer.provide(SessionRunState.defaultLayer),
|
Layer.provide(SessionRunState.defaultLayer),
|
||||||
Layer.provide(SessionStatus.defaultLayer),
|
Layer.provide(SessionStatus.defaultLayer),
|
||||||
Layer.provide(SessionCompaction.defaultLayer),
|
Layer.provide(SessionCompaction.defaultLayer),
|
||||||
@@ -1680,6 +1693,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
Layer.provide(ToolRegistry.defaultLayer),
|
Layer.provide(ToolRegistry.defaultLayer),
|
||||||
Layer.provide(Truncate.defaultLayer),
|
Layer.provide(Truncate.defaultLayer),
|
||||||
Layer.provide(Provider.defaultLayer),
|
Layer.provide(Provider.defaultLayer),
|
||||||
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(Instruction.defaultLayer),
|
Layer.provide(Instruction.defaultLayer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
Layer.provide(Plugin.defaultLayer),
|
Layer.provide(Plugin.defaultLayer),
|
||||||
@@ -1687,8 +1701,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
Layer.provide(SessionRevert.defaultLayer),
|
Layer.provide(SessionRevert.defaultLayer),
|
||||||
Layer.provide(Agent.defaultLayer),
|
Layer.provide(Agent.defaultLayer),
|
||||||
Layer.provide(Bus.layer),
|
Layer.provide(Bus.layer),
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
)
|
||||||
),
|
.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",
|
||||||
|
|||||||
+130
-143
@@ -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,114 +330,7 @@ function cmd(shell: string, name: string, command: string, cwd: string, env: Nod
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const parser = lazy(async () => {
|
async function run(
|
||||||
const { Parser } = await import("web-tree-sitter")
|
|
||||||
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
|
|
||||||
with: { type: "wasm" },
|
|
||||||
})
|
|
||||||
const treePath = resolveWasm(treeWasm)
|
|
||||||
await Parser.init({
|
|
||||||
locateFile() {
|
|
||||||
return treePath
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {
|
|
||||||
with: { type: "wasm" },
|
|
||||||
})
|
|
||||||
const { default: psWasm } = await import("tree-sitter-powershell/tree-sitter-powershell.wasm" as string, {
|
|
||||||
with: { type: "wasm" },
|
|
||||||
})
|
|
||||||
const bashPath = resolveWasm(bashWasm)
|
|
||||||
const psPath = resolveWasm(psWasm)
|
|
||||||
const [bashLanguage, psLanguage] = await Promise.all([Language.load(bashPath), Language.load(psPath)])
|
|
||||||
const bash = new Parser()
|
|
||||||
bash.setLanguage(bashLanguage)
|
|
||||||
const ps = new Parser()
|
|
||||||
ps.setLanguage(psLanguage)
|
|
||||||
return { bash, ps }
|
|
||||||
})
|
|
||||||
|
|
||||||
// TODO: we may wanna rename this tool so it works better on other shells
|
|
||||||
export const BashTool = Tool.defineEffect(
|
|
||||||
"bash",
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const spawner = yield* ChildProcessSpawner
|
|
||||||
const fs = yield* AppFileSystem.Service
|
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
|
|
||||||
const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) {
|
|
||||||
const lines = yield* spawner
|
|
||||||
.lines(ChildProcess.make(shell, ["-lc", 'cygpath -w -- "$1"', "_", text]))
|
|
||||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
|
||||||
const file = lines[0]?.trim()
|
|
||||||
if (!file) return
|
|
||||||
return AppFileSystem.normalizePath(file)
|
|
||||||
})
|
|
||||||
|
|
||||||
const resolvePath = Effect.fn("BashTool.resolvePath")(function* (text: string, root: string, shell: string) {
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
if (Shell.posix(shell) && text.startsWith("/") && AppFileSystem.windowsPath(text) === text) {
|
|
||||||
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 argPath = Effect.fn("BashTool.argPath")(function* (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 yield* resolvePath(next, cwd, shell)
|
|
||||||
})
|
|
||||||
|
|
||||||
const collect = Effect.fn("BashTool.collect")(function* (root: Node, cwd: string, ps: boolean, shell: string) {
|
|
||||||
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 = 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: {
|
input: {
|
||||||
shell: string
|
shell: string
|
||||||
name: string
|
name: string
|
||||||
@@ -396,7 +353,7 @@ export const BashTool = Tool.defineEffect(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const code: number | null = yield* Effect.scoped(
|
const exit = await CrossSpawnSpawner.runPromiseExit((spawner) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env))
|
const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env))
|
||||||
|
|
||||||
@@ -439,8 +396,15 @@ export const BashTool = Tool.defineEffect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return exit.kind === "exit" ? exit.code : null
|
return exit.kind === "exit" ? exit.code : null
|
||||||
}),
|
}).pipe(Effect.scoped, Effect.orDie),
|
||||||
).pipe(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[] = []
|
const meta: string[] = []
|
||||||
if (expired) meta.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
|
if (expired) meta.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
|
||||||
@@ -458,9 +422,37 @@ export const BashTool = Tool.defineEffect(
|
|||||||
},
|
},
|
||||||
output,
|
output,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parser = lazy(async () => {
|
||||||
|
const { Parser } = await import("web-tree-sitter")
|
||||||
|
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
|
||||||
|
with: { type: "wasm" },
|
||||||
|
})
|
||||||
|
const treePath = resolveWasm(treeWasm)
|
||||||
|
await Parser.init({
|
||||||
|
locateFile() {
|
||||||
|
return treePath
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {
|
||||||
|
with: { type: "wasm" },
|
||||||
|
})
|
||||||
|
const { default: psWasm } = await import("tree-sitter-powershell/tree-sitter-powershell.wasm" as string, {
|
||||||
|
with: { type: "wasm" },
|
||||||
|
})
|
||||||
|
const bashPath = resolveWasm(bashWasm)
|
||||||
|
const psPath = resolveWasm(psWasm)
|
||||||
|
const [bashLanguage, psLanguage] = await Promise.all([Language.load(bashPath), Language.load(psPath)])
|
||||||
|
const bash = new Parser()
|
||||||
|
bash.setLanguage(bashLanguage)
|
||||||
|
const ps = new Parser()
|
||||||
|
ps.setLanguage(psLanguage)
|
||||||
|
return { bash, ps }
|
||||||
})
|
})
|
||||||
|
|
||||||
return async () => {
|
// TODO: we may wanna rename this tool so it works better on other shells
|
||||||
|
export const BashTool = Tool.define("bash", async () => {
|
||||||
const shell = Shell.acceptable()
|
const shell = Shell.acceptable()
|
||||||
const name = Shell.name(shell)
|
const name = Shell.name(shell)
|
||||||
const chain =
|
const chain =
|
||||||
@@ -477,35 +469,30 @@ export const BashTool = Tool.defineEffect(
|
|||||||
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
|
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
|
||||||
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
|
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
|
||||||
parameters: Parameters,
|
parameters: Parameters,
|
||||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
async execute(params, ctx) {
|
||||||
Effect.gen(function* () {
|
const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory
|
||||||
const cwd = params.workdir
|
|
||||||
? yield* resolvePath(params.workdir, Instance.directory, shell)
|
|
||||||
: Instance.directory
|
|
||||||
if (params.timeout !== undefined && params.timeout < 0) {
|
if (params.timeout !== undefined && params.timeout < 0) {
|
||||||
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
|
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
|
||||||
}
|
}
|
||||||
const timeout = params.timeout ?? DEFAULT_TIMEOUT
|
const timeout = params.timeout ?? DEFAULT_TIMEOUT
|
||||||
const ps = PS.has(name)
|
const ps = PS.has(name)
|
||||||
const root = yield* parse(params.command, ps)
|
const root = await parse(params.command, ps)
|
||||||
const scan = yield* collect(root, cwd, ps, shell)
|
const scan = await collect(root, cwd, ps, shell)
|
||||||
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
|
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
|
||||||
yield* ask(ctx, scan)
|
await ask(ctx, scan)
|
||||||
|
|
||||||
return yield* run(
|
return run(
|
||||||
{
|
{
|
||||||
shell,
|
shell,
|
||||||
name,
|
name,
|
||||||
command: params.command,
|
command: params.command,
|
||||||
cwd,
|
cwd,
|
||||||
env: yield* shellEnv(ctx, cwd),
|
env: await shellEnv(ctx, cwd),
|
||||||
timeout,
|
timeout,
|
||||||
description: params.description,
|
description: params.description,
|
||||||
},
|
},
|
||||||
ctx,
|
ctx,
|
||||||
)
|
)
|
||||||
}).pipe(Effect.orDie, Effect.runPromise),
|
},
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
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",
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const rg = yield* Ripgrep.Service
|
|
||||||
const fs = yield* AppFileSystem.Service
|
|
||||||
|
|
||||||
return {
|
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
pattern: z.string().describe("The glob pattern to match files against"),
|
pattern: z.string().describe("The glob pattern to match files against"),
|
||||||
@@ -26,10 +18,8 @@ export const GlobTool = Tool.defineEffect(
|
|||||||
`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.`,
|
`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) =>
|
async execute(params, ctx) {
|
||||||
Effect.gen(function* () {
|
await ctx.ask({
|
||||||
yield* Effect.promise(() =>
|
|
||||||
ctx.ask({
|
|
||||||
permission: "glob",
|
permission: "glob",
|
||||||
patterns: [params.pattern],
|
patterns: [params.pattern],
|
||||||
always: ["*"],
|
always: ["*"],
|
||||||
@@ -37,36 +27,30 @@ export const GlobTool = Tool.defineEffect(
|
|||||||
pattern: params.pattern,
|
pattern: params.pattern,
|
||||||
path: params.path,
|
path: params.path,
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
)
|
|
||||||
|
|
||||||
let search = params.path ?? Instance.directory
|
let search = params.path ?? Instance.directory
|
||||||
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
|
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
|
||||||
yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
|
await assertExternalDirectory(ctx, search, { kind: "directory" })
|
||||||
|
|
||||||
const limit = 100
|
const limit = 100
|
||||||
|
const files = []
|
||||||
let truncated = false
|
let truncated = false
|
||||||
const files = yield* rg.files({ cwd: search, glob: [params.pattern] }).pipe(
|
for await (const file of Ripgrep.files({
|
||||||
Stream.mapEffect((file) =>
|
cwd: search,
|
||||||
Effect.gen(function* () {
|
glob: [params.pattern],
|
||||||
const full = path.resolve(search, file)
|
signal: ctx.abort,
|
||||||
const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
})) {
|
||||||
const mtime =
|
if (files.length >= limit) {
|
||||||
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
|
truncated = true
|
||||||
files.length = limit
|
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)
|
files.sort((a, b) => b.mtime - a.mtime)
|
||||||
|
|
||||||
@@ -90,7 +74,5 @@ export const GlobTool = Tool.defineEffect(
|
|||||||
},
|
},
|
||||||
output: output.join("\n"),
|
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,42 +35,31 @@ export const IGNORE_PATTERNS = [
|
|||||||
|
|
||||||
const LIMIT = 100
|
const LIMIT = 100
|
||||||
|
|
||||||
export const ListTool = Tool.defineEffect(
|
export const ListTool = Tool.define("list", {
|
||||||
"list",
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const rg = yield* Ripgrep.Service
|
|
||||||
|
|
||||||
return {
|
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
path: z
|
path: z.string().describe("The absolute path to the directory to list (must be absolute, not relative)").optional(),
|
||||||
.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(),
|
ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
|
||||||
}),
|
}),
|
||||||
execute: (params: { path?: string; ignore?: string[] }, ctx: Tool.Context) =>
|
async execute(params, ctx) {
|
||||||
Effect.gen(function* () {
|
|
||||||
const searchPath = path.resolve(Instance.directory, params.path || ".")
|
const searchPath = path.resolve(Instance.directory, params.path || ".")
|
||||||
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
|
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
|
||||||
|
|
||||||
yield* Effect.promise(() =>
|
await ctx.ask({
|
||||||
ctx.ask({
|
|
||||||
permission: "list",
|
permission: "list",
|
||||||
patterns: [searchPath],
|
patterns: [searchPath],
|
||||||
always: ["*"],
|
always: ["*"],
|
||||||
metadata: {
|
metadata: {
|
||||||
path: searchPath,
|
path: searchPath,
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
)
|
|
||||||
|
|
||||||
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
|
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
|
||||||
const files = yield* rg.files({ cwd: searchPath, glob: ignoreGlobs }).pipe(
|
const files = []
|
||||||
Stream.take(LIMIT),
|
for await (const file of Ripgrep.files({ cwd: searchPath, glob: ignoreGlobs, signal: ctx.abort })) {
|
||||||
Stream.runCollect,
|
files.push(file)
|
||||||
Effect.map((chunk) => [...chunk]),
|
if (files.length >= LIMIT) break
|
||||||
)
|
}
|
||||||
|
|
||||||
// Build directory structure
|
// Build directory structure
|
||||||
const dirs = new Set<string>()
|
const dirs = new Set<string>()
|
||||||
@@ -130,7 +117,5 @@ export const ListTool = Tool.defineEffect(
|
|||||||
},
|
},
|
||||||
output,
|
output,
|
||||||
}
|
}
|
||||||
}).pipe(Effect.orDie, Effect.runPromise),
|
},
|
||||||
}
|
})
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ 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", {
|
||||||
|
description: DESCRIPTION,
|
||||||
|
parameters: z.object({
|
||||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||||
edits: z
|
edits: z
|
||||||
.array(
|
.array(
|
||||||
@@ -18,22 +19,12 @@ const Parameters = z.object({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.describe("Array of edit operations to perform sequentially on the file"),
|
.describe("Array of edit operations to perform sequentially on the file"),
|
||||||
})
|
}),
|
||||||
|
async execute(params, ctx) {
|
||||||
export const MultiEditTool = Tool.defineEffect(
|
const tool = await EditTool.init()
|
||||||
"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 = []
|
const results = []
|
||||||
for (const [, edit] of params.edits.entries()) {
|
for (const [, edit] of params.edits.entries()) {
|
||||||
const result = yield* Effect.promise(() =>
|
const result = await tool.execute(
|
||||||
tool.execute(
|
|
||||||
{
|
{
|
||||||
filePath: params.filePath,
|
filePath: params.filePath,
|
||||||
oldString: edit.oldString,
|
oldString: edit.oldString,
|
||||||
@@ -41,7 +32,6 @@ export const MultiEditTool = Tool.defineEffect(
|
|||||||
replaceAll: edit.replaceAll,
|
replaceAll: edit.replaceAll,
|
||||||
},
|
},
|
||||||
ctx,
|
ctx,
|
||||||
),
|
|
||||||
)
|
)
|
||||||
results.push(result)
|
results.push(result)
|
||||||
}
|
}
|
||||||
@@ -52,7 +42,5 @@ export const MultiEditTool = Tool.defineEffect(
|
|||||||
},
|
},
|
||||||
output: results.at(-1)!.output,
|
output: results.at(-1)!.output,
|
||||||
}
|
}
|
||||||
}).pipe(Effect.orDie, Effect.runPromise),
|
},
|
||||||
}
|
})
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,14 +24,35 @@ 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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaskMetadata = Metadata
|
||||||
|
|
||||||
|
type Runtime = {
|
||||||
|
agent: Agent.Interface
|
||||||
|
config: Config.Interface
|
||||||
|
cancel: (sessionID: SessionID) => Promise<void>
|
||||||
|
resolvePromptParts: (template: string) => Promise<SessionPrompt.PromptInput["parts"]>
|
||||||
|
prompt: (input: SessionPrompt.PromptInput) => Promise<MessageV2.WithParts>
|
||||||
|
}
|
||||||
|
|
||||||
|
const unbound: Tool.DefWithoutID<typeof parameters, Metadata> = {
|
||||||
|
description: DESCRIPTION,
|
||||||
|
parameters,
|
||||||
|
async execute() {
|
||||||
|
throw new Error("Task tool execution is only available from the prompt runtime")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const build = (runtime: Runtime) => {
|
||||||
const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
|
const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
|
||||||
const cfg = yield* config.get()
|
const cfg = yield* runtime.config.get()
|
||||||
|
|
||||||
if (!ctx.extra?.bypassAgentCheck) {
|
if (!ctx.extra?.bypassAgentCheck) {
|
||||||
yield* Effect.promise(() =>
|
yield* Effect.promise(() =>
|
||||||
@@ -48,7 +68,7 @@ export const TaskTool = Tool.defineEffect(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = yield* agent.get(params.subagent_type)
|
const next = yield* runtime.agent.get(params.subagent_type)
|
||||||
if (!next) {
|
if (!next) {
|
||||||
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
|
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
|
||||||
}
|
}
|
||||||
@@ -116,7 +136,7 @@ export const TaskTool = Tool.defineEffect(
|
|||||||
const messageID = MessageID.ascending()
|
const messageID = MessageID.ascending()
|
||||||
|
|
||||||
function cancel() {
|
function cancel() {
|
||||||
SessionPrompt.cancel(nextSession.id)
|
return runtime.cancel(nextSession.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
return yield* Effect.acquireUseRelease(
|
return yield* Effect.acquireUseRelease(
|
||||||
@@ -125,9 +145,9 @@ export const TaskTool = Tool.defineEffect(
|
|||||||
}),
|
}),
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const parts = yield* Effect.promise(() => SessionPrompt.resolvePromptParts(params.prompt))
|
const parts = yield* Effect.promise(() => runtime.resolvePromptParts(params.prompt))
|
||||||
const result = yield* Effect.promise(() =>
|
const result = yield* Effect.promise(() =>
|
||||||
SessionPrompt.prompt({
|
runtime.prompt({
|
||||||
messageID,
|
messageID,
|
||||||
sessionID: nextSession.id,
|
sessionID: nextSession.id,
|
||||||
model: {
|
model: {
|
||||||
@@ -166,12 +186,18 @@ export const TaskTool = Tool.defineEffect(
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return Tool.define(id, {
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
parameters,
|
parameters,
|
||||||
async execute(params: z.infer<typeof parameters>, ctx) {
|
async execute(params: z.infer<typeof parameters>, ctx) {
|
||||||
return Effect.runPromise(run(params, 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,10 +728,14 @@ 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) => {
|
return {
|
||||||
|
id: base.id,
|
||||||
|
async init() {
|
||||||
|
const next = await base.init()
|
||||||
|
next.execute = async (_args: any, ctx: any) => {
|
||||||
ready.resolve()
|
ready.resolve()
|
||||||
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
|
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
|
||||||
await new Promise<void>(() => {})
|
await new Promise<void>(() => {})
|
||||||
@@ -746,7 +748,11 @@ it.live(
|
|||||||
output: "",
|
output: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original)))
|
return next
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}) as typeof TaskTool.build
|
||||||
|
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