mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-11 08:24:55 +00:00
Compare commits
1 Commits
fix-anthro
...
task-spec-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d7501a6a3 |
@@ -202,7 +202,7 @@ Top-level API groups exposed to `tui(api, options, meta)`:
|
||||
- `api.kv.get`, `set`, `ready`
|
||||
- `api.state`
|
||||
- `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.renderer`
|
||||
- `api.slots.register(plugin)`
|
||||
@@ -270,6 +270,7 @@ Command behavior:
|
||||
- `provider`
|
||||
- `path.{state,config,worktree,directory}`
|
||||
- `vcs?.branch`
|
||||
- `workspace.list()` / `workspace.get(workspaceID)`
|
||||
- `session.count()`
|
||||
- `session.diff(sessionID)`
|
||||
- `session.todo(sessionID)`
|
||||
@@ -281,6 +282,8 @@ Command behavior:
|
||||
- `lsp()`
|
||||
- `mcp()`
|
||||
- `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.renderer` exposes the raw `CliRenderer`.
|
||||
|
||||
|
||||
@@ -169,8 +169,6 @@ export namespace Bus {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
const { runPromise, runSync } = makeRuntime(Service, layer)
|
||||
|
||||
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
|
||||
|
||||
@@ -22,7 +22,7 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
||||
import { ErrorComponent } from "@tui/component/error-component"
|
||||
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
|
||||
import { ProjectProvider, useProject } from "@tui/context/project"
|
||||
import { ProjectProvider } from "@tui/context/project"
|
||||
import { useEvent } from "@tui/context/event"
|
||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||
import { StartupLoading } from "@tui/component/startup-loading"
|
||||
@@ -36,6 +36,7 @@ import { DialogHelp } from "./ui/dialog-help"
|
||||
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
|
||||
import { DialogAgent } from "@tui/component/dialog-agent"
|
||||
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 { KeybindProvider, useKeybind } from "@tui/context/keybind"
|
||||
import { ThemeProvider, useTheme } from "@tui/context/theme"
|
||||
@@ -464,6 +465,22 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
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",
|
||||
suggested: route.data.type === "session",
|
||||
|
||||
@@ -2,31 +2,25 @@ 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, createResource, createSignal, onMount } from "solid-js"
|
||||
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { DialogSessionRename } from "./dialog-session-rename"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import { useKV } from "../context/kv"
|
||||
import { createDebouncedSignal } from "../util/signal"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const project = useProject()
|
||||
const keybind = useKeybind()
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const kv = useKV()
|
||||
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
|
||||
@@ -37,24 +31,8 @@ export function DialogSessionList() {
|
||||
})
|
||||
|
||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
const sessions = createMemo(() => searchResults() ?? sync.data.session)
|
||||
|
||||
function createWorkspace() {
|
||||
dialog.replace(() => (
|
||||
<DialogWorkspaceCreate
|
||||
onSelect={(workspaceID) =>
|
||||
openWorkspaceSession({
|
||||
dialog,
|
||||
route,
|
||||
sdk,
|
||||
sync,
|
||||
toast,
|
||||
workspaceID,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
}
|
||||
const sessions = createMemo(() => searchResults() ?? sync.data.session)
|
||||
|
||||
const options = createMemo(() => {
|
||||
const today = new Date().toDateString()
|
||||
@@ -62,43 +40,6 @@ export function DialogSessionList() {
|
||||
.filter((x) => x.parentID === undefined)
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
.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)
|
||||
let category = date.toDateString()
|
||||
if (category === today) {
|
||||
@@ -112,7 +53,7 @@ export function DialogSessionList() {
|
||||
bg: isDeleting ? theme.error : undefined,
|
||||
value: x.id,
|
||||
category,
|
||||
footer,
|
||||
footer: Locale.time(x.time.updated),
|
||||
gutter: isWorking ? <Spinner /> : undefined,
|
||||
}
|
||||
})
|
||||
@@ -161,15 +102,6 @@ export function DialogSessionList() {
|
||||
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 type { Path, Workspace } from "@opencode-ai/sdk/v2"
|
||||
import type { Path } from "@opencode-ai/sdk"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
|
||||
|
||||
export const { use: useProject, provider: ProjectProvider } = createSimpleContext({
|
||||
name: "Project",
|
||||
init: () => {
|
||||
@@ -16,22 +14,17 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
|
||||
},
|
||||
instance: {
|
||||
path: {
|
||||
home: "",
|
||||
state: "",
|
||||
config: "",
|
||||
worktree: "",
|
||||
directory: sdk.directory ?? "",
|
||||
} satisfies Path,
|
||||
},
|
||||
workspace: {
|
||||
current: undefined as string | undefined,
|
||||
list: [] as Workspace[],
|
||||
status: {} as Record<string, WorkspaceStatus>,
|
||||
},
|
||||
workspace: undefined as string | undefined,
|
||||
})
|
||||
|
||||
async function sync() {
|
||||
const workspace = store.workspace.current
|
||||
const workspace = store.workspace
|
||||
const [path, project] = await Promise.all([
|
||||
sdk.client.path.get({ 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 {
|
||||
data: store,
|
||||
project() {
|
||||
@@ -79,26 +51,13 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
|
||||
},
|
||||
workspace: {
|
||||
current() {
|
||||
return store.workspace.current
|
||||
return store.workspace
|
||||
},
|
||||
set(next?: string | null) {
|
||||
const workspace = next ?? undefined
|
||||
if (store.workspace.current === workspace) return
|
||||
setStore("workspace", "current", workspace)
|
||||
if (store.workspace === workspace) return
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
ProviderListResponse,
|
||||
ProviderAuthMethod,
|
||||
VcsInfo,
|
||||
Workspace,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useProject } from "@tui/context/project"
|
||||
@@ -74,6 +75,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
[key: string]: McpResource
|
||||
}
|
||||
formatter: FormatterStatus[]
|
||||
workspaceList: Workspace[]
|
||||
vcs: VcsInfo | undefined
|
||||
}>({
|
||||
provider_next: {
|
||||
@@ -101,6 +103,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
mcp: {},
|
||||
mcp_resource: {},
|
||||
formatter: [],
|
||||
workspaceList: [],
|
||||
vcs: undefined,
|
||||
})
|
||||
|
||||
@@ -108,6 +111,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const project = useProject()
|
||||
const sdk = useSDK()
|
||||
|
||||
async function syncWorkspaces() {
|
||||
const workspace = project.workspace.current()
|
||||
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
|
||||
if (!result?.data) return
|
||||
setStore("workspaceList", reconcile(result.data))
|
||||
if (!result.data.some((item) => item.id === workspace)) {
|
||||
project.workspace.set(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
event.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case "server.instance.disposed":
|
||||
@@ -355,7 +368,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const workspace = project.workspace.current()
|
||||
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
|
||||
const sessionListPromise = sdk.client.session
|
||||
.list({ start: start })
|
||||
.list({ start: start, workspace })
|
||||
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
|
||||
|
||||
// blocking - include session.list when continuing a session
|
||||
@@ -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.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
|
||||
project.workspace.sync(),
|
||||
syncWorkspaces(),
|
||||
]).then(() => {
|
||||
setStore("status", "complete")
|
||||
})
|
||||
@@ -509,6 +522,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
fullSyncedSessions.add(sessionID)
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
list() {
|
||||
return store.workspaceList
|
||||
},
|
||||
get(workspaceID: string) {
|
||||
return store.workspaceList.find((item) => item.id === workspaceID)
|
||||
},
|
||||
sync: syncWorkspaces,
|
||||
},
|
||||
bootstrap,
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -146,6 +146,14 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
|
||||
branch: sync.data.vcs.branch,
|
||||
}
|
||||
},
|
||||
workspace: {
|
||||
list() {
|
||||
return sync.data.workspaceList
|
||||
},
|
||||
get(workspaceID) {
|
||||
return sync.workspace.get(workspaceID)
|
||||
},
|
||||
},
|
||||
session: {
|
||||
count() {
|
||||
return sync.data.session.length
|
||||
|
||||
@@ -26,7 +26,6 @@ export interface DialogSelectProps<T> {
|
||||
keybind?: {
|
||||
keybind?: Keybind.Info
|
||||
title: string
|
||||
side?: "left" | "right"
|
||||
disabled?: boolean
|
||||
onTrigger: (option: DialogSelectOption<T>) => void
|
||||
}[]
|
||||
@@ -43,7 +42,6 @@ export interface DialogSelectOption<T = any> {
|
||||
disabled?: boolean
|
||||
bg?: RGBA
|
||||
gutter?: JSX.Element
|
||||
margin?: JSX.Element
|
||||
onSelect?: (ctx: DialogContext) => void
|
||||
}
|
||||
|
||||
@@ -236,8 +234,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
props.ref?.(ref)
|
||||
|
||||
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 (
|
||||
<box gap={1} paddingBottom={1}>
|
||||
@@ -316,7 +312,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
<box
|
||||
id={JSON.stringify(option.value)}
|
||||
flexDirection="row"
|
||||
position="relative"
|
||||
onMouseMove={() => {
|
||||
setStore("input", "mouse")
|
||||
}}
|
||||
@@ -340,11 +335,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
paddingRight={3}
|
||||
gap={1}
|
||||
>
|
||||
<Show when={!current() && option.margin}>
|
||||
<box position="absolute" left={1} flexShrink={0}>
|
||||
{option.margin}
|
||||
</box>
|
||||
</Show>
|
||||
<Option
|
||||
title={option.title}
|
||||
footer={flatten() ? (option.category ?? option.footer) : option.footer}
|
||||
@@ -363,38 +353,17 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
</scrollbox>
|
||||
</Show>
|
||||
<Show when={keybinds().length} fallback={<box flexShrink={0} />}>
|
||||
<box
|
||||
paddingRight={2}
|
||||
paddingLeft={4}
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
flexShrink={0}
|
||||
paddingTop={1}
|
||||
>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<For each={left()}>
|
||||
{(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 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 paddingRight={2} paddingLeft={4} flexDirection="row" gap={2} flexShrink={0} paddingTop={1}>
|
||||
<For each={keybinds()}>
|
||||
{(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>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
@@ -5,9 +5,7 @@ import { Database, eq } from "@/storage/db"
|
||||
import { Project } from "@/project/project"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { Log } from "@/util/log"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { WorkspaceTable } from "./workspace.sql"
|
||||
import { getAdaptor } from "./adaptors"
|
||||
@@ -16,18 +14,6 @@ import { WorkspaceID } from "./schema"
|
||||
import { parseSSE } from "./sse"
|
||||
|
||||
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 = {
|
||||
Ready: BusEvent.define(
|
||||
"workspace.ready",
|
||||
@@ -41,9 +27,13 @@ export namespace Workspace {
|
||||
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 {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -95,9 +85,6 @@ export namespace Workspace {
|
||||
})
|
||||
|
||||
await adaptor.create(config)
|
||||
|
||||
startSync(info)
|
||||
|
||||
return info
|
||||
})
|
||||
|
||||
@@ -105,24 +92,18 @@ export namespace Workspace {
|
||||
const rows = Database.use((db) =>
|
||||
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))
|
||||
for (const space of spaces) startSync(space)
|
||||
return spaces
|
||||
return rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
|
||||
export const get = fn(WorkspaceID.zod, async (id) => {
|
||||
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
|
||||
if (!row) return
|
||||
const space = fromRow(row)
|
||||
startSync(space)
|
||||
return space
|
||||
return fromRow(row)
|
||||
})
|
||||
|
||||
export const remove = fn(WorkspaceID.zod, async (id) => {
|
||||
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
|
||||
if (row) {
|
||||
stopSync(id)
|
||||
|
||||
const info = fromRow(row)
|
||||
const adaptor = await getAdaptor(row.type)
|
||||
adaptor.remove(info)
|
||||
@@ -130,100 +111,58 @@ export namespace Workspace {
|
||||
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" })
|
||||
|
||||
async function workspaceEventLoop(space: Info, signal: AbortSignal) {
|
||||
log.info("starting sync: " + space.id)
|
||||
|
||||
while (!signal.aborted) {
|
||||
log.info("connecting to sync: " + space.id)
|
||||
|
||||
setStatus(space.id, "connecting")
|
||||
async function workspaceEventLoop(space: Info, stop: AbortSignal) {
|
||||
while (!stop.aborted) {
|
||||
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) => {
|
||||
setStatus(space.id, "error", String(err))
|
||||
return undefined
|
||||
const baseURL = String(target.url).replace(/\/?$/, "/")
|
||||
|
||||
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)
|
||||
continue
|
||||
}
|
||||
setStatus(space.id, "connected")
|
||||
await parseSSE(res.body, signal, (evt) => {
|
||||
const event = evt as SyncEvent.SerializedEvent
|
||||
|
||||
try {
|
||||
if (!event.type.startsWith("server.")) {
|
||||
SyncEvent.replay(event)
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn("failed to replay sync event", {
|
||||
workspaceID: space.id,
|
||||
error: err,
|
||||
})
|
||||
}
|
||||
})
|
||||
setStatus(space.id, "disconnected")
|
||||
log.info("disconnected to sync: " + space.id)
|
||||
// await parseSSE(res.body, stop, (event) => {
|
||||
// GlobalBus.emit("event", {
|
||||
// directory: space.id,
|
||||
// payload: event,
|
||||
// })
|
||||
// })
|
||||
|
||||
// Wait 250ms and retry if SSE connection fails
|
||||
await sleep(250)
|
||||
}
|
||||
}
|
||||
|
||||
function startSync(space: Info) {
|
||||
if (space.type === "worktree") {
|
||||
void Filesystem.exists(space.directory!).then((exists) => {
|
||||
setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist")
|
||||
})
|
||||
return
|
||||
}
|
||||
export function startSyncing(project: Project.Info) {
|
||||
const stop = new AbortController()
|
||||
const spaces = list(project).filter((space) => space.type !== "worktree")
|
||||
|
||||
if (aborts.has(space.id)) return
|
||||
const abort = new AbortController()
|
||||
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", {
|
||||
workspaceID: space.id,
|
||||
error,
|
||||
spaces.forEach((space) => {
|
||||
void workspaceEventLoop(space, stop.signal).catch((error) => {
|
||||
log.warn("workspace sync listener failed", {
|
||||
workspaceID: space.id,
|
||||
error,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function stopSync(id: WorkspaceID) {
|
||||
aborts.get(id)?.abort()
|
||||
aborts.delete(id)
|
||||
connections.delete(id)
|
||||
return {
|
||||
async stop() {
|
||||
stop.abort()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { Layer, ManagedRuntime } from "effect"
|
||||
import { memoMap } from "./run-service"
|
||||
import { Observability } from "./oltp"
|
||||
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { Bus } from "@/bus"
|
||||
import { Auth } from "@/auth"
|
||||
import { Account } from "@/account"
|
||||
import { Config } from "@/config/config"
|
||||
import { Git } from "@/git"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
import { FileTime } from "@/file/time"
|
||||
import { File } from "@/file"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ProviderAuth } from "@/provider/auth"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Skill } from "@/skill"
|
||||
import { Discovery } from "@/skill/discovery"
|
||||
import { Question } from "@/question"
|
||||
import { Permission } from "@/permission"
|
||||
import { Todo } from "@/session/todo"
|
||||
import { Session } from "@/session"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
import { SessionRunState } from "@/session/run-state"
|
||||
import { SessionProcessor } from "@/session/processor"
|
||||
import { SessionCompaction } from "@/session/compaction"
|
||||
import { SessionRevert } from "@/session/revert"
|
||||
import { SessionSummary } from "@/session/summary"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
import { Instruction } from "@/session/instruction"
|
||||
import { LLM } from "@/session/llm"
|
||||
import { LSP } from "@/lsp"
|
||||
import { MCP } from "@/mcp"
|
||||
import { McpAuth } from "@/mcp/auth"
|
||||
import { Command } from "@/command"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Format } from "@/format"
|
||||
import { Project } from "@/project/project"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Pty } from "@/pty"
|
||||
import { Installation } from "@/installation"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { SessionShare } from "@/share/session"
|
||||
|
||||
export const AppLayer = Layer.mergeAll(
|
||||
Observability.layer,
|
||||
AppFileSystem.defaultLayer,
|
||||
Bus.defaultLayer,
|
||||
Auth.defaultLayer,
|
||||
Account.defaultLayer,
|
||||
Config.defaultLayer,
|
||||
Git.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
FileTime.defaultLayer,
|
||||
File.defaultLayer,
|
||||
FileWatcher.defaultLayer,
|
||||
Storage.defaultLayer,
|
||||
Snapshot.defaultLayer,
|
||||
Plugin.defaultLayer,
|
||||
Provider.defaultLayer,
|
||||
ProviderAuth.defaultLayer,
|
||||
Agent.defaultLayer,
|
||||
Skill.defaultLayer,
|
||||
Discovery.defaultLayer,
|
||||
Question.defaultLayer,
|
||||
Permission.defaultLayer,
|
||||
Todo.defaultLayer,
|
||||
Session.defaultLayer,
|
||||
SessionStatus.defaultLayer,
|
||||
SessionRunState.defaultLayer,
|
||||
SessionProcessor.defaultLayer,
|
||||
SessionCompaction.defaultLayer,
|
||||
SessionRevert.defaultLayer,
|
||||
SessionSummary.defaultLayer,
|
||||
SessionPrompt.defaultLayer,
|
||||
Instruction.defaultLayer,
|
||||
LLM.defaultLayer,
|
||||
LSP.defaultLayer,
|
||||
MCP.defaultLayer,
|
||||
McpAuth.defaultLayer,
|
||||
Command.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
ToolRegistry.defaultLayer,
|
||||
Format.defaultLayer,
|
||||
Project.defaultLayer,
|
||||
Vcs.defaultLayer,
|
||||
Worktree.defaultLayer,
|
||||
Pty.defaultLayer,
|
||||
Installation.defaultLayer,
|
||||
ShareNext.defaultLayer,
|
||||
SessionShare.defaultLayer,
|
||||
)
|
||||
|
||||
export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap })
|
||||
@@ -8,7 +8,7 @@ import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
|
||||
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 {
|
||||
const ctx = Instance.current
|
||||
const workspaceID = WorkspaceContext.workspaceID
|
||||
|
||||
@@ -3,17 +3,10 @@ import path from "path"
|
||||
import { Global } from "../global"
|
||||
import fs from "fs/promises"
|
||||
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 { lazy } from "../util/lazy"
|
||||
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { AppFileSystem } from "../filesystem"
|
||||
import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
import { text } from "node:stream/consumers"
|
||||
@@ -281,69 +274,6 @@ export namespace Ripgrep {
|
||||
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 }) {
|
||||
log.info("tree", input)
|
||||
const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal }))
|
||||
|
||||
@@ -51,13 +51,6 @@ export namespace Format {
|
||||
formatters[item.name] = item
|
||||
}
|
||||
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) {
|
||||
delete formatters[name]
|
||||
continue
|
||||
|
||||
@@ -141,7 +141,7 @@ export namespace McpAuth {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@ export namespace Vcs {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
const defaultLayer = layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
|
||||
@@ -53,7 +53,7 @@ export namespace ProviderTransform {
|
||||
): ModelMessage[] {
|
||||
// Anthropic rejects messages with empty content - filter out empty string messages
|
||||
// and remove empty text/reasoning parts from array content
|
||||
if (model.api.npm === "@ai-sdk/amazon-bedrock") {
|
||||
if (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/amazon-bedrock") {
|
||||
msgs = msgs
|
||||
.map((msg) => {
|
||||
if (typeof msg.content === "string") {
|
||||
|
||||
@@ -359,7 +359,7 @@ export namespace Pty {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
|
||||
const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
||||
@@ -29,20 +29,13 @@ function local(method: string, path: string) {
|
||||
return false
|
||||
}
|
||||
|
||||
function getSessionID(url: URL) {
|
||||
async function getSessionWorkspace(url: URL) {
|
||||
if (url.pathname === "/session/status") return null
|
||||
|
||||
const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
|
||||
if (!id) return null
|
||||
|
||||
return SessionID.make(id)
|
||||
}
|
||||
|
||||
async function getSessionWorkspace(url: URL) {
|
||||
const id = getSessionID(url)
|
||||
if (!id) return null
|
||||
|
||||
const session = await Session.get(id).catch(() => undefined)
|
||||
const session = await Session.get(SessionID.make(id)).catch(() => undefined)
|
||||
return session?.workspaceID
|
||||
}
|
||||
|
||||
@@ -78,18 +71,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||
}
|
||||
|
||||
const workspace = await Workspace.get(WorkspaceID.make(workspaceID))
|
||||
|
||||
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}`, {
|
||||
status: 500,
|
||||
headers: {
|
||||
|
||||
@@ -62,28 +62,6 @@ export const WorkspaceRoutes = lazy(() =>
|
||||
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(
|
||||
"/:id",
|
||||
describeRoute({
|
||||
|
||||
@@ -413,35 +413,26 @@ export namespace Session {
|
||||
})
|
||||
|
||||
const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
|
||||
const ctx = yield* InstanceState.context
|
||||
const rows = yield* db((d) =>
|
||||
d
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(and(eq(SessionTable.parent_id, parentID)))
|
||||
.where(and(eq(SessionTable.project_id, ctx.project.id), eq(SessionTable.parent_id, parentID)))
|
||||
.all(),
|
||||
)
|
||||
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 {
|
||||
const session = yield* get(sessionID)
|
||||
const kids = yield* children(sessionID)
|
||||
for (const child of kids) {
|
||||
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(() => {
|
||||
SyncEvent.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance })
|
||||
SyncEvent.run(Event.Deleted, { sessionID, info: session })
|
||||
SyncEvent.remove(sessionID)
|
||||
})
|
||||
} catch (e) {
|
||||
|
||||
@@ -245,7 +245,7 @@ export namespace SessionProcessor {
|
||||
|
||||
case "reasoning-end":
|
||||
if (!(value.id in ctx.reasoningMap)) return
|
||||
ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text
|
||||
ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text.trimEnd()
|
||||
ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() }
|
||||
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
|
||||
yield* session.updatePart(ctx.reasoningMap[value.id])
|
||||
@@ -425,7 +425,7 @@ export namespace SessionProcessor {
|
||||
|
||||
case "text-end":
|
||||
if (!ctx.currentText) return
|
||||
ctx.currentText.text = ctx.currentText.text
|
||||
ctx.currentText.text = ctx.currentText.text.trimEnd()
|
||||
ctx.currentText.text = (yield* plugin.trigger(
|
||||
"experimental.text.complete",
|
||||
{
|
||||
|
||||
@@ -46,7 +46,8 @@ import { Process } from "@/util/process"
|
||||
import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { TaskTool, type TaskPromptOps } from "@/tool/task"
|
||||
import { TaskTool } from "@/tool/task"
|
||||
import { Config } from "@/config/config"
|
||||
import { SessionRunState } from "./run-state"
|
||||
|
||||
// @ts-ignore
|
||||
@@ -88,6 +89,7 @@ export namespace SessionPrompt {
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const config = yield* Config.Service
|
||||
const permission = yield* Permission.Service
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const mcp = yield* MCP.Service
|
||||
@@ -140,6 +142,17 @@ export namespace SessionPrompt {
|
||||
return parts
|
||||
})
|
||||
|
||||
let prompt!: Interface["prompt"]
|
||||
|
||||
const taskTool = () =>
|
||||
TaskTool.build({
|
||||
agent: agents,
|
||||
config,
|
||||
cancel: SessionPrompt.cancel,
|
||||
resolvePromptParts: SessionPrompt.resolvePromptParts,
|
||||
prompt: SessionPrompt.prompt,
|
||||
})
|
||||
|
||||
const title = Effect.fn("SessionPrompt.ensureTitle")(function* (input: {
|
||||
session: Session.Info
|
||||
history: MessageV2.WithParts[]
|
||||
@@ -356,7 +369,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
abort: options.abortSignal!,
|
||||
messageID: input.processor.message.id,
|
||||
callID: options.toolCallId,
|
||||
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps },
|
||||
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck },
|
||||
agent: input.agent.name,
|
||||
messages: input.messages,
|
||||
metadata: (val) =>
|
||||
@@ -391,6 +404,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
providerID: input.model.providerID,
|
||||
agent: input.agent,
|
||||
})) {
|
||||
const toolDef = item.id === TaskTool.id ? yield* Tool.init(taskTool()) : item
|
||||
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
|
||||
tools[item.id] = tool({
|
||||
id: item.id as any,
|
||||
@@ -405,7 +419,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
|
||||
{ args },
|
||||
)
|
||||
const result = yield* Effect.promise(() => item.execute(args, ctx))
|
||||
const result = yield* Effect.promise(() => toolDef.execute(args, ctx))
|
||||
const output = {
|
||||
...result,
|
||||
attachments: result.attachments?.map((attachment) => ({
|
||||
@@ -521,7 +535,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}) {
|
||||
const { task, model, lastUser, sessionID, session, msgs } = input
|
||||
const ctx = yield* InstanceState.context
|
||||
const { task: taskTool } = yield* registry.named()
|
||||
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
|
||||
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
@@ -578,15 +591,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}
|
||||
|
||||
let error: Error | undefined
|
||||
const taskDef = yield* Tool.init(taskTool())
|
||||
const result = yield* Effect.promise((signal) =>
|
||||
taskTool
|
||||
taskDef
|
||||
.execute(taskArgs, {
|
||||
agent: task.agent,
|
||||
messageID: assistantMessage.id,
|
||||
sessionID,
|
||||
abort: signal,
|
||||
callID: part.callID,
|
||||
extra: { bypassAgentCheck: true, promptOps },
|
||||
extra: { bypassAgentCheck: true },
|
||||
messages: msgs,
|
||||
metadata(val: { title?: string; metadata?: Record<string, any> }) {
|
||||
return Effect.runPromise(
|
||||
@@ -1267,26 +1281,24 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
return { info, parts }
|
||||
}, Effect.scoped)
|
||||
|
||||
const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.prompt")(
|
||||
function* (input: PromptInput) {
|
||||
const session = yield* sessions.get(input.sessionID)
|
||||
yield* revert.cleanup(session)
|
||||
const message = yield* createUserMessage(input)
|
||||
yield* sessions.touch(input.sessionID)
|
||||
prompt = Effect.fn("SessionPrompt.prompt")(function* (input: PromptInput) {
|
||||
const session = yield* sessions.get(input.sessionID)
|
||||
yield* revert.cleanup(session)
|
||||
const message = yield* createUserMessage(input)
|
||||
yield* sessions.touch(input.sessionID)
|
||||
|
||||
const permissions: Permission.Ruleset = []
|
||||
for (const [t, enabled] of Object.entries(input.tools ?? {})) {
|
||||
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
|
||||
}
|
||||
if (permissions.length > 0) {
|
||||
session.permission = permissions
|
||||
yield* sessions.setPermission({ sessionID: session.id, permission: permissions })
|
||||
}
|
||||
const permissions: Permission.Ruleset = []
|
||||
for (const [t, enabled] of Object.entries(input.tools ?? {})) {
|
||||
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
|
||||
}
|
||||
if (permissions.length > 0) {
|
||||
session.permission = permissions
|
||||
yield* sessions.setPermission({ sessionID: session.id, permission: permissions })
|
||||
}
|
||||
|
||||
if (input.noReply === true) return message
|
||||
return yield* loop({ sessionID: input.sessionID })
|
||||
},
|
||||
)
|
||||
if (input.noReply === true) return message
|
||||
return yield* loop({ sessionID: input.sessionID })
|
||||
})
|
||||
|
||||
const lastAssistant = (sessionID: SessionID) =>
|
||||
Effect.promise(async () => {
|
||||
@@ -1655,12 +1667,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
return result
|
||||
})
|
||||
|
||||
const promptOps: TaskPromptOps = {
|
||||
cancel: (sessionID) => Effect.runFork(cancel(sessionID)),
|
||||
resolvePromptParts: (template) => Effect.runPromise(resolvePromptParts(template)),
|
||||
prompt: (input) => Effect.runPromise(prompt(input)),
|
||||
}
|
||||
|
||||
return Service.of({
|
||||
cancel,
|
||||
prompt,
|
||||
@@ -1672,29 +1678,31 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = Layer.suspend(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(SessionRunState.defaultLayer),
|
||||
Layer.provide(SessionStatus.defaultLayer),
|
||||
Layer.provide(SessionCompaction.defaultLayer),
|
||||
Layer.provide(SessionProcessor.defaultLayer),
|
||||
Layer.provide(Command.defaultLayer),
|
||||
Layer.provide(Permission.defaultLayer),
|
||||
Layer.provide(MCP.defaultLayer),
|
||||
Layer.provide(LSP.defaultLayer),
|
||||
Layer.provide(FileTime.defaultLayer),
|
||||
Layer.provide(ToolRegistry.defaultLayer),
|
||||
Layer.provide(Truncate.defaultLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(SessionRevert.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
),
|
||||
const defaultLayer = Layer.suspend(() =>
|
||||
layer
|
||||
.pipe(
|
||||
Layer.provide(SessionRunState.defaultLayer),
|
||||
Layer.provide(SessionStatus.defaultLayer),
|
||||
Layer.provide(SessionCompaction.defaultLayer),
|
||||
Layer.provide(SessionProcessor.defaultLayer),
|
||||
Layer.provide(Command.defaultLayer),
|
||||
Layer.provide(Permission.defaultLayer),
|
||||
Layer.provide(MCP.defaultLayer),
|
||||
Layer.provide(LSP.defaultLayer),
|
||||
Layer.provide(FileTime.defaultLayer),
|
||||
Layer.provide(ToolRegistry.defaultLayer),
|
||||
Layer.provide(Truncate.defaultLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(SessionRevert.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
)
|
||||
.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)),
|
||||
)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ export namespace SyncEvent {
|
||||
// and it validets all the sequence ids
|
||||
// * 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)
|
||||
if (!def) {
|
||||
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}`)
|
||||
}
|
||||
|
||||
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]
|
||||
// This should never happen: we've enforced it via typescript in
|
||||
// the definition
|
||||
@@ -204,8 +204,6 @@ export namespace SyncEvent {
|
||||
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.
|
||||
// We need to make sure we can safely read and write with nothing
|
||||
// else changing the data from under us
|
||||
@@ -220,7 +218,7 @@ export namespace SyncEvent {
|
||||
const seq = row?.seq != null ? row.seq + 1 : 0
|
||||
|
||||
const event = { id, seq, aggregateID: agg, data }
|
||||
process(def, event, { publish })
|
||||
process(def, event, { publish: true })
|
||||
},
|
||||
{
|
||||
behavior: "immediate",
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import z from "zod"
|
||||
import * as path from "path"
|
||||
import { Effect } from "effect"
|
||||
import * as fs from "fs/promises"
|
||||
import { Tool } from "./tool"
|
||||
import { Bus } from "../bus"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Patch } from "../patch"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
import { trimDiff } from "./edit"
|
||||
import { LSP } from "../lsp"
|
||||
import { AppFileSystem } from "../filesystem"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import DESCRIPTION from "./apply_patch.txt"
|
||||
import { File } from "../file"
|
||||
import { Format } from "../format"
|
||||
@@ -19,278 +19,261 @@ const PatchParams = z.object({
|
||||
patchText: z.string().describe("The full patch text that describes all changes to be made"),
|
||||
})
|
||||
|
||||
export const ApplyPatchTool = Tool.defineEffect(
|
||||
"apply_patch",
|
||||
Effect.gen(function* () {
|
||||
const lsp = yield* LSP.Service
|
||||
const afs = yield* AppFileSystem.Service
|
||||
const format = yield* Format.Service
|
||||
export const ApplyPatchTool = Tool.define("apply_patch", {
|
||||
description: DESCRIPTION,
|
||||
parameters: PatchParams,
|
||||
async execute(params, ctx) {
|
||||
if (!params.patchText) {
|
||||
throw new Error("patchText is required")
|
||||
}
|
||||
|
||||
const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer<typeof PatchParams>, ctx: Tool.Context) {
|
||||
if (!params.patchText) {
|
||||
return yield* Effect.fail(new Error("patchText is required"))
|
||||
// Parse the patch to get hunks
|
||||
let hunks: Patch.Hunk[]
|
||||
try {
|
||||
const parseResult = Patch.parsePatch(params.patchText)
|
||||
hunks = parseResult.hunks
|
||||
} catch (error) {
|
||||
throw new Error(`apply_patch verification failed: ${error}`)
|
||||
}
|
||||
|
||||
if (hunks.length === 0) {
|
||||
const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
|
||||
if (normalized === "*** Begin Patch\n*** End Patch") {
|
||||
throw new Error("patch rejected: empty patch")
|
||||
}
|
||||
throw new Error("apply_patch verification failed: no hunks found")
|
||||
}
|
||||
|
||||
// Parse the patch to get hunks
|
||||
let hunks: Patch.Hunk[]
|
||||
try {
|
||||
const parseResult = Patch.parsePatch(params.patchText)
|
||||
hunks = parseResult.hunks
|
||||
} catch (error) {
|
||||
return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`))
|
||||
}
|
||||
// Validate file paths and check permissions
|
||||
const fileChanges: Array<{
|
||||
filePath: string
|
||||
oldContent: string
|
||||
newContent: string
|
||||
type: "add" | "update" | "delete" | "move"
|
||||
movePath?: string
|
||||
diff: string
|
||||
additions: number
|
||||
deletions: number
|
||||
}> = []
|
||||
|
||||
if (hunks.length === 0) {
|
||||
const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
|
||||
if (normalized === "*** Begin Patch\n*** End Patch") {
|
||||
return yield* Effect.fail(new Error("patch rejected: empty patch"))
|
||||
}
|
||||
return yield* Effect.fail(new Error("apply_patch verification failed: no hunks found"))
|
||||
}
|
||||
let totalDiff = ""
|
||||
|
||||
// Validate file paths and check permissions
|
||||
const fileChanges: Array<{
|
||||
filePath: string
|
||||
oldContent: string
|
||||
newContent: string
|
||||
type: "add" | "update" | "delete" | "move"
|
||||
movePath?: string
|
||||
diff: string
|
||||
additions: number
|
||||
deletions: number
|
||||
}> = []
|
||||
for (const hunk of hunks) {
|
||||
const filePath = path.resolve(Instance.directory, hunk.path)
|
||||
await assertExternalDirectory(ctx, filePath)
|
||||
|
||||
let totalDiff = ""
|
||||
switch (hunk.type) {
|
||||
case "add": {
|
||||
const oldContent = ""
|
||||
const newContent =
|
||||
hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`
|
||||
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
|
||||
|
||||
for (const hunk of hunks) {
|
||||
const filePath = path.resolve(Instance.directory, hunk.path)
|
||||
yield* assertExternalDirectoryEffect(ctx, filePath)
|
||||
|
||||
switch (hunk.type) {
|
||||
case "add": {
|
||||
const oldContent = ""
|
||||
const newContent =
|
||||
hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`
|
||||
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
|
||||
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
for (const change of diffLines(oldContent, newContent)) {
|
||||
if (change.added) additions += change.count || 0
|
||||
if (change.removed) deletions += change.count || 0
|
||||
}
|
||||
|
||||
fileChanges.push({
|
||||
filePath,
|
||||
oldContent,
|
||||
newContent,
|
||||
type: "add",
|
||||
diff,
|
||||
additions,
|
||||
deletions,
|
||||
})
|
||||
|
||||
totalDiff += diff + "\n"
|
||||
break
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
for (const change of diffLines(oldContent, newContent)) {
|
||||
if (change.added) additions += change.count || 0
|
||||
if (change.removed) deletions += change.count || 0
|
||||
}
|
||||
|
||||
case "update": {
|
||||
// Check if file exists for update
|
||||
const stats = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!stats || stats.type === "Directory") {
|
||||
return yield* Effect.fail(
|
||||
new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`),
|
||||
)
|
||||
}
|
||||
fileChanges.push({
|
||||
filePath,
|
||||
oldContent,
|
||||
newContent,
|
||||
type: "add",
|
||||
diff,
|
||||
additions,
|
||||
deletions,
|
||||
})
|
||||
|
||||
const oldContent = yield* afs.readFileString(filePath)
|
||||
let newContent = oldContent
|
||||
totalDiff += diff + "\n"
|
||||
break
|
||||
}
|
||||
|
||||
// Apply the update chunks to get new content
|
||||
try {
|
||||
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
|
||||
newContent = fileUpdate.content
|
||||
} catch (error) {
|
||||
return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`))
|
||||
}
|
||||
|
||||
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
|
||||
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
for (const change of diffLines(oldContent, newContent)) {
|
||||
if (change.added) additions += change.count || 0
|
||||
if (change.removed) deletions += change.count || 0
|
||||
}
|
||||
|
||||
const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
|
||||
yield* assertExternalDirectoryEffect(ctx, movePath)
|
||||
|
||||
fileChanges.push({
|
||||
filePath,
|
||||
oldContent,
|
||||
newContent,
|
||||
type: hunk.move_path ? "move" : "update",
|
||||
movePath,
|
||||
diff,
|
||||
additions,
|
||||
deletions,
|
||||
})
|
||||
|
||||
totalDiff += diff + "\n"
|
||||
break
|
||||
case "update": {
|
||||
// Check if file exists for update
|
||||
const stats = await fs.stat(filePath).catch(() => null)
|
||||
if (!stats || stats.isDirectory()) {
|
||||
throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`)
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
const contentToDelete = yield* afs
|
||||
.readFileString(filePath)
|
||||
.pipe(Effect.catch((error) => Effect.fail(new Error(`apply_patch verification failed: ${error}`))))
|
||||
const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, ""))
|
||||
const oldContent = await fs.readFile(filePath, "utf-8")
|
||||
let newContent = oldContent
|
||||
|
||||
const deletions = contentToDelete.split("\n").length
|
||||
|
||||
fileChanges.push({
|
||||
filePath,
|
||||
oldContent: contentToDelete,
|
||||
newContent: "",
|
||||
type: "delete",
|
||||
diff: deleteDiff,
|
||||
additions: 0,
|
||||
deletions,
|
||||
})
|
||||
|
||||
totalDiff += deleteDiff + "\n"
|
||||
break
|
||||
// Apply the update chunks to get new content
|
||||
try {
|
||||
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
|
||||
newContent = fileUpdate.content
|
||||
} catch (error) {
|
||||
throw new Error(`apply_patch verification failed: ${error}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build per-file metadata for UI rendering (used for both permission and result)
|
||||
const files = fileChanges.map((change) => ({
|
||||
filePath: change.filePath,
|
||||
relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"),
|
||||
type: change.type,
|
||||
patch: change.diff,
|
||||
additions: change.additions,
|
||||
deletions: change.deletions,
|
||||
movePath: change.movePath,
|
||||
}))
|
||||
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
|
||||
|
||||
// Check permissions if needed
|
||||
const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/"))
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: relativePaths,
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath: relativePaths.join(", "),
|
||||
diff: totalDiff,
|
||||
files,
|
||||
},
|
||||
}),
|
||||
)
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
for (const change of diffLines(oldContent, newContent)) {
|
||||
if (change.added) additions += change.count || 0
|
||||
if (change.removed) deletions += change.count || 0
|
||||
}
|
||||
|
||||
// Apply the changes
|
||||
const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = []
|
||||
const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
|
||||
await assertExternalDirectory(ctx, movePath)
|
||||
|
||||
for (const change of fileChanges) {
|
||||
const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath)
|
||||
switch (change.type) {
|
||||
case "add":
|
||||
// Create parent directories (recursive: true is safe on existing/root dirs)
|
||||
fileChanges.push({
|
||||
filePath,
|
||||
oldContent,
|
||||
newContent,
|
||||
type: hunk.move_path ? "move" : "update",
|
||||
movePath,
|
||||
diff,
|
||||
additions,
|
||||
deletions,
|
||||
})
|
||||
|
||||
yield* afs.writeWithDirs(change.filePath, change.newContent)
|
||||
updates.push({ file: change.filePath, event: "add" })
|
||||
break
|
||||
|
||||
case "update":
|
||||
yield* afs.writeWithDirs(change.filePath, change.newContent)
|
||||
updates.push({ file: change.filePath, event: "change" })
|
||||
break
|
||||
|
||||
case "move":
|
||||
if (change.movePath) {
|
||||
// Create parent directories (recursive: true is safe on existing/root dirs)
|
||||
|
||||
yield* afs.writeWithDirs(change.movePath!, change.newContent)
|
||||
yield* afs.remove(change.filePath)
|
||||
updates.push({ file: change.filePath, event: "unlink" })
|
||||
updates.push({ file: change.movePath, event: "add" })
|
||||
}
|
||||
break
|
||||
|
||||
case "delete":
|
||||
yield* afs.remove(change.filePath)
|
||||
updates.push({ file: change.filePath, event: "unlink" })
|
||||
break
|
||||
totalDiff += diff + "\n"
|
||||
break
|
||||
}
|
||||
|
||||
if (edited) {
|
||||
yield* format.file(edited)
|
||||
Bus.publish(File.Event.Edited, { file: edited })
|
||||
case "delete": {
|
||||
const contentToDelete = await fs.readFile(filePath, "utf-8").catch((error) => {
|
||||
throw new Error(`apply_patch verification failed: ${error}`)
|
||||
})
|
||||
const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, ""))
|
||||
|
||||
const deletions = contentToDelete.split("\n").length
|
||||
|
||||
fileChanges.push({
|
||||
filePath,
|
||||
oldContent: contentToDelete,
|
||||
newContent: "",
|
||||
type: "delete",
|
||||
diff: deleteDiff,
|
||||
additions: 0,
|
||||
deletions,
|
||||
})
|
||||
|
||||
totalDiff += deleteDiff + "\n"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Publish file change events
|
||||
for (const update of updates) {
|
||||
Bus.publish(FileWatcher.Event.Updated, update)
|
||||
}
|
||||
// Build per-file metadata for UI rendering (used for both permission and result)
|
||||
const files = fileChanges.map((change) => ({
|
||||
filePath: change.filePath,
|
||||
relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"),
|
||||
type: change.type,
|
||||
patch: change.diff,
|
||||
additions: change.additions,
|
||||
deletions: change.deletions,
|
||||
movePath: change.movePath,
|
||||
}))
|
||||
|
||||
// Notify LSP of file changes and collect diagnostics
|
||||
for (const change of fileChanges) {
|
||||
if (change.type === "delete") continue
|
||||
const target = change.movePath ?? change.filePath
|
||||
yield* lsp.touchFile(target, true)
|
||||
}
|
||||
const diagnostics = yield* lsp.diagnostics()
|
||||
|
||||
// Generate output summary
|
||||
const summaryLines = fileChanges.map((change) => {
|
||||
if (change.type === "add") {
|
||||
return `A ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}`
|
||||
}
|
||||
if (change.type === "delete") {
|
||||
return `D ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}`
|
||||
}
|
||||
const target = change.movePath ?? change.filePath
|
||||
return `M ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}`
|
||||
})
|
||||
let output = `Success. Updated the following files:\n${summaryLines.join("\n")}`
|
||||
|
||||
// Report LSP errors for changed files
|
||||
const MAX_DIAGNOSTICS_PER_FILE = 20
|
||||
for (const change of fileChanges) {
|
||||
if (change.type === "delete") continue
|
||||
const target = change.movePath ?? change.filePath
|
||||
const normalized = AppFileSystem.normalizePath(target)
|
||||
const issues = diagnostics[normalized] ?? []
|
||||
const errors = issues.filter((item) => item.severity === 1)
|
||||
if (errors.length > 0) {
|
||||
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
||||
const suffix =
|
||||
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
|
||||
output += `\n\nLSP errors detected in ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}, please fix:\n<diagnostics file="${target}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: output,
|
||||
metadata: {
|
||||
diff: totalDiff,
|
||||
files,
|
||||
diagnostics,
|
||||
},
|
||||
output,
|
||||
}
|
||||
// Check permissions if needed
|
||||
const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/"))
|
||||
await ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: relativePaths,
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath: relativePaths.join(", "),
|
||||
diff: totalDiff,
|
||||
files,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: PatchParams,
|
||||
async execute(params: z.infer<typeof PatchParams>, ctx) {
|
||||
return Effect.runPromise(run(params, ctx).pipe(Effect.orDie))
|
||||
},
|
||||
// Apply the changes
|
||||
const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = []
|
||||
|
||||
for (const change of fileChanges) {
|
||||
const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath)
|
||||
switch (change.type) {
|
||||
case "add":
|
||||
// Create parent directories (recursive: true is safe on existing/root dirs)
|
||||
await fs.mkdir(path.dirname(change.filePath), { recursive: true })
|
||||
await fs.writeFile(change.filePath, change.newContent, "utf-8")
|
||||
updates.push({ file: change.filePath, event: "add" })
|
||||
break
|
||||
|
||||
case "update":
|
||||
await fs.writeFile(change.filePath, change.newContent, "utf-8")
|
||||
updates.push({ file: change.filePath, event: "change" })
|
||||
break
|
||||
|
||||
case "move":
|
||||
if (change.movePath) {
|
||||
// Create parent directories (recursive: true is safe on existing/root dirs)
|
||||
await fs.mkdir(path.dirname(change.movePath), { recursive: true })
|
||||
await fs.writeFile(change.movePath, change.newContent, "utf-8")
|
||||
await fs.unlink(change.filePath)
|
||||
updates.push({ file: change.filePath, event: "unlink" })
|
||||
updates.push({ file: change.movePath, event: "add" })
|
||||
}
|
||||
break
|
||||
|
||||
case "delete":
|
||||
await fs.unlink(change.filePath)
|
||||
updates.push({ file: change.filePath, event: "unlink" })
|
||||
break
|
||||
}
|
||||
|
||||
if (edited) {
|
||||
await Format.file(edited)
|
||||
Bus.publish(File.Event.Edited, { file: edited })
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// Publish file change events
|
||||
for (const update of updates) {
|
||||
await Bus.publish(FileWatcher.Event.Updated, update)
|
||||
}
|
||||
|
||||
// Notify LSP of file changes and collect diagnostics
|
||||
for (const change of fileChanges) {
|
||||
if (change.type === "delete") continue
|
||||
const target = change.movePath ?? change.filePath
|
||||
await LSP.touchFile(target, true)
|
||||
}
|
||||
const diagnostics = await LSP.diagnostics()
|
||||
|
||||
// Generate output summary
|
||||
const summaryLines = fileChanges.map((change) => {
|
||||
if (change.type === "add") {
|
||||
return `A ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}`
|
||||
}
|
||||
if (change.type === "delete") {
|
||||
return `D ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}`
|
||||
}
|
||||
const target = change.movePath ?? change.filePath
|
||||
return `M ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}`
|
||||
})
|
||||
let output = `Success. Updated the following files:\n${summaryLines.join("\n")}`
|
||||
|
||||
// Report LSP errors for changed files
|
||||
const MAX_DIAGNOSTICS_PER_FILE = 20
|
||||
for (const change of fileChanges) {
|
||||
if (change.type === "delete") continue
|
||||
const target = change.movePath ?? change.filePath
|
||||
const normalized = Filesystem.normalizePath(target)
|
||||
const issues = diagnostics[normalized] ?? []
|
||||
const errors = issues.filter((item) => item.severity === 1)
|
||||
if (errors.length > 0) {
|
||||
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
||||
const suffix =
|
||||
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
|
||||
output += `\n\nLSP errors detected in ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}, please fix:\n<diagnostics file="${target}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: output,
|
||||
metadata: {
|
||||
diff: totalDiff,
|
||||
files,
|
||||
diagnostics,
|
||||
},
|
||||
output,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -8,7 +8,8 @@ import { Instance } from "../project/instance"
|
||||
import { lazy } from "@/util/lazy"
|
||||
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 { Flag } from "@/flag/flag"
|
||||
import { Shell } from "@/shell/shell"
|
||||
@@ -16,9 +17,9 @@ import { Shell } from "@/shell/shell"
|
||||
import { BashArity } from "@/permission/arity"
|
||||
import { Truncate } from "./truncate"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { Cause, Effect, Exit, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
|
||||
const MAX_METADATA_LENGTH = 30_000
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!ps) {
|
||||
return list
|
||||
@@ -209,43 +238,78 @@ function pathArgs(list: Part[], ps: boolean) {
|
||||
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) {
|
||||
if (text.length <= MAX_METADATA_LENGTH) return text
|
||||
return text.slice(0, MAX_METADATA_LENGTH) + "\n\n..."
|
||||
}
|
||||
|
||||
const parse = Effect.fn("BashTool.parse")(function* (command: string, ps: boolean) {
|
||||
const tree = yield* Effect.promise(() => parser().then((p) => (ps ? p.ps : p.bash).parse(command)))
|
||||
async function parse(command: string, ps: boolean) {
|
||||
const tree = await parser().then((p) => (ps ? p.ps : p.bash).parse(command))
|
||||
if (!tree) throw new Error("Failed to parse command")
|
||||
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) {
|
||||
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, "*")
|
||||
})
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "external_directory",
|
||||
patterns: globs,
|
||||
always: globs,
|
||||
metadata: {},
|
||||
}),
|
||||
)
|
||||
await ctx.ask({
|
||||
permission: "external_directory",
|
||||
patterns: globs,
|
||||
always: globs,
|
||||
metadata: {},
|
||||
})
|
||||
}
|
||||
|
||||
if (scan.patterns.size === 0) return
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "bash",
|
||||
patterns: Array.from(scan.patterns),
|
||||
always: Array.from(scan.always),
|
||||
metadata: {},
|
||||
}),
|
||||
)
|
||||
})
|
||||
await ctx.ask({
|
||||
permission: "bash",
|
||||
patterns: Array.from(scan.patterns),
|
||||
always: Array.from(scan.always),
|
||||
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) {
|
||||
if (process.platform === "win32" && PS.has(name)) {
|
||||
@@ -266,6 +330,100 @@ function cmd(shell: string, name: string, command: string, cwd: string, env: Nod
|
||||
})
|
||||
}
|
||||
|
||||
async function run(
|
||||
input: {
|
||||
shell: string
|
||||
name: string
|
||||
command: string
|
||||
cwd: string
|
||||
env: NodeJS.ProcessEnv
|
||||
timeout: number
|
||||
description: string
|
||||
},
|
||||
ctx: Tool.Context,
|
||||
) {
|
||||
let output = ""
|
||||
let expired = false
|
||||
let aborted = false
|
||||
|
||||
ctx.metadata({
|
||||
metadata: {
|
||||
output: "",
|
||||
description: input.description,
|
||||
},
|
||||
})
|
||||
|
||||
const exit = await CrossSpawnSpawner.runPromiseExit((spawner) =>
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env))
|
||||
|
||||
yield* Effect.forkScoped(
|
||||
Stream.runForEach(Stream.decodeText(handle.all), (chunk) =>
|
||||
Effect.sync(() => {
|
||||
output += chunk
|
||||
ctx.metadata({
|
||||
metadata: {
|
||||
output: preview(output),
|
||||
description: input.description,
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const abort = Effect.callback<void>((resume) => {
|
||||
if (ctx.abort.aborted) return resume(Effect.void)
|
||||
const handler = () => resume(Effect.void)
|
||||
ctx.abort.addEventListener("abort", handler, { once: true })
|
||||
return Effect.sync(() => ctx.abort.removeEventListener("abort", handler))
|
||||
})
|
||||
|
||||
const timeout = Effect.sleep(`${input.timeout + 100} millis`)
|
||||
|
||||
const exit = yield* Effect.raceAll([
|
||||
handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))),
|
||||
abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))),
|
||||
timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))),
|
||||
])
|
||||
|
||||
if (exit.kind === "abort") {
|
||||
aborted = true
|
||||
yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
|
||||
}
|
||||
if (exit.kind === "timeout") {
|
||||
expired = true
|
||||
yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
return exit.kind === "exit" ? exit.code : null
|
||||
}).pipe(Effect.scoped, Effect.orDie),
|
||||
)
|
||||
|
||||
let code: number | null = null
|
||||
if (Exit.isSuccess(exit)) {
|
||||
code = exit.value
|
||||
} else if (!Cause.hasInterruptsOnly(exit.cause)) {
|
||||
throw Cause.squash(exit.cause)
|
||||
}
|
||||
|
||||
const meta: string[] = []
|
||||
if (expired) meta.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
|
||||
if (aborted) meta.push("User aborted the command")
|
||||
if (meta.length > 0) {
|
||||
output += "\n\n<bash_metadata>\n" + meta.join("\n") + "\n</bash_metadata>"
|
||||
}
|
||||
|
||||
return {
|
||||
title: input.description,
|
||||
metadata: {
|
||||
output: preview(output),
|
||||
exit: code,
|
||||
description: input.description,
|
||||
},
|
||||
output,
|
||||
}
|
||||
}
|
||||
|
||||
const parser = lazy(async () => {
|
||||
const { Parser } = await import("web-tree-sitter")
|
||||
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
|
||||
@@ -294,218 +452,47 @@ const parser = lazy(async () => {
|
||||
})
|
||||
|
||||
// TODO: we may wanna rename this tool so it works better on other shells
|
||||
export const BashTool = Tool.defineEffect(
|
||||
"bash",
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
export const BashTool = Tool.define("bash", async () => {
|
||||
const shell = Shell.acceptable()
|
||||
const name = Shell.name(shell)
|
||||
const chain =
|
||||
name === "powershell"
|
||||
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
|
||||
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
|
||||
log.info("bash tool using shell", { shell })
|
||||
|
||||
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 {
|
||||
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
|
||||
.replaceAll("${os}", process.platform)
|
||||
.replaceAll("${shell}", name)
|
||||
.replaceAll("${chaining}", chain)
|
||||
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
|
||||
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
|
||||
parameters: Parameters,
|
||||
async execute(params, ctx) {
|
||||
const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory
|
||||
if (params.timeout !== undefined && params.timeout < 0) {
|
||||
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
|
||||
}
|
||||
return path.resolve(root, text)
|
||||
})
|
||||
const timeout = params.timeout ?? DEFAULT_TIMEOUT
|
||||
const ps = PS.has(name)
|
||||
const root = await parse(params.command, ps)
|
||||
const scan = await collect(root, cwd, ps, shell)
|
||||
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
|
||||
await ask(ctx, scan)
|
||||
|
||||
const argPath = Effect.fn("BashTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) {
|
||||
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 run(
|
||||
{
|
||||
shell,
|
||||
name,
|
||||
command: params.command,
|
||||
cwd,
|
||||
env: await shellEnv(ctx, cwd),
|
||||
timeout,
|
||||
description: params.description,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
return {
|
||||
...process.env,
|
||||
...extra.env,
|
||||
}
|
||||
})
|
||||
|
||||
const run = Effect.fn("BashTool.run")(function* (
|
||||
input: {
|
||||
shell: string
|
||||
name: string
|
||||
command: string
|
||||
cwd: string
|
||||
env: NodeJS.ProcessEnv
|
||||
timeout: number
|
||||
description: string
|
||||
},
|
||||
ctx: Tool.Context,
|
||||
) {
|
||||
let output = ""
|
||||
let expired = false
|
||||
let aborted = false
|
||||
|
||||
ctx.metadata({
|
||||
metadata: {
|
||||
output: "",
|
||||
description: input.description,
|
||||
},
|
||||
})
|
||||
|
||||
const code: number | null = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env))
|
||||
|
||||
yield* Effect.forkScoped(
|
||||
Stream.runForEach(Stream.decodeText(handle.all), (chunk) =>
|
||||
Effect.sync(() => {
|
||||
output += chunk
|
||||
ctx.metadata({
|
||||
metadata: {
|
||||
output: preview(output),
|
||||
description: input.description,
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const abort = Effect.callback<void>((resume) => {
|
||||
if (ctx.abort.aborted) return resume(Effect.void)
|
||||
const handler = () => resume(Effect.void)
|
||||
ctx.abort.addEventListener("abort", handler, { once: true })
|
||||
return Effect.sync(() => ctx.abort.removeEventListener("abort", handler))
|
||||
})
|
||||
|
||||
const timeout = Effect.sleep(`${input.timeout + 100} millis`)
|
||||
|
||||
const exit = yield* Effect.raceAll([
|
||||
handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))),
|
||||
abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))),
|
||||
timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))),
|
||||
])
|
||||
|
||||
if (exit.kind === "abort") {
|
||||
aborted = true
|
||||
yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
|
||||
}
|
||||
if (exit.kind === "timeout") {
|
||||
expired = true
|
||||
yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
return exit.kind === "exit" ? exit.code : null
|
||||
}),
|
||||
).pipe(Effect.orDie)
|
||||
|
||||
const meta: string[] = []
|
||||
if (expired) meta.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
|
||||
if (aborted) meta.push("User aborted the command")
|
||||
if (meta.length > 0) {
|
||||
output += "\n\n<bash_metadata>\n" + meta.join("\n") + "\n</bash_metadata>"
|
||||
}
|
||||
|
||||
return {
|
||||
title: input.description,
|
||||
metadata: {
|
||||
output: preview(output),
|
||||
exit: code,
|
||||
description: input.description,
|
||||
},
|
||||
output,
|
||||
}
|
||||
})
|
||||
|
||||
return async () => {
|
||||
const shell = Shell.acceptable()
|
||||
const name = Shell.name(shell)
|
||||
const chain =
|
||||
name === "powershell"
|
||||
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
|
||||
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
|
||||
log.info("bash tool using shell", { shell })
|
||||
|
||||
return {
|
||||
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
|
||||
.replaceAll("${os}", process.platform)
|
||||
.replaceAll("${shell}", name)
|
||||
.replaceAll("${chaining}", chain)
|
||||
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
|
||||
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const cwd = params.workdir
|
||||
? yield* resolvePath(params.workdir, Instance.directory, shell)
|
||||
: Instance.directory
|
||||
if (params.timeout !== undefined && params.timeout < 0) {
|
||||
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
|
||||
}
|
||||
const timeout = params.timeout ?? DEFAULT_TIMEOUT
|
||||
const ps = PS.has(name)
|
||||
const root = yield* parse(params.command, ps)
|
||||
const scan = yield* collect(root, cwd, ps, shell)
|
||||
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
|
||||
yield* ask(ctx, scan)
|
||||
|
||||
return yield* run(
|
||||
{
|
||||
shell,
|
||||
name,
|
||||
command: params.command,
|
||||
cwd,
|
||||
env: yield* shellEnv(ctx, cwd),
|
||||
timeout,
|
||||
description: params.description,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
}).pipe(Effect.orDie, Effect.runPromise),
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
import z from "zod"
|
||||
import * as path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Tool } from "./tool"
|
||||
import { LSP } from "../lsp"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
@@ -18,7 +17,7 @@ import { FileTime } from "../file/time"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
|
||||
const MAX_DIAGNOSTICS_PER_FILE = 20
|
||||
|
||||
@@ -35,161 +34,136 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string {
|
||||
return text.replaceAll("\n", "\r\n")
|
||||
}
|
||||
|
||||
const Parameters = z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
oldString: z.string().describe("The text to replace"),
|
||||
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
|
||||
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
|
||||
})
|
||||
export const EditTool = Tool.define("edit", {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
oldString: z.string().describe("The text to replace"),
|
||||
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
|
||||
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
if (!params.filePath) {
|
||||
throw new Error("filePath is required")
|
||||
}
|
||||
|
||||
export const EditTool = Tool.defineEffect(
|
||||
"edit",
|
||||
Effect.gen(function* () {
|
||||
const lsp = yield* LSP.Service
|
||||
const filetime = yield* FileTime.Service
|
||||
if (params.oldString === params.newString) {
|
||||
throw new Error("No changes to apply: oldString and newString are identical.")
|
||||
}
|
||||
|
||||
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
|
||||
await assertExternalDirectory(ctx, filePath)
|
||||
|
||||
let diff = ""
|
||||
let contentOld = ""
|
||||
let contentNew = ""
|
||||
await FileTime.withLock(filePath, async () => {
|
||||
if (params.oldString === "") {
|
||||
const existed = await Filesystem.exists(filePath)
|
||||
contentNew = params.newString
|
||||
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
|
||||
await ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: [path.relative(Instance.worktree, filePath)],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath: filePath,
|
||||
diff,
|
||||
},
|
||||
})
|
||||
await Filesystem.write(filePath, params.newString)
|
||||
await Format.file(filePath)
|
||||
Bus.publish(File.Event.Edited, { file: filePath })
|
||||
await Bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filePath,
|
||||
event: existed ? "change" : "add",
|
||||
})
|
||||
await FileTime.read(ctx.sessionID, filePath)
|
||||
return
|
||||
}
|
||||
|
||||
const stats = Filesystem.stat(filePath)
|
||||
if (!stats) throw new Error(`File ${filePath} not found`)
|
||||
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
|
||||
await FileTime.assert(ctx.sessionID, filePath)
|
||||
contentOld = await Filesystem.readText(filePath)
|
||||
|
||||
const ending = detectLineEnding(contentOld)
|
||||
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
|
||||
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
|
||||
|
||||
contentNew = replace(contentOld, old, next, params.replaceAll)
|
||||
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
|
||||
)
|
||||
await ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: [path.relative(Instance.worktree, filePath)],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath: filePath,
|
||||
diff,
|
||||
},
|
||||
})
|
||||
|
||||
await Filesystem.write(filePath, contentNew)
|
||||
await Format.file(filePath)
|
||||
Bus.publish(File.Event.Edited, { file: filePath })
|
||||
await Bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filePath,
|
||||
event: "change",
|
||||
})
|
||||
contentNew = await Filesystem.readText(filePath)
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
|
||||
)
|
||||
await FileTime.read(ctx.sessionID, filePath)
|
||||
})
|
||||
|
||||
const filediff: Snapshot.FileDiff = {
|
||||
file: filePath,
|
||||
patch: diff,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
}
|
||||
for (const change of diffLines(contentOld, contentNew)) {
|
||||
if (change.added) filediff.additions += change.count || 0
|
||||
if (change.removed) filediff.deletions += change.count || 0
|
||||
}
|
||||
|
||||
ctx.metadata({
|
||||
metadata: {
|
||||
diff,
|
||||
filediff,
|
||||
diagnostics: {},
|
||||
},
|
||||
})
|
||||
|
||||
let output = "Edit applied successfully."
|
||||
await LSP.touchFile(filePath, true)
|
||||
const diagnostics = await LSP.diagnostics()
|
||||
const normalizedFilePath = Filesystem.normalizePath(filePath)
|
||||
const issues = diagnostics[normalizedFilePath] ?? []
|
||||
const errors = issues.filter((item) => item.severity === 1)
|
||||
if (errors.length > 0) {
|
||||
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
||||
const suffix =
|
||||
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
|
||||
output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
}
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
if (!params.filePath) {
|
||||
throw new Error("filePath is required")
|
||||
}
|
||||
|
||||
if (params.oldString === params.newString) {
|
||||
throw new Error("No changes to apply: oldString and newString are identical.")
|
||||
}
|
||||
|
||||
const filePath = path.isAbsolute(params.filePath)
|
||||
? params.filePath
|
||||
: path.join(Instance.directory, params.filePath)
|
||||
yield* assertExternalDirectoryEffect(ctx, filePath)
|
||||
|
||||
let diff = ""
|
||||
let contentOld = ""
|
||||
let contentNew = ""
|
||||
yield* filetime.withLock(filePath, async () => {
|
||||
if (params.oldString === "") {
|
||||
const existed = await Filesystem.exists(filePath)
|
||||
contentNew = params.newString
|
||||
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
|
||||
await ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: [path.relative(Instance.worktree, filePath)],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath: filePath,
|
||||
diff,
|
||||
},
|
||||
})
|
||||
await Filesystem.write(filePath, params.newString)
|
||||
await Format.file(filePath)
|
||||
Bus.publish(File.Event.Edited, { file: filePath })
|
||||
await Bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filePath,
|
||||
event: existed ? "change" : "add",
|
||||
})
|
||||
await FileTime.read(ctx.sessionID, filePath)
|
||||
return
|
||||
}
|
||||
|
||||
const stats = Filesystem.stat(filePath)
|
||||
if (!stats) throw new Error(`File ${filePath} not found`)
|
||||
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
|
||||
await FileTime.assert(ctx.sessionID, filePath)
|
||||
contentOld = await Filesystem.readText(filePath)
|
||||
|
||||
const ending = detectLineEnding(contentOld)
|
||||
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
|
||||
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
|
||||
|
||||
contentNew = replace(contentOld, old, next, params.replaceAll)
|
||||
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(
|
||||
filePath,
|
||||
filePath,
|
||||
normalizeLineEndings(contentOld),
|
||||
normalizeLineEndings(contentNew),
|
||||
),
|
||||
)
|
||||
await ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: [path.relative(Instance.worktree, filePath)],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath: filePath,
|
||||
diff,
|
||||
},
|
||||
})
|
||||
|
||||
await Filesystem.write(filePath, contentNew)
|
||||
await Format.file(filePath)
|
||||
Bus.publish(File.Event.Edited, { file: filePath })
|
||||
await Bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filePath,
|
||||
event: "change",
|
||||
})
|
||||
contentNew = await Filesystem.readText(filePath)
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(
|
||||
filePath,
|
||||
filePath,
|
||||
normalizeLineEndings(contentOld),
|
||||
normalizeLineEndings(contentNew),
|
||||
),
|
||||
)
|
||||
await FileTime.read(ctx.sessionID, filePath)
|
||||
})
|
||||
|
||||
const filediff: Snapshot.FileDiff = {
|
||||
file: filePath,
|
||||
patch: diff,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
}
|
||||
for (const change of diffLines(contentOld, contentNew)) {
|
||||
if (change.added) filediff.additions += change.count || 0
|
||||
if (change.removed) filediff.deletions += change.count || 0
|
||||
}
|
||||
|
||||
ctx.metadata({
|
||||
metadata: {
|
||||
diff,
|
||||
filediff,
|
||||
diagnostics: {},
|
||||
},
|
||||
})
|
||||
|
||||
let output = "Edit applied successfully."
|
||||
yield* lsp.touchFile(filePath, true)
|
||||
const diagnostics = yield* lsp.diagnostics()
|
||||
const normalizedFilePath = Filesystem.normalizePath(filePath)
|
||||
const issues = diagnostics[normalizedFilePath] ?? []
|
||||
const errors = issues.filter((item) => item.severity === 1)
|
||||
if (errors.length > 0) {
|
||||
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
||||
const suffix =
|
||||
errors.length > MAX_DIAGNOSTICS_PER_FILE
|
||||
? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more`
|
||||
: ""
|
||||
output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
}
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
diagnostics,
|
||||
diff,
|
||||
filediff,
|
||||
},
|
||||
title: `${path.relative(Instance.worktree, filePath)}`,
|
||||
output,
|
||||
}
|
||||
}).pipe(Effect.orDie, Effect.runPromise),
|
||||
metadata: {
|
||||
diagnostics,
|
||||
diff,
|
||||
filediff,
|
||||
},
|
||||
title: `${path.relative(Instance.worktree, filePath)}`,
|
||||
output,
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
|
||||
|
||||
|
||||
@@ -1,96 +1,78 @@
|
||||
import z from "zod"
|
||||
import path from "path"
|
||||
import { Effect, Option } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { Tool } from "./tool"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import DESCRIPTION from "./glob.txt"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { Instance } from "../project/instance"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import { AppFileSystem } from "../filesystem"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
|
||||
export const GlobTool = Tool.defineEffect(
|
||||
"glob",
|
||||
Effect.gen(function* () {
|
||||
const rg = yield* Ripgrep.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
export const GlobTool = Tool.define("glob", {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
pattern: z.string().describe("The glob pattern to match files against"),
|
||||
path: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
`The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
|
||||
),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
await ctx.ask({
|
||||
permission: "glob",
|
||||
patterns: [params.pattern],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
pattern: params.pattern,
|
||||
path: params.path,
|
||||
},
|
||||
})
|
||||
|
||||
let search = params.path ?? Instance.directory
|
||||
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
|
||||
await assertExternalDirectory(ctx, search, { kind: "directory" })
|
||||
|
||||
const limit = 100
|
||||
const files = []
|
||||
let truncated = false
|
||||
for await (const file of Ripgrep.files({
|
||||
cwd: search,
|
||||
glob: [params.pattern],
|
||||
signal: ctx.abort,
|
||||
})) {
|
||||
if (files.length >= limit) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
const full = path.resolve(search, file)
|
||||
const stats = Filesystem.stat(full)?.mtime.getTime() ?? 0
|
||||
files.push({
|
||||
path: full,
|
||||
mtime: stats,
|
||||
})
|
||||
}
|
||||
files.sort((a, b) => b.mtime - a.mtime)
|
||||
|
||||
const output = []
|
||||
if (files.length === 0) output.push("No files found")
|
||||
if (files.length > 0) {
|
||||
output.push(...files.map((f) => f.path))
|
||||
if (truncated) {
|
||||
output.push("")
|
||||
output.push(
|
||||
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
pattern: z.string().describe("The glob pattern to match files against"),
|
||||
path: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
`The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
|
||||
),
|
||||
}),
|
||||
execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "glob",
|
||||
patterns: [params.pattern],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
pattern: params.pattern,
|
||||
path: params.path,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
let search = params.path ?? Instance.directory
|
||||
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
|
||||
yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
|
||||
|
||||
const limit = 100
|
||||
let truncated = false
|
||||
const files = yield* rg.files({ cwd: search, glob: [params.pattern] }).pipe(
|
||||
Stream.mapEffect((file) =>
|
||||
Effect.gen(function* () {
|
||||
const full = path.resolve(search, file)
|
||||
const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const mtime =
|
||||
info?.mtime.pipe(
|
||||
Option.map((d) => d.getTime()),
|
||||
Option.getOrElse(() => 0),
|
||||
) ?? 0
|
||||
return { path: full, mtime }
|
||||
}),
|
||||
),
|
||||
Stream.take(limit + 1),
|
||||
Stream.runCollect,
|
||||
Effect.map((chunk) => [...chunk]),
|
||||
)
|
||||
|
||||
if (files.length > limit) {
|
||||
truncated = true
|
||||
files.length = limit
|
||||
}
|
||||
files.sort((a, b) => b.mtime - a.mtime)
|
||||
|
||||
const output = []
|
||||
if (files.length === 0) output.push("No files found")
|
||||
if (files.length > 0) {
|
||||
output.push(...files.map((f) => f.path))
|
||||
if (truncated) {
|
||||
output.push("")
|
||||
output.push(
|
||||
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: path.relative(Instance.worktree, search),
|
||||
metadata: {
|
||||
count: files.length,
|
||||
truncated,
|
||||
},
|
||||
output: output.join("\n"),
|
||||
}
|
||||
}).pipe(Effect.orDie, Effect.runPromise),
|
||||
title: path.relative(Instance.worktree, search),
|
||||
metadata: {
|
||||
count: files.length,
|
||||
truncated,
|
||||
},
|
||||
output: output.join("\n"),
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,177 +1,156 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { text } from "node:stream/consumers"
|
||||
import { Tool } from "./tool"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { Process } from "../util/process"
|
||||
|
||||
import DESCRIPTION from "./grep.txt"
|
||||
import { Instance } from "../project/instance"
|
||||
import path from "path"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
|
||||
export const GrepTool = Tool.defineEffect(
|
||||
"grep",
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
export const GrepTool = Tool.define("grep", {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
pattern: z.string().describe("The regex pattern to search for in file contents"),
|
||||
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
|
||||
include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
if (!params.pattern) {
|
||||
throw new Error("pattern is required")
|
||||
}
|
||||
|
||||
await ctx.ask({
|
||||
permission: "grep",
|
||||
patterns: [params.pattern],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
pattern: params.pattern,
|
||||
path: params.path,
|
||||
include: params.include,
|
||||
},
|
||||
})
|
||||
|
||||
let searchPath = params.path ?? Instance.directory
|
||||
searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
|
||||
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
|
||||
|
||||
const rgPath = await Ripgrep.filepath()
|
||||
const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
|
||||
if (params.include) {
|
||||
args.push("--glob", params.include)
|
||||
}
|
||||
args.push(searchPath)
|
||||
|
||||
const proc = Process.spawn([rgPath, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
abort: ctx.abort,
|
||||
})
|
||||
|
||||
if (!proc.stdout || !proc.stderr) {
|
||||
throw new Error("Process output not available")
|
||||
}
|
||||
|
||||
const output = await text(proc.stdout)
|
||||
const errorOutput = await text(proc.stderr)
|
||||
const exitCode = await proc.exited
|
||||
|
||||
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
|
||||
// With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
|
||||
// Only fail if exit code is 2 AND no output was produced
|
||||
if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
|
||||
return {
|
||||
title: params.pattern,
|
||||
metadata: { matches: 0, truncated: false },
|
||||
output: "No files found",
|
||||
}
|
||||
}
|
||||
|
||||
if (exitCode !== 0 && exitCode !== 2) {
|
||||
throw new Error(`ripgrep failed: ${errorOutput}`)
|
||||
}
|
||||
|
||||
const hasErrors = exitCode === 2
|
||||
|
||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
||||
const lines = output.trim().split(/\r?\n/)
|
||||
const matches = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line) continue
|
||||
|
||||
const [filePath, lineNumStr, ...lineTextParts] = line.split("|")
|
||||
if (!filePath || !lineNumStr || lineTextParts.length === 0) continue
|
||||
|
||||
const lineNum = parseInt(lineNumStr, 10)
|
||||
const lineText = lineTextParts.join("|")
|
||||
|
||||
const stats = Filesystem.stat(filePath)
|
||||
if (!stats) continue
|
||||
|
||||
matches.push({
|
||||
path: filePath,
|
||||
modTime: stats.mtime.getTime(),
|
||||
lineNum,
|
||||
lineText,
|
||||
})
|
||||
}
|
||||
|
||||
matches.sort((a, b) => b.modTime - a.modTime)
|
||||
|
||||
const limit = 100
|
||||
const truncated = matches.length > limit
|
||||
const finalMatches = truncated ? matches.slice(0, limit) : matches
|
||||
|
||||
if (finalMatches.length === 0) {
|
||||
return {
|
||||
title: params.pattern,
|
||||
metadata: { matches: 0, truncated: false },
|
||||
output: "No files found",
|
||||
}
|
||||
}
|
||||
|
||||
const totalMatches = matches.length
|
||||
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
|
||||
|
||||
let currentFile = ""
|
||||
for (const match of finalMatches) {
|
||||
if (currentFile !== match.path) {
|
||||
if (currentFile !== "") {
|
||||
outputLines.push("")
|
||||
}
|
||||
currentFile = match.path
|
||||
outputLines.push(`${match.path}:`)
|
||||
}
|
||||
const truncatedLineText =
|
||||
match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText
|
||||
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
outputLines.push("")
|
||||
outputLines.push(
|
||||
`(Results truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). Consider using a more specific path or pattern.)`,
|
||||
)
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
outputLines.push("")
|
||||
outputLines.push("(Some paths were inaccessible and skipped)")
|
||||
}
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
pattern: z.string().describe("The regex pattern to search for in file contents"),
|
||||
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
|
||||
include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
|
||||
}),
|
||||
execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
if (!params.pattern) {
|
||||
throw new Error("pattern is required")
|
||||
}
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "grep",
|
||||
patterns: [params.pattern],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
pattern: params.pattern,
|
||||
path: params.path,
|
||||
include: params.include,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
let searchPath = params.path ?? Instance.directory
|
||||
searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
|
||||
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
|
||||
|
||||
const rgPath = yield* Effect.promise(() => Ripgrep.filepath())
|
||||
const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
|
||||
if (params.include) {
|
||||
args.push("--glob", params.include)
|
||||
}
|
||||
args.push(searchPath)
|
||||
|
||||
const result = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* spawner.spawn(
|
||||
ChildProcess.make(rgPath, args, {
|
||||
stdin: "ignore",
|
||||
}),
|
||||
)
|
||||
|
||||
const [output, errorOutput] = yield* Effect.all(
|
||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
|
||||
const exitCode = yield* handle.exitCode
|
||||
|
||||
return { output, errorOutput, exitCode }
|
||||
}),
|
||||
)
|
||||
|
||||
const { output, errorOutput, exitCode } = result
|
||||
|
||||
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
|
||||
// With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
|
||||
// Only fail if exit code is 2 AND no output was produced
|
||||
if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
|
||||
return {
|
||||
title: params.pattern,
|
||||
metadata: { matches: 0, truncated: false },
|
||||
output: "No files found",
|
||||
}
|
||||
}
|
||||
|
||||
if (exitCode !== 0 && exitCode !== 2) {
|
||||
throw new Error(`ripgrep failed: ${errorOutput}`)
|
||||
}
|
||||
|
||||
const hasErrors = exitCode === 2
|
||||
|
||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
||||
const lines = output.trim().split(/\r?\n/)
|
||||
const matches = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line) continue
|
||||
|
||||
const [filePath, lineNumStr, ...lineTextParts] = line.split("|")
|
||||
if (!filePath || !lineNumStr || lineTextParts.length === 0) continue
|
||||
|
||||
const lineNum = parseInt(lineNumStr, 10)
|
||||
const lineText = lineTextParts.join("|")
|
||||
|
||||
const stats = Filesystem.stat(filePath)
|
||||
if (!stats) continue
|
||||
|
||||
matches.push({
|
||||
path: filePath,
|
||||
modTime: stats.mtime.getTime(),
|
||||
lineNum,
|
||||
lineText,
|
||||
})
|
||||
}
|
||||
|
||||
matches.sort((a, b) => b.modTime - a.modTime)
|
||||
|
||||
const limit = 100
|
||||
const truncated = matches.length > limit
|
||||
const finalMatches = truncated ? matches.slice(0, limit) : matches
|
||||
|
||||
if (finalMatches.length === 0) {
|
||||
return {
|
||||
title: params.pattern,
|
||||
metadata: { matches: 0, truncated: false },
|
||||
output: "No files found",
|
||||
}
|
||||
}
|
||||
|
||||
const totalMatches = matches.length
|
||||
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
|
||||
|
||||
let currentFile = ""
|
||||
for (const match of finalMatches) {
|
||||
if (currentFile !== match.path) {
|
||||
if (currentFile !== "") {
|
||||
outputLines.push("")
|
||||
}
|
||||
currentFile = match.path
|
||||
outputLines.push(`${match.path}:`)
|
||||
}
|
||||
const truncatedLineText =
|
||||
match.lineText.length > MAX_LINE_LENGTH
|
||||
? match.lineText.substring(0, MAX_LINE_LENGTH) + "..."
|
||||
: match.lineText
|
||||
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
outputLines.push("")
|
||||
outputLines.push(
|
||||
`(Results truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). Consider using a more specific path or pattern.)`,
|
||||
)
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
outputLines.push("")
|
||||
outputLines.push("(Some paths were inaccessible and skipped)")
|
||||
}
|
||||
|
||||
return {
|
||||
title: params.pattern,
|
||||
metadata: {
|
||||
matches: totalMatches,
|
||||
truncated,
|
||||
},
|
||||
output: outputLines.join("\n"),
|
||||
}
|
||||
}).pipe(Effect.orDie, Effect.runPromise),
|
||||
title: params.pattern,
|
||||
metadata: {
|
||||
matches: totalMatches,
|
||||
truncated,
|
||||
},
|
||||
output: outputLines.join("\n"),
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { Tool } from "./tool"
|
||||
import * as path from "path"
|
||||
import DESCRIPTION from "./ls.txt"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
|
||||
export const IGNORE_PATTERNS = [
|
||||
"node_modules/",
|
||||
@@ -37,100 +35,87 @@ export const IGNORE_PATTERNS = [
|
||||
|
||||
const LIMIT = 100
|
||||
|
||||
export const ListTool = Tool.defineEffect(
|
||||
"list",
|
||||
Effect.gen(function* () {
|
||||
const rg = yield* Ripgrep.Service
|
||||
export const ListTool = Tool.define("list", {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
path: z.string().describe("The absolute path to the directory to list (must be absolute, not relative)").optional(),
|
||||
ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
const searchPath = path.resolve(Instance.directory, params.path || ".")
|
||||
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
|
||||
|
||||
await ctx.ask({
|
||||
permission: "list",
|
||||
patterns: [searchPath],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
path: searchPath,
|
||||
},
|
||||
})
|
||||
|
||||
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
|
||||
const files = []
|
||||
for await (const file of Ripgrep.files({ cwd: searchPath, glob: ignoreGlobs, signal: ctx.abort })) {
|
||||
files.push(file)
|
||||
if (files.length >= LIMIT) break
|
||||
}
|
||||
|
||||
// Build directory structure
|
||||
const dirs = new Set<string>()
|
||||
const filesByDir = new Map<string, string[]>()
|
||||
|
||||
for (const file of files) {
|
||||
const dir = path.dirname(file)
|
||||
const parts = dir === "." ? [] : dir.split("/")
|
||||
|
||||
// Add all parent directories
|
||||
for (let i = 0; i <= parts.length; i++) {
|
||||
const dirPath = i === 0 ? "." : parts.slice(0, i).join("/")
|
||||
dirs.add(dirPath)
|
||||
}
|
||||
|
||||
// Add file to its directory
|
||||
if (!filesByDir.has(dir)) filesByDir.set(dir, [])
|
||||
filesByDir.get(dir)!.push(path.basename(file))
|
||||
}
|
||||
|
||||
function renderDir(dirPath: string, depth: number): string {
|
||||
const indent = " ".repeat(depth)
|
||||
let output = ""
|
||||
|
||||
if (depth > 0) {
|
||||
output += `${indent}${path.basename(dirPath)}/\n`
|
||||
}
|
||||
|
||||
const childIndent = " ".repeat(depth + 1)
|
||||
const children = Array.from(dirs)
|
||||
.filter((d) => path.dirname(d) === dirPath && d !== dirPath)
|
||||
.sort()
|
||||
|
||||
// Render subdirectories first
|
||||
for (const child of children) {
|
||||
output += renderDir(child, depth + 1)
|
||||
}
|
||||
|
||||
// Render files
|
||||
const files = filesByDir.get(dirPath) || []
|
||||
for (const file of files.sort()) {
|
||||
output += `${childIndent}${file}\n`
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
const output = `${searchPath}/\n` + renderDir(".", 0)
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
path: z
|
||||
.string()
|
||||
.describe("The absolute path to the directory to list (must be absolute, not relative)")
|
||||
.optional(),
|
||||
ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
|
||||
}),
|
||||
execute: (params: { path?: string; ignore?: string[] }, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const searchPath = path.resolve(Instance.directory, params.path || ".")
|
||||
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "list",
|
||||
patterns: [searchPath],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
path: searchPath,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
|
||||
const files = yield* rg.files({ cwd: searchPath, glob: ignoreGlobs }).pipe(
|
||||
Stream.take(LIMIT),
|
||||
Stream.runCollect,
|
||||
Effect.map((chunk) => [...chunk]),
|
||||
)
|
||||
|
||||
// Build directory structure
|
||||
const dirs = new Set<string>()
|
||||
const filesByDir = new Map<string, string[]>()
|
||||
|
||||
for (const file of files) {
|
||||
const dir = path.dirname(file)
|
||||
const parts = dir === "." ? [] : dir.split("/")
|
||||
|
||||
// Add all parent directories
|
||||
for (let i = 0; i <= parts.length; i++) {
|
||||
const dirPath = i === 0 ? "." : parts.slice(0, i).join("/")
|
||||
dirs.add(dirPath)
|
||||
}
|
||||
|
||||
// Add file to its directory
|
||||
if (!filesByDir.has(dir)) filesByDir.set(dir, [])
|
||||
filesByDir.get(dir)!.push(path.basename(file))
|
||||
}
|
||||
|
||||
function renderDir(dirPath: string, depth: number): string {
|
||||
const indent = " ".repeat(depth)
|
||||
let output = ""
|
||||
|
||||
if (depth > 0) {
|
||||
output += `${indent}${path.basename(dirPath)}/\n`
|
||||
}
|
||||
|
||||
const childIndent = " ".repeat(depth + 1)
|
||||
const children = Array.from(dirs)
|
||||
.filter((d) => path.dirname(d) === dirPath && d !== dirPath)
|
||||
.sort()
|
||||
|
||||
// Render subdirectories first
|
||||
for (const child of children) {
|
||||
output += renderDir(child, depth + 1)
|
||||
}
|
||||
|
||||
// Render files
|
||||
const files = filesByDir.get(dirPath) || []
|
||||
for (const file of files.sort()) {
|
||||
output += `${childIndent}${file}\n`
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
const output = `${searchPath}/\n` + renderDir(".", 0)
|
||||
|
||||
return {
|
||||
title: path.relative(Instance.worktree, searchPath),
|
||||
metadata: {
|
||||
count: files.length,
|
||||
truncated: files.length >= LIMIT,
|
||||
},
|
||||
output,
|
||||
}
|
||||
}).pipe(Effect.orDie, Effect.runPromise),
|
||||
title: path.relative(Instance.worktree, searchPath),
|
||||
metadata: {
|
||||
count: files.length,
|
||||
truncated: files.length >= LIMIT,
|
||||
},
|
||||
output,
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,63 +1,46 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Tool } from "./tool"
|
||||
import { EditTool } from "./edit"
|
||||
import DESCRIPTION from "./multiedit.txt"
|
||||
import path from "path"
|
||||
import { Instance } from "../project/instance"
|
||||
|
||||
export const MultiEditTool = Tool.defineEffect(
|
||||
"multiedit",
|
||||
Effect.gen(function* () {
|
||||
const editInfo = yield* EditTool
|
||||
const edit = yield* Effect.promise(() => editInfo.init())
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
edits: z
|
||||
.array(
|
||||
z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
oldString: z.string().describe("The text to replace"),
|
||||
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
|
||||
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
|
||||
}),
|
||||
)
|
||||
.describe("Array of edit operations to perform sequentially on the file"),
|
||||
}),
|
||||
execute: (
|
||||
params: {
|
||||
filePath: string
|
||||
edits: Array<{ filePath: string; oldString: string; newString: string; replaceAll?: boolean }>
|
||||
},
|
||||
ctx: Tool.Context,
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const results = []
|
||||
for (const [, entry] of params.edits.entries()) {
|
||||
const result = yield* Effect.promise(() =>
|
||||
edit.execute(
|
||||
{
|
||||
filePath: params.filePath,
|
||||
oldString: entry.oldString,
|
||||
newString: entry.newString,
|
||||
replaceAll: entry.replaceAll,
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
results.push(result)
|
||||
}
|
||||
return {
|
||||
title: path.relative(Instance.worktree, params.filePath),
|
||||
metadata: {
|
||||
results: results.map((r) => r.metadata),
|
||||
},
|
||||
output: results.at(-1)!.output,
|
||||
}
|
||||
}).pipe(Effect.orDie, Effect.runPromise),
|
||||
}
|
||||
export const MultiEditTool = Tool.define("multiedit", {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
edits: z
|
||||
.array(
|
||||
z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
oldString: z.string().describe("The text to replace"),
|
||||
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
|
||||
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
|
||||
}),
|
||||
)
|
||||
.describe("Array of edit operations to perform sequentially on the file"),
|
||||
}),
|
||||
)
|
||||
async execute(params, ctx) {
|
||||
const tool = await EditTool.init()
|
||||
const results = []
|
||||
for (const [, edit] of params.edits.entries()) {
|
||||
const result = await tool.execute(
|
||||
{
|
||||
filePath: params.filePath,
|
||||
oldString: edit.oldString,
|
||||
newString: edit.newString,
|
||||
replaceAll: edit.replaceAll,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
results.push(result)
|
||||
}
|
||||
return {
|
||||
title: path.relative(Instance.worktree, params.filePath),
|
||||
metadata: {
|
||||
results: results.map((r) => r.metadata),
|
||||
},
|
||||
output: results.at(-1)!.output,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -31,10 +31,6 @@ import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
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 { Format } from "../format"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Env } from "../env"
|
||||
@@ -47,6 +43,7 @@ import { AppFileSystem } from "../filesystem"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { Skill } from "../skill"
|
||||
import { Permission } from "@/permission"
|
||||
import type { TaskMetadata } from "./task"
|
||||
|
||||
export namespace ToolRegistry {
|
||||
const log = Log.create({ service: "tool.registry" })
|
||||
@@ -90,9 +87,6 @@ export namespace ToolRegistry {
|
||||
| Instruction.Service
|
||||
| AppFileSystem.Service
|
||||
| HttpClient.HttpClient
|
||||
| ChildProcessSpawner
|
||||
| Ripgrep.Service
|
||||
| Format.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -101,7 +95,7 @@ export namespace ToolRegistry {
|
||||
const agents = yield* Agent.Service
|
||||
const skill = yield* Skill.Service
|
||||
|
||||
const task = yield* TaskTool
|
||||
const task: Tool.Info<typeof TaskTool.parameters, TaskMetadata> = yield* TaskTool
|
||||
const read = yield* ReadTool
|
||||
const question = yield* QuestionTool
|
||||
const todo = yield* TodoWriteTool
|
||||
@@ -109,14 +103,7 @@ export namespace ToolRegistry {
|
||||
const plan = yield* PlanExitTool
|
||||
const webfetch = yield* WebFetchTool
|
||||
const websearch = yield* WebSearchTool
|
||||
const bash = yield* BashTool
|
||||
const codesearch = yield* CodeSearchTool
|
||||
const globtool = yield* GlobTool
|
||||
const writetool = yield* WriteTool
|
||||
const edit = yield* EditTool
|
||||
const greptool = yield* GrepTool
|
||||
const patchtool = yield* ApplyPatchTool
|
||||
const skilltool = yield* SkillTool
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("ToolRegistry.state")(function* (ctx) {
|
||||
@@ -175,19 +162,19 @@ export namespace ToolRegistry {
|
||||
|
||||
const tool = yield* Effect.all({
|
||||
invalid: Tool.init(InvalidTool),
|
||||
bash: Tool.init(bash),
|
||||
bash: Tool.init(BashTool),
|
||||
read: Tool.init(read),
|
||||
glob: Tool.init(globtool),
|
||||
grep: Tool.init(greptool),
|
||||
edit: Tool.init(edit),
|
||||
write: Tool.init(writetool),
|
||||
glob: Tool.init(GlobTool),
|
||||
grep: Tool.init(GrepTool),
|
||||
edit: Tool.init(EditTool),
|
||||
write: Tool.init(WriteTool),
|
||||
task: Tool.init(task),
|
||||
fetch: Tool.init(webfetch),
|
||||
todo: Tool.init(todo),
|
||||
search: Tool.init(websearch),
|
||||
code: Tool.init(codesearch),
|
||||
skill: Tool.init(skilltool),
|
||||
patch: Tool.init(patchtool),
|
||||
skill: Tool.init(SkillTool),
|
||||
patch: Tool.init(ApplyPatchTool),
|
||||
question: Tool.init(question),
|
||||
lsp: Tool.init(lsptool),
|
||||
plan: Tool.init(plan),
|
||||
@@ -329,9 +316,6 @@ export namespace ToolRegistry {
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,101 +1,99 @@
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { Tool } from "./tool"
|
||||
import { Skill } from "../skill"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { iife } from "@/util/iife"
|
||||
|
||||
const Parameters = z.object({
|
||||
name: z.string().describe("The name of the skill from available_skills"),
|
||||
})
|
||||
|
||||
export const SkillTool = Tool.defineEffect(
|
||||
"skill",
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const rg = yield* Ripgrep.Service
|
||||
export const SkillTool = Tool.define("skill", async () => {
|
||||
const list = await Skill.available()
|
||||
|
||||
return async () => {
|
||||
const list = await Effect.runPromise(skill.available())
|
||||
const description =
|
||||
list.length === 0
|
||||
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
|
||||
: [
|
||||
"Load a specialized skill that provides domain-specific instructions and workflows.",
|
||||
"",
|
||||
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
|
||||
"",
|
||||
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
|
||||
"",
|
||||
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
|
||||
"",
|
||||
"The following skills provide specialized sets of instructions for particular tasks",
|
||||
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
|
||||
"",
|
||||
Skill.fmt(list, { verbose: false }),
|
||||
].join("\n")
|
||||
|
||||
const description =
|
||||
list.length === 0
|
||||
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
|
||||
: [
|
||||
"Load a specialized skill that provides domain-specific instructions and workflows.",
|
||||
"",
|
||||
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
|
||||
"",
|
||||
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
|
||||
"",
|
||||
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
|
||||
"",
|
||||
"The following skills provide specialized sets of instructions for particular tasks",
|
||||
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
|
||||
"",
|
||||
Skill.fmt(list, { verbose: false }),
|
||||
].join("\n")
|
||||
return {
|
||||
description,
|
||||
parameters: Parameters,
|
||||
async execute(params: z.infer<typeof Parameters>, ctx) {
|
||||
const skill = await Skill.get(params.name)
|
||||
|
||||
if (!skill) {
|
||||
const available = await Skill.all().then((x) => x.map((skill) => skill.name).join(", "))
|
||||
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
|
||||
}
|
||||
|
||||
await ctx.ask({
|
||||
permission: "skill",
|
||||
patterns: [params.name],
|
||||
always: [params.name],
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
const dir = path.dirname(skill.location)
|
||||
const base = pathToFileURL(dir).href
|
||||
|
||||
const limit = 10
|
||||
const files = await iife(async () => {
|
||||
const arr = []
|
||||
for await (const file of Ripgrep.files({
|
||||
cwd: dir,
|
||||
follow: false,
|
||||
hidden: true,
|
||||
signal: ctx.abort,
|
||||
})) {
|
||||
if (file.includes("SKILL.md")) {
|
||||
continue
|
||||
}
|
||||
arr.push(path.resolve(dir, file))
|
||||
if (arr.length >= limit) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return arr
|
||||
}).then((f) => f.map((file) => `<file>${file}</file>`).join("\n"))
|
||||
|
||||
return {
|
||||
description,
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* skill.get(params.name)
|
||||
|
||||
if (!info) {
|
||||
const all = yield* skill.all()
|
||||
const available = all.map((s) => s.name).join(", ")
|
||||
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
|
||||
}
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "skill",
|
||||
patterns: [params.name],
|
||||
always: [params.name],
|
||||
metadata: {},
|
||||
}),
|
||||
)
|
||||
|
||||
const dir = path.dirname(info.location)
|
||||
const base = pathToFileURL(dir).href
|
||||
|
||||
const limit = 10
|
||||
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe(
|
||||
Stream.filter((file) => !file.includes("SKILL.md")),
|
||||
Stream.map((file) => path.resolve(dir, file)),
|
||||
Stream.take(limit),
|
||||
Stream.runCollect,
|
||||
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
|
||||
)
|
||||
|
||||
return {
|
||||
title: `Loaded skill: ${info.name}`,
|
||||
output: [
|
||||
`<skill_content name="${info.name}">`,
|
||||
`# Skill: ${info.name}`,
|
||||
"",
|
||||
info.content.trim(),
|
||||
"",
|
||||
`Base directory for this skill: ${base}`,
|
||||
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
|
||||
"Note: file list is sampled.",
|
||||
"",
|
||||
"<skill_files>",
|
||||
files,
|
||||
"</skill_files>",
|
||||
"</skill_content>",
|
||||
].join("\n"),
|
||||
metadata: {
|
||||
name: info.name,
|
||||
dir,
|
||||
},
|
||||
}
|
||||
}).pipe(Effect.orDie, Effect.runPromise),
|
||||
title: `Loaded skill: ${skill.name}`,
|
||||
output: [
|
||||
`<skill_content name="${skill.name}">`,
|
||||
`# Skill: ${skill.name}`,
|
||||
"",
|
||||
skill.content.trim(),
|
||||
"",
|
||||
`Base directory for this skill: ${base}`,
|
||||
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
|
||||
"Note: file list is sampled.",
|
||||
"",
|
||||
"<skill_files>",
|
||||
files,
|
||||
"</skill_files>",
|
||||
"</skill_content>",
|
||||
].join("\n"),
|
||||
metadata: {
|
||||
name: skill.name,
|
||||
dir,
|
||||
},
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,16 +5,9 @@ import { Session } from "../session"
|
||||
import { SessionID, MessageID } from "../session/schema"
|
||||
import { MessageV2 } from "../session/message-v2"
|
||||
import { Agent } from "../agent/agent"
|
||||
import type { SessionPrompt } from "../session/prompt"
|
||||
import { Config } from "../config/config"
|
||||
import type { SessionPrompt } from "../session/prompt"
|
||||
import { Effect } from "effect"
|
||||
import { Log } from "@/util/log"
|
||||
|
||||
export interface TaskPromptOps {
|
||||
cancel(sessionID: SessionID): void
|
||||
resolvePromptParts(template: string): Promise<SessionPrompt.PromptInput["parts"]>
|
||||
prompt(input: SessionPrompt.PromptInput): Promise<MessageV2.WithParts>
|
||||
}
|
||||
|
||||
const id = "task"
|
||||
|
||||
@@ -31,156 +24,180 @@ const parameters = z.object({
|
||||
command: z.string().describe("The command that triggered this task").optional(),
|
||||
})
|
||||
|
||||
export const TaskTool = Tool.defineEffect(
|
||||
id,
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
const config = yield* Config.Service
|
||||
type Metadata = {
|
||||
sessionId: SessionID
|
||||
model: {
|
||||
modelID: MessageV2.Assistant["modelID"]
|
||||
providerID: MessageV2.Assistant["providerID"]
|
||||
}
|
||||
}
|
||||
|
||||
const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
|
||||
const cfg = yield* config.get()
|
||||
export type TaskMetadata = Metadata
|
||||
|
||||
if (!ctx.extra?.bypassAgentCheck) {
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: id,
|
||||
patterns: [params.subagent_type],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
description: params.description,
|
||||
subagent_type: params.subagent_type,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
type Runtime = {
|
||||
agent: Agent.Interface
|
||||
config: Config.Interface
|
||||
cancel: (sessionID: SessionID) => Promise<void>
|
||||
resolvePromptParts: (template: string) => Promise<SessionPrompt.PromptInput["parts"]>
|
||||
prompt: (input: SessionPrompt.PromptInput) => Promise<MessageV2.WithParts>
|
||||
}
|
||||
|
||||
const next = yield* agent.get(params.subagent_type)
|
||||
if (!next) {
|
||||
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
|
||||
}
|
||||
const unbound: Tool.DefWithoutID<typeof parameters, Metadata> = {
|
||||
description: DESCRIPTION,
|
||||
parameters,
|
||||
async execute() {
|
||||
throw new Error("Task tool execution is only available from the prompt runtime")
|
||||
},
|
||||
}
|
||||
|
||||
const canTask = next.permission.some((rule) => rule.permission === id)
|
||||
const canTodo = next.permission.some((rule) => rule.permission === "todowrite")
|
||||
const build = (runtime: Runtime) => {
|
||||
const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
|
||||
const cfg = yield* runtime.config.get()
|
||||
|
||||
const taskID = params.task_id
|
||||
const session = taskID
|
||||
? yield* Effect.promise(() => {
|
||||
const id = SessionID.make(taskID)
|
||||
return Session.get(id).catch(() => undefined)
|
||||
})
|
||||
: undefined
|
||||
const nextSession =
|
||||
session ??
|
||||
(yield* Effect.promise(() =>
|
||||
Session.create({
|
||||
parentID: ctx.sessionID,
|
||||
title: params.description + ` (@${next.name} subagent)`,
|
||||
permission: [
|
||||
...(canTodo
|
||||
? []
|
||||
: [
|
||||
{
|
||||
permission: "todowrite" as const,
|
||||
pattern: "*" as const,
|
||||
action: "deny" as const,
|
||||
},
|
||||
]),
|
||||
...(canTask
|
||||
? []
|
||||
: [
|
||||
{
|
||||
permission: id,
|
||||
pattern: "*" as const,
|
||||
action: "deny" as const,
|
||||
},
|
||||
]),
|
||||
...(cfg.experimental?.primary_tools?.map((item) => ({
|
||||
pattern: "*",
|
||||
action: "allow" as const,
|
||||
permission: item,
|
||||
})) ?? []),
|
||||
],
|
||||
}),
|
||||
))
|
||||
|
||||
const msg = yield* Effect.sync(() => MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }))
|
||||
if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message"))
|
||||
|
||||
const model = next.model ?? {
|
||||
modelID: msg.info.modelID,
|
||||
providerID: msg.info.providerID,
|
||||
}
|
||||
|
||||
ctx.metadata({
|
||||
title: params.description,
|
||||
metadata: {
|
||||
sessionId: nextSession.id,
|
||||
model,
|
||||
},
|
||||
})
|
||||
|
||||
const ops = ctx.extra?.promptOps as TaskPromptOps
|
||||
if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra"))
|
||||
|
||||
const messageID = MessageID.ascending()
|
||||
|
||||
function cancel() {
|
||||
ops.cancel(nextSession.id)
|
||||
}
|
||||
|
||||
return yield* Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
ctx.abort.addEventListener("abort", cancel)
|
||||
if (!ctx.extra?.bypassAgentCheck) {
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: id,
|
||||
patterns: [params.subagent_type],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
description: params.description,
|
||||
subagent_type: params.subagent_type,
|
||||
},
|
||||
}),
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const parts = yield* Effect.promise(() => ops.resolvePromptParts(params.prompt))
|
||||
const result = yield* Effect.promise(() =>
|
||||
ops.prompt({
|
||||
messageID,
|
||||
sessionID: nextSession.id,
|
||||
model: {
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
},
|
||||
agent: next.name,
|
||||
tools: {
|
||||
...(canTodo ? {} : { todowrite: false }),
|
||||
...(canTask ? {} : { task: false }),
|
||||
...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])),
|
||||
},
|
||||
parts,
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
title: params.description,
|
||||
metadata: {
|
||||
sessionId: nextSession.id,
|
||||
model,
|
||||
},
|
||||
output: [
|
||||
`task_id: ${nextSession.id} (for resuming to continue this task if needed)`,
|
||||
"",
|
||||
"<task_result>",
|
||||
result.parts.findLast((item) => item.type === "text")?.text ?? "",
|
||||
"</task_result>",
|
||||
].join("\n"),
|
||||
}
|
||||
}),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
ctx.abort.removeEventListener("abort", cancel)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const next = yield* runtime.agent.get(params.subagent_type)
|
||||
if (!next) {
|
||||
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
|
||||
}
|
||||
|
||||
const canTask = next.permission.some((rule) => rule.permission === id)
|
||||
const canTodo = next.permission.some((rule) => rule.permission === "todowrite")
|
||||
|
||||
const taskID = params.task_id
|
||||
const session = taskID
|
||||
? yield* Effect.promise(() => {
|
||||
const id = SessionID.make(taskID)
|
||||
return Session.get(id).catch(() => undefined)
|
||||
})
|
||||
: undefined
|
||||
const nextSession =
|
||||
session ??
|
||||
(yield* Effect.promise(() =>
|
||||
Session.create({
|
||||
parentID: ctx.sessionID,
|
||||
title: params.description + ` (@${next.name} subagent)`,
|
||||
permission: [
|
||||
...(canTodo
|
||||
? []
|
||||
: [
|
||||
{
|
||||
permission: "todowrite" as const,
|
||||
pattern: "*" as const,
|
||||
action: "deny" as const,
|
||||
},
|
||||
]),
|
||||
...(canTask
|
||||
? []
|
||||
: [
|
||||
{
|
||||
permission: id,
|
||||
pattern: "*" as const,
|
||||
action: "deny" as const,
|
||||
},
|
||||
]),
|
||||
...(cfg.experimental?.primary_tools?.map((item) => ({
|
||||
pattern: "*",
|
||||
action: "allow" as const,
|
||||
permission: item,
|
||||
})) ?? []),
|
||||
],
|
||||
}),
|
||||
))
|
||||
|
||||
const msg = yield* Effect.sync(() => MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }))
|
||||
if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message"))
|
||||
|
||||
const model = next.model ?? {
|
||||
modelID: msg.info.modelID,
|
||||
providerID: msg.info.providerID,
|
||||
}
|
||||
|
||||
ctx.metadata({
|
||||
title: params.description,
|
||||
metadata: {
|
||||
sessionId: nextSession.id,
|
||||
model,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters,
|
||||
async execute(params: z.infer<typeof parameters>, ctx) {
|
||||
return Effect.runPromise(run(params, ctx))
|
||||
},
|
||||
const messageID = MessageID.ascending()
|
||||
|
||||
function cancel() {
|
||||
return runtime.cancel(nextSession.id)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return yield* Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
ctx.abort.addEventListener("abort", cancel)
|
||||
}),
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const parts = yield* Effect.promise(() => runtime.resolvePromptParts(params.prompt))
|
||||
const result = yield* Effect.promise(() =>
|
||||
runtime.prompt({
|
||||
messageID,
|
||||
sessionID: nextSession.id,
|
||||
model: {
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
},
|
||||
agent: next.name,
|
||||
tools: {
|
||||
...(canTodo ? {} : { todowrite: false }),
|
||||
...(canTask ? {} : { task: false }),
|
||||
...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])),
|
||||
},
|
||||
parts,
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
title: params.description,
|
||||
metadata: {
|
||||
sessionId: nextSession.id,
|
||||
model,
|
||||
},
|
||||
output: [
|
||||
`task_id: ${nextSession.id} (for resuming to continue this task if needed)`,
|
||||
"",
|
||||
"<task_result>",
|
||||
result.parts.findLast((item) => item.type === "text")?.text ?? "",
|
||||
"</task_result>",
|
||||
].join("\n"),
|
||||
}
|
||||
}),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
ctx.abort.removeEventListener("abort", cancel)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return Tool.define(id, {
|
||||
description: DESCRIPTION,
|
||||
parameters,
|
||||
async execute(params: z.infer<typeof parameters>, ctx) {
|
||||
return Effect.runPromise(run(params, ctx))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const TaskTool = Object.assign(Effect.succeed(Tool.define(id, unbound)), {
|
||||
id,
|
||||
description: DESCRIPTION,
|
||||
parameters,
|
||||
build,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import z from "zod"
|
||||
import * as path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Tool } from "./tool"
|
||||
import { LSP } from "../lsp"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
@@ -10,94 +9,76 @@ import { File } from "../file"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { Format } from "../format"
|
||||
import { FileTime } from "../file/time"
|
||||
import { AppFileSystem } from "../filesystem"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Instance } from "../project/instance"
|
||||
import { trimDiff } from "./edit"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
|
||||
const MAX_DIAGNOSTICS_PER_FILE = 20
|
||||
const MAX_PROJECT_DIAGNOSTICS_FILES = 5
|
||||
|
||||
export const WriteTool = Tool.defineEffect(
|
||||
"write",
|
||||
Effect.gen(function* () {
|
||||
const lsp = yield* LSP.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const filetime = yield* FileTime.Service
|
||||
export const WriteTool = Tool.define("write", {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
content: z.string().describe("The content to write to the file"),
|
||||
filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
|
||||
await assertExternalDirectory(ctx, filepath)
|
||||
|
||||
const exists = await Filesystem.exists(filepath)
|
||||
const contentOld = exists ? await Filesystem.readText(filepath) : ""
|
||||
if (exists) await FileTime.assert(ctx.sessionID, filepath)
|
||||
|
||||
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
|
||||
await ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: [path.relative(Instance.worktree, filepath)],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath,
|
||||
diff,
|
||||
},
|
||||
})
|
||||
|
||||
await Filesystem.write(filepath, params.content)
|
||||
await Format.file(filepath)
|
||||
Bus.publish(File.Event.Edited, { file: filepath })
|
||||
await Bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filepath,
|
||||
event: exists ? "change" : "add",
|
||||
})
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
let output = "Wrote file successfully."
|
||||
await LSP.touchFile(filepath, true)
|
||||
const diagnostics = await LSP.diagnostics()
|
||||
const normalizedFilepath = Filesystem.normalizePath(filepath)
|
||||
let projectDiagnosticsCount = 0
|
||||
for (const [file, issues] of Object.entries(diagnostics)) {
|
||||
const errors = issues.filter((item) => item.severity === 1)
|
||||
if (errors.length === 0) continue
|
||||
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
||||
const suffix =
|
||||
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
|
||||
if (file === normalizedFilepath) {
|
||||
output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filepath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
continue
|
||||
}
|
||||
if (projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue
|
||||
projectDiagnosticsCount++
|
||||
output += `\n\nLSP errors detected in other files:\n<diagnostics file="${file}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
}
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
content: z.string().describe("The content to write to the file"),
|
||||
filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
|
||||
}),
|
||||
execute: (params: { content: string; filePath: string }, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.isAbsolute(params.filePath)
|
||||
? params.filePath
|
||||
: path.join(Instance.directory, params.filePath)
|
||||
yield* assertExternalDirectoryEffect(ctx, filepath)
|
||||
|
||||
const exists = yield* fs.existsSafe(filepath)
|
||||
const contentOld = exists ? yield* fs.readFileString(filepath) : ""
|
||||
if (exists) yield* filetime.assert(ctx.sessionID, filepath)
|
||||
|
||||
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: [path.relative(Instance.worktree, filepath)],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath,
|
||||
diff,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
yield* fs.writeWithDirs(filepath, params.content)
|
||||
yield* Effect.promise(() => Format.file(filepath))
|
||||
Bus.publish(File.Event.Edited, { file: filepath })
|
||||
yield* Effect.promise(() =>
|
||||
Bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filepath,
|
||||
event: exists ? "change" : "add",
|
||||
}),
|
||||
)
|
||||
yield* filetime.read(ctx.sessionID, filepath)
|
||||
|
||||
let output = "Wrote file successfully."
|
||||
yield* lsp.touchFile(filepath, true)
|
||||
const diagnostics = yield* lsp.diagnostics()
|
||||
const normalizedFilepath = AppFileSystem.normalizePath(filepath)
|
||||
let projectDiagnosticsCount = 0
|
||||
for (const [file, issues] of Object.entries(diagnostics)) {
|
||||
const errors = issues.filter((item) => item.severity === 1)
|
||||
if (errors.length === 0) continue
|
||||
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
||||
const suffix =
|
||||
errors.length > MAX_DIAGNOSTICS_PER_FILE
|
||||
? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more`
|
||||
: ""
|
||||
if (file === normalizedFilepath) {
|
||||
output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filepath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
continue
|
||||
}
|
||||
if (projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue
|
||||
projectDiagnosticsCount++
|
||||
output += `\n\nLSP errors detected in other files:\n<diagnostics file="${file}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
}
|
||||
|
||||
return {
|
||||
title: path.relative(Instance.worktree, filepath),
|
||||
metadata: {
|
||||
diagnostics,
|
||||
filepath,
|
||||
exists: exists,
|
||||
},
|
||||
output,
|
||||
}
|
||||
}).pipe(Effect.orDie, Effect.runPromise),
|
||||
title: path.relative(Instance.worktree, filepath),
|
||||
metadata: {
|
||||
diagnostics,
|
||||
filepath,
|
||||
exists: exists,
|
||||
},
|
||||
output,
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -590,7 +590,7 @@ export namespace Worktree {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
const defaultLayer = layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(Project.defaultLayer),
|
||||
|
||||
@@ -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 === "/config" && item.workspace === "ws_a")).toBe(true)
|
||||
expect(log.some((item) => item.path === "/session" && item.workspace === "ws_a")).toBe(true)
|
||||
expect(log.some((item) => item.path === "/command" && item.workspace === "ws_a")).toBe(true)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
@@ -54,52 +52,3 @@ describe("file.ripgrep", () => {
|
||||
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"]
|
||||
path?: HostPluginApi["state"]["path"]
|
||||
vcs?: HostPluginApi["state"]["vcs"]
|
||||
workspace?: Partial<HostPluginApi["state"]["workspace"]>
|
||||
session?: Partial<HostPluginApi["state"]["session"]>
|
||||
part?: HostPluginApi["state"]["part"]
|
||||
lsp?: HostPluginApi["state"]["lsp"]
|
||||
@@ -276,11 +277,15 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
|
||||
return opts.state?.provider ?? []
|
||||
},
|
||||
get path() {
|
||||
return opts.state?.path ?? { home: "", state: "", config: "", worktree: "", directory: "" }
|
||||
return opts.state?.path ?? { state: "", config: "", worktree: "", directory: "" }
|
||||
},
|
||||
get vcs() {
|
||||
return opts.state?.vcs
|
||||
},
|
||||
workspace: {
|
||||
list: opts.state?.workspace?.list ?? (() => []),
|
||||
get: opts.state?.workspace?.get ?? (() => undefined),
|
||||
},
|
||||
session: {
|
||||
count: opts.state?.session?.count ?? (() => 0),
|
||||
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("status() initializes formatter state per directory", () =>
|
||||
|
||||
143
packages/opencode/test/memory/abort-leak.test.ts
Normal file
143
packages/opencode/test/memory/abort-leak.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WebFetchTool } from "../../src/tool/webfetch"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
|
||||
const projectRoot = path.join(__dirname, "../..")
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
messageID: MessageID.make(""),
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
const MB = 1024 * 1024
|
||||
const ITERATIONS = 50
|
||||
|
||||
const getHeapMB = () => {
|
||||
Bun.gc(true)
|
||||
return process.memoryUsage().heapUsed / MB
|
||||
}
|
||||
|
||||
describe("memory: abort controller leak", () => {
|
||||
test("webfetch does not leak memory over many invocations", async () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const tool = await WebFetchTool.pipe(
|
||||
Effect.flatMap((info) => Effect.promise(() => info.init())),
|
||||
Effect.provide(FetchHttpClient.layer),
|
||||
Effect.runPromise,
|
||||
)
|
||||
|
||||
// Warm up
|
||||
await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {})
|
||||
|
||||
Bun.gc(true)
|
||||
const baseline = getHeapMB()
|
||||
|
||||
// Run many fetches
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {})
|
||||
}
|
||||
|
||||
Bun.gc(true)
|
||||
const after = getHeapMB()
|
||||
const growth = after - baseline
|
||||
|
||||
console.log(`Baseline: ${baseline.toFixed(2)} MB`)
|
||||
console.log(`After ${ITERATIONS} fetches: ${after.toFixed(2)} MB`)
|
||||
console.log(`Growth: ${growth.toFixed(2)} MB`)
|
||||
|
||||
// Memory growth should be minimal - less than 1MB per 10 requests
|
||||
// With the old closure pattern, this would grow ~0.5MB per request
|
||||
expect(growth).toBeLessThan(ITERATIONS / 10)
|
||||
},
|
||||
})
|
||||
}, 60000)
|
||||
|
||||
test("compare closure vs bind pattern directly", async () => {
|
||||
const ITERATIONS = 500
|
||||
|
||||
// Test OLD pattern: arrow function closure
|
||||
// Store closures in a map keyed by content to force retention
|
||||
const closureMap = new Map<string, () => void>()
|
||||
const timers: Timer[] = []
|
||||
const controllers: AbortController[] = []
|
||||
|
||||
Bun.gc(true)
|
||||
Bun.sleepSync(100)
|
||||
const baseline = getHeapMB()
|
||||
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
// Simulate large response body like webfetch would have
|
||||
const content = `${i}:${"x".repeat(50 * 1024)}` // 50KB unique per iteration
|
||||
const controller = new AbortController()
|
||||
controllers.push(controller)
|
||||
|
||||
// OLD pattern - closure captures `content`
|
||||
const handler = () => {
|
||||
// Actually use content so it can't be optimized away
|
||||
if (content.length > 1000000000) controller.abort()
|
||||
}
|
||||
closureMap.set(content, handler)
|
||||
const timeoutId = setTimeout(handler, 30000)
|
||||
timers.push(timeoutId)
|
||||
}
|
||||
|
||||
Bun.gc(true)
|
||||
Bun.sleepSync(100)
|
||||
const after = getHeapMB()
|
||||
const oldGrowth = after - baseline
|
||||
|
||||
console.log(`OLD pattern (closure): ${oldGrowth.toFixed(2)} MB growth (${closureMap.size} closures)`)
|
||||
|
||||
// Cleanup after measuring
|
||||
timers.forEach(clearTimeout)
|
||||
controllers.forEach((c) => c.abort())
|
||||
closureMap.clear()
|
||||
|
||||
// Test NEW pattern: bind
|
||||
Bun.gc(true)
|
||||
Bun.sleepSync(100)
|
||||
const baseline2 = getHeapMB()
|
||||
const handlers2: (() => void)[] = []
|
||||
const timers2: Timer[] = []
|
||||
const controllers2: AbortController[] = []
|
||||
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
const _content = `${i}:${"x".repeat(50 * 1024)}` // 50KB - won't be captured
|
||||
const controller = new AbortController()
|
||||
controllers2.push(controller)
|
||||
|
||||
// NEW pattern - bind doesn't capture surrounding scope
|
||||
const handler = controller.abort.bind(controller)
|
||||
handlers2.push(handler)
|
||||
const timeoutId = setTimeout(handler, 30000)
|
||||
timers2.push(timeoutId)
|
||||
}
|
||||
|
||||
Bun.gc(true)
|
||||
Bun.sleepSync(100)
|
||||
const after2 = getHeapMB()
|
||||
const newGrowth = after2 - baseline2
|
||||
|
||||
// Cleanup after measuring
|
||||
timers2.forEach(clearTimeout)
|
||||
controllers2.forEach((c) => c.abort())
|
||||
handlers2.length = 0
|
||||
|
||||
console.log(`NEW pattern (bind): ${newGrowth.toFixed(2)} MB growth`)
|
||||
console.log(`Improvement: ${(oldGrowth - newGrowth).toFixed(2)} MB saved`)
|
||||
|
||||
expect(newGrowth).toBeLessThanOrEqual(oldGrowth)
|
||||
})
|
||||
})
|
||||
@@ -33,12 +33,11 @@ import { SessionStatus } from "../../src/session/status"
|
||||
import { Skill } from "../../src/skill"
|
||||
import { Shell } from "../../src/shell/shell"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { TaskTool } from "../../src/tool/task"
|
||||
import { ToolRegistry } from "../../src/tool/registry"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { Log } from "../../src/util/log"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { Format } from "../../src/format"
|
||||
import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { reply, TestLLMServer } from "../lib/llm-server"
|
||||
@@ -173,9 +172,6 @@ function makeHttp() {
|
||||
const registry = ToolRegistry.layer.pipe(
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provideMerge(todo),
|
||||
Layer.provideMerge(question),
|
||||
Layer.provideMerge(deps),
|
||||
@@ -732,23 +728,31 @@ it.live(
|
||||
Effect.gen(function* () {
|
||||
const ready = defer<void>()
|
||||
const aborted = defer<void>()
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const { task } = yield* registry.named()
|
||||
const original = task.execute
|
||||
task.execute = async (_args, ctx) => {
|
||||
ready.resolve()
|
||||
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
|
||||
await new Promise<void>(() => {})
|
||||
const original = TaskTool.build
|
||||
TaskTool.build = ((runtime: Parameters<typeof TaskTool.build>[0]) => {
|
||||
const base = original(runtime)
|
||||
return {
|
||||
title: "",
|
||||
metadata: {
|
||||
sessionId: SessionID.make("task"),
|
||||
model: ref,
|
||||
id: base.id,
|
||||
async init() {
|
||||
const next = await base.init()
|
||||
next.execute = async (_args: any, ctx: any) => {
|
||||
ready.resolve()
|
||||
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
|
||||
await new Promise<void>(() => {})
|
||||
return {
|
||||
title: "",
|
||||
metadata: {
|
||||
sessionId: SessionID.make("task"),
|
||||
model: ref,
|
||||
},
|
||||
output: "",
|
||||
}
|
||||
}
|
||||
return next
|
||||
},
|
||||
output: "",
|
||||
}
|
||||
}
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original)))
|
||||
}) as typeof TaskTool.build
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => void (TaskTool.build = original)))
|
||||
|
||||
const { prompt, chat } = yield* boot()
|
||||
const msg = yield* user(chat.id, "hello")
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Log } from "../../src/util/log"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { MessageID, PartID } from "../../src/session/schema"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const projectRoot = path.join(__dirname, "../..")
|
||||
Log.init({ print: false })
|
||||
@@ -141,25 +140,3 @@ describe("step-finish token propagation via Bus event", () => {
|
||||
{ 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,8 +53,6 @@ import { ToolRegistry } from "../../src/tool/registry"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { Format } from "../../src/format"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
@@ -137,9 +135,6 @@ function makeHttp() {
|
||||
const registry = ToolRegistry.layer.pipe(
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provideMerge(todo),
|
||||
Layer.provideMerge(question),
|
||||
Layer.provideMerge(deps),
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { Effect, ManagedRuntime, Layer } from "effect"
|
||||
import { ApplyPatchTool } from "../../src/tool/apply_patch"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import { Format } from "../../src/format"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
|
||||
const runtime = ManagedRuntime.make(Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer))
|
||||
|
||||
const baseCtx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
messageID: MessageID.make(""),
|
||||
@@ -46,8 +40,7 @@ type ToolCtx = typeof baseCtx & {
|
||||
}
|
||||
|
||||
const execute = async (params: { patchText: string }, ctx: ToolCtx) => {
|
||||
const info = await runtime.runPromise(ApplyPatchTool)
|
||||
const tool = await info.init()
|
||||
const tool = await ApplyPatchTool.init()
|
||||
return tool.execute(params, ctx)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Shell } from "../../src/shell/shell"
|
||||
@@ -10,17 +9,6 @@ import { tmpdir } from "../fixture/fixture"
|
||||
import type { Permission } from "../../src/permission"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
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 = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
@@ -130,7 +118,7 @@ describe("tool.bash", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: "echo test",
|
||||
@@ -151,7 +139,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
@@ -172,7 +160,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
@@ -196,7 +184,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
@@ -220,7 +208,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*"
|
||||
@@ -254,7 +242,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
@@ -285,7 +273,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
@@ -313,7 +301,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`
|
||||
await bash.execute(
|
||||
@@ -343,7 +331,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
@@ -371,7 +359,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
@@ -400,7 +388,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
@@ -428,7 +416,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
@@ -460,7 +448,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "")
|
||||
@@ -493,7 +481,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
@@ -520,7 +508,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
@@ -550,7 +538,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
@@ -580,7 +568,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
@@ -609,7 +597,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
@@ -634,7 +622,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
@@ -657,7 +645,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
@@ -685,7 +673,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const want = Filesystem.normalizePathPattern(path.join(outerTmp.path, "*"))
|
||||
|
||||
for (const dir of forms(outerTmp.path)) {
|
||||
@@ -719,7 +707,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const want = glob(path.join(os.tmpdir(), "*"))
|
||||
@@ -749,7 +737,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const want = glob(path.join(os.tmpdir(), "*"))
|
||||
@@ -784,7 +772,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const filepath = path.join(outerTmp.path, "outside.txt")
|
||||
@@ -815,7 +803,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
@@ -835,7 +823,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
@@ -856,7 +844,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
@@ -876,7 +864,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
@@ -897,7 +885,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute({ command: "ls -la", description: "List" }, capture(requests))
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
@@ -913,7 +901,7 @@ describe("tool.bash abort", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const controller = new AbortController()
|
||||
const collected: string[] = []
|
||||
const result = bash.execute(
|
||||
@@ -945,7 +933,7 @@ describe("tool.bash abort", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: `echo started && sleep 60`,
|
||||
@@ -964,7 +952,7 @@ describe("tool.bash abort", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: `echo stdout_msg && echo stderr_msg >&2`,
|
||||
@@ -983,7 +971,7 @@ describe("tool.bash abort", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: `exit 42`,
|
||||
@@ -1000,7 +988,7 @@ describe("tool.bash abort", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const updates: string[] = []
|
||||
const result = await bash.execute(
|
||||
{
|
||||
@@ -1028,7 +1016,7 @@ describe("tool.bash truncation", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const lineCount = Truncate.MAX_LINES + 500
|
||||
const result = await bash.execute(
|
||||
{
|
||||
@@ -1048,7 +1036,7 @@ describe("tool.bash truncation", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const byteCount = Truncate.MAX_BYTES + 10000
|
||||
const result = await bash.execute(
|
||||
{
|
||||
@@ -1068,7 +1056,7 @@ describe("tool.bash truncation", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: "echo hello",
|
||||
@@ -1086,7 +1074,7 @@ describe("tool.bash truncation", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const bash = await BashTool.init()
|
||||
const lineCount = Truncate.MAX_LINES + 100
|
||||
const result = await bash.execute(
|
||||
{
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { afterAll, afterEach, describe, test, expect } from "bun:test"
|
||||
import { afterEach, describe, test, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { EditTool } from "../../src/tool/edit"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { FileTime } from "../../src/file/time"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
|
||||
const ctx = {
|
||||
@@ -29,20 +27,6 @@ async function touch(file: string, time: number) {
|
||||
await fs.utimes(file, date, date)
|
||||
}
|
||||
|
||||
const runtime = ManagedRuntime.make(Layer.mergeAll(LSP.defaultLayer, FileTime.defaultLayer))
|
||||
|
||||
afterAll(async () => {
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
const resolve = () =>
|
||||
runtime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const info = yield* EditTool
|
||||
return yield* Effect.promise(() => info.init())
|
||||
}),
|
||||
)
|
||||
|
||||
describe("tool.edit", () => {
|
||||
describe("creating new files", () => {
|
||||
test("creates new file when oldString is empty", async () => {
|
||||
@@ -52,7 +36,7 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
const edit = await EditTool.init()
|
||||
const result = await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
@@ -77,7 +61,7 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
@@ -107,7 +91,7 @@ describe("tool.edit", () => {
|
||||
const events: string[] = []
|
||||
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
|
||||
|
||||
const edit = await resolve()
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
@@ -135,7 +119,7 @@ describe("tool.edit", () => {
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
const edit = await EditTool.init()
|
||||
const result = await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
@@ -162,7 +146,7 @@ describe("tool.edit", () => {
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
@@ -185,7 +169,7 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
@@ -210,7 +194,7 @@ describe("tool.edit", () => {
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
@@ -233,7 +217,7 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
@@ -265,7 +249,7 @@ describe("tool.edit", () => {
|
||||
await touch(filepath, 2_000)
|
||||
|
||||
// Try to edit with the new content
|
||||
const edit = await resolve()
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
@@ -290,7 +274,7 @@ describe("tool.edit", () => {
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
@@ -323,7 +307,7 @@ describe("tool.edit", () => {
|
||||
const events: string[] = []
|
||||
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
|
||||
|
||||
const edit = await resolve()
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
@@ -351,7 +335,7 @@ describe("tool.edit", () => {
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
@@ -377,7 +361,7 @@ describe("tool.edit", () => {
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
@@ -401,7 +385,7 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
@@ -426,7 +410,7 @@ describe("tool.edit", () => {
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, dirpath)
|
||||
|
||||
const edit = await resolve()
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
@@ -451,7 +435,7 @@ describe("tool.edit", () => {
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
const edit = await EditTool.init()
|
||||
const result = await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
@@ -518,7 +502,7 @@ describe("tool.edit", () => {
|
||||
return await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
const edit = await EditTool.init()
|
||||
const filePath = path.join(tmp.path, "test.txt")
|
||||
await FileTime.read(ctx.sessionID, filePath)
|
||||
await edit.execute(
|
||||
@@ -663,7 +647,7 @@ describe("tool.edit", () => {
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
const edit = await EditTool.init()
|
||||
|
||||
// Two concurrent edits
|
||||
const promise1 = edit.execute(
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { GrepTool } from "../../src/tool/grep"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
|
||||
const runtime = ManagedRuntime.make(Layer.mergeAll(CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
function initGrep() {
|
||||
return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => Effect.promise(() => info.init()))))
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
@@ -31,7 +23,7 @@ describe("tool.grep", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const grep = await initGrep()
|
||||
const grep = await GrepTool.init()
|
||||
const result = await grep.execute(
|
||||
{
|
||||
pattern: "export",
|
||||
@@ -55,7 +47,7 @@ describe("tool.grep", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const grep = await initGrep()
|
||||
const grep = await GrepTool.init()
|
||||
const result = await grep.execute(
|
||||
{
|
||||
pattern: "xyznonexistentpatternxyz123",
|
||||
@@ -80,7 +72,7 @@ describe("tool.grep", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const grep = await initGrep()
|
||||
const grep = await GrepTool.init()
|
||||
const result = await grep.execute(
|
||||
{
|
||||
pattern: "line",
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { Skill } from "../../src/skill"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { Effect } from "effect"
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
@@ -150,9 +148,7 @@ Use this skill.
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const runtime = ManagedRuntime.make(Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer))
|
||||
const info = await runtime.runPromise(SkillTool)
|
||||
const tool = await info.init()
|
||||
const tool = await SkillTool.init()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
|
||||
@@ -6,10 +6,11 @@ import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Session } from "../../src/session"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import type { SessionPrompt } from "../../src/session/prompt"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
import { MessageID, PartID } from "../../src/session/schema"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
|
||||
import { TaskTool } from "../../src/tool/task"
|
||||
import { Tool } from "../../src/tool/tool"
|
||||
import { ToolRegistry } from "../../src/tool/registry"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -23,6 +24,15 @@ const ref = {
|
||||
modelID: ModelID.make("test-model"),
|
||||
}
|
||||
|
||||
const bindTask = (agent: Agent.Interface, config: Config.Interface) =>
|
||||
TaskTool.build({
|
||||
agent,
|
||||
config,
|
||||
cancel: (sessionID) => SessionPrompt.cancel(sessionID),
|
||||
resolvePromptParts: (template) => SessionPrompt.resolvePromptParts(template),
|
||||
prompt: (input) => SessionPrompt.prompt(input),
|
||||
})
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
Agent.defaultLayer,
|
||||
@@ -62,17 +72,6 @@ const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") {
|
||||
return { chat, assistant }
|
||||
})
|
||||
|
||||
function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps {
|
||||
return {
|
||||
cancel() {},
|
||||
resolvePromptParts: async (template) => [{ type: "text", text: template }],
|
||||
prompt: async (input) => {
|
||||
opts?.onPrompt?.(input)
|
||||
return reply(input, opts?.text ?? "done")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function reply(input: Parameters<typeof SessionPrompt.prompt>[0], text: string): MessageV2.WithParts {
|
||||
const id = MessageID.ascending()
|
||||
return {
|
||||
@@ -186,13 +185,27 @@ describe("tool.task", () => {
|
||||
it.live("execute resumes an existing task session from task_id", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
const config = yield* Config.Service
|
||||
const sessions = yield* Session.Service
|
||||
const { chat, assistant } = yield* seed()
|
||||
const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
|
||||
const tool = yield* TaskTool
|
||||
const def = yield* Effect.promise(() => tool.init())
|
||||
let seen: SessionPrompt.PromptInput | undefined
|
||||
const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
|
||||
const def = yield* Tool.init(bindTask(agent, config))
|
||||
const resolve = SessionPrompt.resolvePromptParts
|
||||
const prompt = SessionPrompt.prompt
|
||||
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
|
||||
|
||||
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
|
||||
SessionPrompt.prompt = async (input) => {
|
||||
seen = input
|
||||
return reply(input, "resumed")
|
||||
}
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
SessionPrompt.resolvePromptParts = resolve
|
||||
SessionPrompt.prompt = prompt
|
||||
}),
|
||||
)
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
def.execute(
|
||||
@@ -207,7 +220,6 @@ describe("tool.task", () => {
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
extra: { promptOps },
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: async () => {},
|
||||
@@ -228,13 +240,24 @@ describe("tool.task", () => {
|
||||
it.live("execute asks by default and skips checks when bypassed", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
const config = yield* Config.Service
|
||||
const { chat, assistant } = yield* seed()
|
||||
const tool = yield* TaskTool
|
||||
const def = yield* Effect.promise(() => tool.init())
|
||||
const def = yield* Tool.init(bindTask(agent, config))
|
||||
const resolve = SessionPrompt.resolvePromptParts
|
||||
const prompt = SessionPrompt.prompt
|
||||
const calls: unknown[] = []
|
||||
const promptOps = stubOps()
|
||||
|
||||
const exec = (extra?: Record<string, any>) =>
|
||||
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
|
||||
SessionPrompt.prompt = async (input) => reply(input, "done")
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
SessionPrompt.resolvePromptParts = resolve
|
||||
SessionPrompt.prompt = prompt
|
||||
}),
|
||||
)
|
||||
|
||||
const exec = (extra?: { bypassAgentCheck?: boolean }) =>
|
||||
Effect.promise(() =>
|
||||
def.execute(
|
||||
{
|
||||
@@ -247,7 +270,7 @@ describe("tool.task", () => {
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
extra: { promptOps, ...extra },
|
||||
extra,
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: async (input) => {
|
||||
@@ -277,12 +300,26 @@ describe("tool.task", () => {
|
||||
it.live("execute creates a child when task_id does not exist", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
const config = yield* Config.Service
|
||||
const sessions = yield* Session.Service
|
||||
const { chat, assistant } = yield* seed()
|
||||
const tool = yield* TaskTool
|
||||
const def = yield* Effect.promise(() => tool.init())
|
||||
let seen: SessionPrompt.PromptInput | undefined
|
||||
const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
|
||||
const def = yield* Tool.init(bindTask(agent, config))
|
||||
const resolve = SessionPrompt.resolvePromptParts
|
||||
const prompt = SessionPrompt.prompt
|
||||
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
|
||||
|
||||
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
|
||||
SessionPrompt.prompt = async (input) => {
|
||||
seen = input
|
||||
return reply(input, "created")
|
||||
}
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
SessionPrompt.resolvePromptParts = resolve
|
||||
SessionPrompt.prompt = prompt
|
||||
}),
|
||||
)
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
def.execute(
|
||||
@@ -297,7 +334,6 @@ describe("tool.task", () => {
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
extra: { promptOps },
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: async () => {},
|
||||
@@ -319,12 +355,26 @@ describe("tool.task", () => {
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
const config = yield* Config.Service
|
||||
const sessions = yield* Session.Service
|
||||
const { chat, assistant } = yield* seed()
|
||||
const tool = yield* TaskTool
|
||||
const def = yield* Effect.promise(() => tool.init())
|
||||
let seen: SessionPrompt.PromptInput | undefined
|
||||
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
|
||||
const def = yield* Tool.init(bindTask(agent, config))
|
||||
const resolve = SessionPrompt.resolvePromptParts
|
||||
const prompt = SessionPrompt.prompt
|
||||
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
|
||||
|
||||
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
|
||||
SessionPrompt.prompt = async (input) => {
|
||||
seen = input
|
||||
return reply(input, "done")
|
||||
}
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
SessionPrompt.resolvePromptParts = resolve
|
||||
SessionPrompt.prompt = prompt
|
||||
}),
|
||||
)
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
def.execute(
|
||||
@@ -338,7 +388,6 @@ describe("tool.task", () => {
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
extra: { promptOps },
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: async () => {},
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { afterEach, describe, test, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { WriteTool } from "../../src/tool/write"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import { FileTime } from "../../src/file/time"
|
||||
import { Tool } from "../../src/tool/tool"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test-write-session"),
|
||||
@@ -28,216 +21,333 @@ afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, FileTime.defaultLayer, CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
|
||||
const init = Effect.fn("WriteToolTest.init")(function* () {
|
||||
const info = yield* WriteTool
|
||||
return yield* Effect.promise(() => info.init())
|
||||
})
|
||||
|
||||
const run = Effect.fn("WriteToolTest.run")(function* (
|
||||
args: Tool.InferParameters<typeof WriteTool>,
|
||||
next: Tool.Context = ctx,
|
||||
) {
|
||||
const tool = yield* init()
|
||||
return yield* Effect.promise(() => tool.execute(args, next))
|
||||
})
|
||||
|
||||
const markRead = Effect.fn("WriteToolTest.markRead")(function* (sessionID: string, filepath: string) {
|
||||
const ft = yield* FileTime.Service
|
||||
yield* ft.read(sessionID as any, filepath)
|
||||
})
|
||||
|
||||
describe("tool.write", () => {
|
||||
describe("new file creation", () => {
|
||||
it.live("writes content to new file", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "newfile.txt")
|
||||
const result = yield* run({ filePath: filepath, content: "Hello, World!" })
|
||||
test("writes content to new file", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "newfile.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const write = await WriteTool.init()
|
||||
const result = await write.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
content: "Hello, World!",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.output).toContain("Wrote file successfully")
|
||||
expect(result.metadata.exists).toBe(false)
|
||||
|
||||
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("Hello, World!")
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.live("creates parent directories if needed", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "nested", "deep", "file.txt")
|
||||
yield* run({ filePath: filepath, content: "nested content" })
|
||||
test("creates parent directories if needed", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "nested", "deep", "file.txt")
|
||||
|
||||
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const write = await WriteTool.init()
|
||||
await write.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
content: "nested content",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("nested content")
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.live("handles relative paths by resolving to instance directory", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* run({ filePath: "relative.txt", content: "relative content" })
|
||||
test("handles relative paths by resolving to instance directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
const content = yield* Effect.promise(() => fs.readFile(path.join(dir, "relative.txt"), "utf-8"))
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const write = await WriteTool.init()
|
||||
await write.execute(
|
||||
{
|
||||
filePath: "relative.txt",
|
||||
content: "relative content",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
const content = await fs.readFile(path.join(tmp.path, "relative.txt"), "utf-8")
|
||||
expect(content).toBe("relative content")
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("existing file overwrite", () => {
|
||||
it.live("overwrites existing file content", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "existing.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8"))
|
||||
yield* markRead(ctx.sessionID, filepath)
|
||||
test("overwrites existing file content", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "existing.txt")
|
||||
await fs.writeFile(filepath, "old content", "utf-8")
|
||||
|
||||
const result = yield* run({ filePath: filepath, content: "new content" })
|
||||
// First read the file to satisfy FileTime requirement
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { FileTime } = await import("../../src/file/time")
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const write = await WriteTool.init()
|
||||
const result = await write.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
content: "new content",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.output).toContain("Wrote file successfully")
|
||||
expect(result.metadata.exists).toBe(true)
|
||||
|
||||
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("new content")
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.live("returns diff in metadata for existing files", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "file.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8"))
|
||||
yield* markRead(ctx.sessionID, filepath)
|
||||
test("returns diff in metadata for existing files", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "old", "utf-8")
|
||||
|
||||
const result = yield* run({ filePath: filepath, content: "new" })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { FileTime } = await import("../../src/file/time")
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const write = await WriteTool.init()
|
||||
const result = await write.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
content: "new",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
// Diff should be in metadata
|
||||
expect(result.metadata).toHaveProperty("filepath", filepath)
|
||||
expect(result.metadata).toHaveProperty("exists", true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("file permissions", () => {
|
||||
it.live("sets file permissions when writing sensitive data", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "sensitive.json")
|
||||
yield* run({ filePath: filepath, content: JSON.stringify({ secret: "data" }) })
|
||||
test("sets file permissions when writing sensitive data", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "sensitive.json")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const write = await WriteTool.init()
|
||||
await write.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
content: JSON.stringify({ secret: "data" }),
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
// On Unix systems, check permissions
|
||||
if (process.platform !== "win32") {
|
||||
const stats = yield* Effect.promise(() => fs.stat(filepath))
|
||||
const stats = await fs.stat(filepath)
|
||||
expect(stats.mode & 0o777).toBe(0o644)
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("content types", () => {
|
||||
it.live("writes JSON content", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "data.json")
|
||||
const data = { key: "value", nested: { array: [1, 2, 3] } }
|
||||
yield* run({ filePath: filepath, content: JSON.stringify(data, null, 2) })
|
||||
test("writes JSON content", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "data.json")
|
||||
const data = { key: "value", nested: { array: [1, 2, 3] } }
|
||||
|
||||
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const write = await WriteTool.init()
|
||||
await write.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
content: JSON.stringify(data, null, 2),
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(JSON.parse(content)).toEqual(data)
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.live("writes binary-safe content", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "binary.bin")
|
||||
const content = "Hello\x00World\x01\x02\x03"
|
||||
yield* run({ filePath: filepath, content })
|
||||
test("writes binary-safe content", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "binary.bin")
|
||||
const content = "Hello\x00World\x01\x02\x03"
|
||||
|
||||
const buf = yield* Effect.promise(() => fs.readFile(filepath))
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const write = await WriteTool.init()
|
||||
await write.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
content,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
const buf = await fs.readFile(filepath)
|
||||
expect(buf.toString()).toBe(content)
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.live("writes empty content", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "empty.txt")
|
||||
yield* run({ filePath: filepath, content: "" })
|
||||
test("writes empty content", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "empty.txt")
|
||||
|
||||
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const write = await WriteTool.init()
|
||||
await write.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
content: "",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("")
|
||||
|
||||
const stats = yield* Effect.promise(() => fs.stat(filepath))
|
||||
const stats = await fs.stat(filepath)
|
||||
expect(stats.size).toBe(0)
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.live("writes multi-line content", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "multiline.txt")
|
||||
const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n")
|
||||
yield* run({ filePath: filepath, content: lines })
|
||||
test("writes multi-line content", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "multiline.txt")
|
||||
const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n")
|
||||
|
||||
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const write = await WriteTool.init()
|
||||
await write.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
content: lines,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe(lines)
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.live("handles different line endings", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "crlf.txt")
|
||||
const content = "Line 1\r\nLine 2\r\nLine 3"
|
||||
yield* run({ filePath: filepath, content })
|
||||
test("handles different line endings", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "crlf.txt")
|
||||
const content = "Line 1\r\nLine 2\r\nLine 3"
|
||||
|
||||
const buf = yield* Effect.promise(() => fs.readFile(filepath))
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const write = await WriteTool.init()
|
||||
await write.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
content,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
const buf = await fs.readFile(filepath)
|
||||
expect(buf.toString()).toBe(content)
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it.live("throws error when OS denies write access", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const readonlyPath = path.join(dir, "readonly.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8"))
|
||||
yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444))
|
||||
yield* markRead(ctx.sessionID, readonlyPath)
|
||||
test("throws error when OS denies write access", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const readonlyPath = path.join(tmp.path, "readonly.txt")
|
||||
|
||||
const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit)
|
||||
expect(exit._tag).toBe("Failure")
|
||||
}),
|
||||
),
|
||||
)
|
||||
// Create a read-only file
|
||||
await fs.writeFile(readonlyPath, "test", "utf-8")
|
||||
await fs.chmod(readonlyPath, 0o444)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { FileTime } = await import("../../src/file/time")
|
||||
await FileTime.read(ctx.sessionID, readonlyPath)
|
||||
|
||||
const write = await WriteTool.init()
|
||||
await expect(
|
||||
write.execute(
|
||||
{
|
||||
filePath: readonlyPath,
|
||||
content: "new content",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("title generation", () => {
|
||||
it.live("returns relative path as title", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "src", "components", "Button.tsx")
|
||||
yield* Effect.promise(() => fs.mkdir(path.dirname(filepath), { recursive: true }))
|
||||
test("returns relative path as title", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "src", "components", "Button.tsx")
|
||||
await fs.mkdir(path.dirname(filepath), { recursive: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const write = await WriteTool.init()
|
||||
const result = await write.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
content: "export const Button = () => {}",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
const result = yield* run({ filePath: filepath, content: "export const Button = () => {}" })
|
||||
expect(result.title).toEndWith(path.join("src", "components", "Button.tsx"))
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -272,6 +272,10 @@ export type TuiState = {
|
||||
directory: string
|
||||
}
|
||||
readonly vcs: { branch?: string } | undefined
|
||||
readonly workspace: {
|
||||
list: () => ReadonlyArray<Workspace>
|
||||
get: (workspaceID: string) => Workspace | undefined
|
||||
}
|
||||
session: {
|
||||
count: () => number
|
||||
diff: (sessionID: string) => ReadonlyArray<TuiSidebarFileItem>
|
||||
|
||||
@@ -34,7 +34,6 @@ import type {
|
||||
ExperimentalWorkspaceListResponses,
|
||||
ExperimentalWorkspaceRemoveErrors,
|
||||
ExperimentalWorkspaceRemoveResponses,
|
||||
ExperimentalWorkspaceStatusResponses,
|
||||
FileListResponses,
|
||||
FilePartInput,
|
||||
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
|
||||
*
|
||||
|
||||
@@ -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 = {
|
||||
/**
|
||||
* Display text (1-5 words, concise)
|
||||
@@ -997,7 +988,6 @@ export type Event =
|
||||
| EventCommandExecuted
|
||||
| EventWorkspaceReady
|
||||
| EventWorkspaceFailed
|
||||
| EventWorkspaceStatus
|
||||
| EventQuestionAsked
|
||||
| EventQuestionReplied
|
||||
| EventQuestionRejected
|
||||
@@ -2867,30 +2857,6 @@ export type ExperimentalWorkspaceCreateResponses = {
|
||||
export type ExperimentalWorkspaceCreateResponse =
|
||||
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 = {
|
||||
body?: never
|
||||
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}": {
|
||||
"delete": {
|
||||
"operationId": "experimental.workspace.remove",
|
||||
@@ -8024,33 +7966,6 @@
|
||||
},
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9943,9 +9858,6 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.workspace.failed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.workspace.status"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.question.asked"
|
||||
},
|
||||
|
||||
@@ -213,7 +213,7 @@ https://opencode.ai/zen/v1/models
|
||||
- MiMo V2 Pro Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج.
|
||||
- MiMo V2 Omni Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج.
|
||||
- Qwen3.6 Plus Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج.
|
||||
- Nemotron 3 Super Free (نقاط نهاية NVIDIA المجانية): يُقدَّم بموجب [شروط خدمة النسخة التجريبية من واجهة NVIDIA API](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). للاستخدام التجريبي فقط، وليس للإنتاج أو البيانات الحساسة. تقوم NVIDIA بتسجيل المطالبات والمخرجات لتحسين نماذجها وخدماتها. لا ترسل بيانات شخصية أو سرية.
|
||||
- Nemotron 3 Super Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج.
|
||||
- OpenAI APIs: يتم الاحتفاظ بالطلبات لمدة 30 يوما وفقا لـ [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- Anthropic APIs: يتم الاحتفاظ بالطلبات لمدة 30 يوما وفقا لـ [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
|
||||
|
||||
@@ -225,7 +225,7 @@ i ne koriste vaše podatke za treniranje modela, uz sljedeće izuzetke:
|
||||
- MiMo V2 Pro Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela.
|
||||
- MiMo V2 Omni Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela.
|
||||
- Qwen3.6 Plus Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela.
|
||||
- Nemotron 3 Super Free (besplatni NVIDIA endpointi): Dostupan je prema [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Samo za probnu upotrebu, nije za produkciju niti osjetljive podatke. NVIDIA bilježi promptove i izlaze radi poboljšanja svojih modela i usluga. Nemojte slati lične ili povjerljive podatke.
|
||||
- Nemotron 3 Super Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela.
|
||||
- OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- Anthropic APIs: Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ Alle vores modeller hostes i US. Vores udbydere følger en nul-opbevaringspoliti
|
||||
- MiMo V2 Pro Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen.
|
||||
- MiMo V2 Omni Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen.
|
||||
- Qwen3.6 Plus Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen.
|
||||
- Nemotron 3 Super Free (gratis NVIDIA-endpoints): Leveres under [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Kun til prøvebrug, ikke til produktion eller følsomme data. Prompts og outputs logges af NVIDIA for at forbedre deres modeller og tjenester. Indsend ikke personlige eller fortrolige data.
|
||||
- Nemotron 3 Super Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen.
|
||||
- OpenAI APIs: Anmodninger opbevares i 30 dage i overensstemmelse med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- Anthropic APIs: Anmodninger opbevares i 30 dage i overensstemmelse med [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ Alle unsere Modelle werden in den USA gehostet. Unsere Provider folgen einer Zer
|
||||
- MiMo V2 Pro Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden.
|
||||
- MiMo V2 Omni Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden.
|
||||
- Qwen3.6 Plus Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden.
|
||||
- Nemotron 3 Super Free (kostenlose NVIDIA-Endpunkte): Bereitgestellt gemäß den [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Nur für Testzwecke, nicht für Produktion oder sensible Daten. Eingaben und Ausgaben werden von NVIDIA protokolliert, um seine Modelle und Dienste zu verbessern. Übermitteln Sie keine personenbezogenen oder vertraulichen Daten.
|
||||
- Nemotron 3 Super Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden.
|
||||
- OpenAI APIs: Anfragen werden in Übereinstimmung mit [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 30 Tage lang gespeichert.
|
||||
- Anthropic APIs: Anfragen werden in Übereinstimmung mit [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 30 Tage lang gespeichert.
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ Todos nuestros modelos están alojados en US. Nuestros proveedores siguen una po
|
||||
- MiMo V2 Pro Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo.
|
||||
- MiMo V2 Omni Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo.
|
||||
- Qwen3.6 Plus Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo.
|
||||
- Nemotron 3 Super Free (endpoints gratuitos de NVIDIA): Se ofrece bajo los [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Solo para uso de prueba, no para producción ni datos sensibles. NVIDIA registra los prompts y las salidas para mejorar sus modelos y servicios. No envíes datos personales ni confidenciales.
|
||||
- Nemotron 3 Super Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo.
|
||||
- OpenAI APIs: Las solicitudes se conservan durante 30 días de acuerdo con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- Anthropic APIs: Las solicitudes se conservan durante 30 días de acuerdo con [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ Tous nos modèles sont hébergés aux US. Nos fournisseurs suivent une politique
|
||||
- MiMo V2 Pro Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle.
|
||||
- MiMo V2 Omni Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle.
|
||||
- Qwen3.6 Plus Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle.
|
||||
- Nemotron 3 Super Free (endpoints NVIDIA gratuits) : Fourni dans le cadre des [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Réservé à un usage d'essai, pas à la production ni aux données sensibles. Les prompts et les sorties sont journalisés par NVIDIA pour améliorer ses modèles et services. N'envoyez pas de données personnelles ou confidentielles.
|
||||
- Nemotron 3 Super Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle.
|
||||
- OpenAI APIs : Les requêtes sont conservées pendant 30 jours conformément à [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- Anthropic APIs : Les requêtes sont conservées pendant 30 jours conformément à [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ Tutti i nostri modelli sono ospitati negli US. I nostri provider seguono una pol
|
||||
- MiMo V2 Pro Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello.
|
||||
- MiMo V2 Omni Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello.
|
||||
- Qwen3.6 Plus Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello.
|
||||
- Nemotron 3 Super Free (endpoint NVIDIA gratuiti): fornito secondo i [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Solo per uso di prova, non per produzione o dati sensibili. NVIDIA registra prompt e output per migliorare i propri modelli e servizi. Non inviare dati personali o riservati.
|
||||
- Nemotron 3 Super Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello.
|
||||
- OpenAI APIs: le richieste vengono conservate per 30 giorni in conformità con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- Anthropic APIs: le richieste vengono conservate per 30 giorni in conformità con [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ https://opencode.ai/zen/v1/models
|
||||
- MiMo V2 Pro Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。
|
||||
- MiMo V2 Omni Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。
|
||||
- Qwen3.6 Plus Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。
|
||||
- Nemotron 3 Super Free(NVIDIA の無料エンドポイント): [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) に基づいて提供されます。試用専用であり、本番環境や機密性の高いデータには使用しないでください。プロンプトと出力は、NVIDIA が自社のモデルとサービスを改善するために記録します。個人情報や機密データは送信しないでください。
|
||||
- Nemotron 3 Super Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。
|
||||
- OpenAI APIs: リクエストは [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) に従って 30 日間保持されます。
|
||||
- Anthropic APIs: リクエストは [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) に従って 30 日間保持されます。
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ https://opencode.ai/zen/v1/models
|
||||
- MiMo V2 Pro Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다.
|
||||
- MiMo V2 Omni Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다.
|
||||
- Qwen3.6 Plus Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다.
|
||||
- Nemotron 3 Super Free(NVIDIA 무료 엔드포인트): [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)에 따라 제공됩니다. 평가판 전용이며 프로덕션 환경이나 민감한 데이터에는 사용할 수 없습니다. NVIDIA는 자사 모델과 서비스를 개선하기 위해 프롬프트와 출력을 기록합니다. 개인 정보나 기밀 데이터는 제출하지 마세요.
|
||||
- Nemotron 3 Super Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다.
|
||||
- OpenAI APIs: 요청은 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data)에 따라 30일 동안 보관됩니다.
|
||||
- Anthropic APIs: 요청은 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage)에 따라 30일 동안 보관됩니다.
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ Alle modellene våre hostes i US. Leverandørene våre følger en policy for zer
|
||||
- MiMo V2 Pro Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen.
|
||||
- MiMo V2 Omni Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen.
|
||||
- Qwen3.6 Plus Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen.
|
||||
- Nemotron 3 Super Free (gratis NVIDIA-endepunkter): Leveres under [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Kun for prøvebruk, ikke for produksjon eller sensitive data. Prompter og svar logges av NVIDIA for å forbedre modellene og tjenestene deres. Ikke send inn personopplysninger eller konfidensielle data.
|
||||
- Nemotron 3 Super Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen.
|
||||
- OpenAI APIs: Forespørsler lagres i 30 dager i samsvar med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- Anthropic APIs: Forespørsler lagres i 30 dager i samsvar med [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
|
||||
|
||||
@@ -224,7 +224,7 @@ Wszystkie nasze modele są hostowane w US. Nasi dostawcy stosują politykę zero
|
||||
- MiMo V2 Pro Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu.
|
||||
- MiMo V2 Omni Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu.
|
||||
- Qwen3.6 Plus Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu.
|
||||
- Nemotron 3 Super Free (darmowe endpointy NVIDIA): Udostępniany zgodnie z [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Tylko do użytku próbnego, nie do produkcji ani danych wrażliwych. NVIDIA rejestruje prompty i odpowiedzi, aby ulepszać swoje modele i usługi. Nie przesyłaj danych osobowych ani poufnych.
|
||||
- Nemotron 3 Super Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu.
|
||||
- OpenAI APIs: Żądania są przechowywane przez 30 dni zgodnie z [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- Anthropic APIs: Żądania są przechowywane przez 30 dni zgodnie z [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ Todos os nossos modelos são hospedados nos US. Nossos provedores seguem uma pol
|
||||
- MiMo V2 Pro Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo.
|
||||
- MiMo V2 Omni Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo.
|
||||
- Qwen3.6 Plus Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo.
|
||||
- Nemotron 3 Super Free (endpoints gratuitos da NVIDIA): Fornecido sob os [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Apenas para uso de avaliação, não para produção nem dados sensíveis. A NVIDIA registra prompts e saídas para melhorar seus modelos e serviços. Não envie dados pessoais ou confidenciais.
|
||||
- Nemotron 3 Super Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo.
|
||||
- OpenAI APIs: As solicitações são retidas por 30 dias de acordo com [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- Anthropic APIs: As solicitações são retidas por 30 dias de acordo com [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ https://opencode.ai/zen/v1/models
|
||||
- MiMo V2 Pro Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели.
|
||||
- MiMo V2 Omni Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели.
|
||||
- Qwen3.6 Plus Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели.
|
||||
- Nemotron 3 Super Free (бесплатные эндпоинты NVIDIA): предоставляется в соответствии с [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Только для пробного использования, не для продакшена и не для чувствительных данных. NVIDIA логирует запросы и ответы, чтобы улучшать свои модели и сервисы. Не отправляйте персональные или конфиденциальные данные.
|
||||
- Nemotron 3 Super Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели.
|
||||
- OpenAI APIs: запросы хранятся 30 дней в соответствии с [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- Anthropic APIs: запросы хранятся 30 дней в соответствии с [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
|
||||
|
||||
@@ -211,7 +211,7 @@ https://opencode.ai/zen/v1/models
|
||||
- MiMo V2 Pro Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล
|
||||
- MiMo V2 Omni Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล
|
||||
- Qwen3.6 Plus Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล
|
||||
- Nemotron 3 Super Free (endpoint ฟรีของ NVIDIA): ให้บริการภายใต้ [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) ใช้สำหรับการทดลองเท่านั้น ไม่เหมาะสำหรับ production หรือข้อมูลที่อ่อนไหว NVIDIA จะบันทึก prompt และ output เพื่อนำไปปรับปรุงโมเดลและบริการของตน โปรดอย่าส่งข้อมูลส่วนบุคคลหรือข้อมูลลับ.
|
||||
- Nemotron 3 Super Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล
|
||||
- OpenAI APIs: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- Anthropic APIs: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ Tüm modellerimiz US'de barındırılıyor. Sağlayıcılarımız zero-retention
|
||||
- MiMo V2 Pro Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir.
|
||||
- MiMo V2 Omni Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir.
|
||||
- Qwen3.6 Plus Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir.
|
||||
- Nemotron 3 Super Free (ücretsiz NVIDIA uç noktaları): [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) kapsamında sunulur. Yalnızca deneme amaçlıdır; üretim veya hassas veriler için uygun değildir. NVIDIA, modellerini ve hizmetlerini geliştirmek için promptları ve çıktıları kaydeder. Kişisel veya gizli veri göndermeyin.
|
||||
- Nemotron 3 Super Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir.
|
||||
- OpenAI APIs: İstekler [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) uyarınca 30 gün boyunca saklanır.
|
||||
- Anthropic APIs: İstekler [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) uyarınca 30 gün boyunca saklanır.
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ All our models are hosted in the US. Our providers follow a zero-retention polic
|
||||
- Big Pickle: During its free period, collected data may be used to improve the model.
|
||||
- MiniMax M2.5 Free: During its free period, collected data may be used to improve the model.
|
||||
- Qwen3.6 Plus Free: During its free period, collected data may be used to improve the model.
|
||||
- Nemotron 3 Super Free (NVIDIA free endpoints): Provided under the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Trial use only — not for production or sensitive data. Prompts and outputs are logged by NVIDIA to improve its models and services. Do not submit personal or confidential data.
|
||||
- Nemotron 3 Super Free: During its free period, collected data may be used to improve the model.
|
||||
- OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- Anthropic APIs: Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ https://opencode.ai/zen/v1/models
|
||||
- MiMo V2 Pro Free:在免费期间,收集的数据可能会被用于改进模型。
|
||||
- MiMo V2 Omni Free:在免费期间,收集的数据可能会被用于改进模型。
|
||||
- Qwen3.6 Plus Free:在免费期间,收集的数据可能会被用于改进模型。
|
||||
- Nemotron 3 Super Free(NVIDIA 免费端点):根据 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) 提供。仅供试用,不适用于生产环境或敏感数据。NVIDIA 会记录提示词和输出内容,以改进其模型和服务。请勿提交个人或机密数据。
|
||||
- Nemotron 3 Super Free:在免费期间,收集的数据可能会被用于改进模型。
|
||||
- OpenAI APIs:请求会根据 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 保留 30 天。
|
||||
- Anthropic APIs:请求会根据 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 保留 30 天。
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ https://opencode.ai/zen/v1/models
|
||||
- MiMo V2 Pro Free: 在免費期間,收集到的資料可能會用於改進模型。
|
||||
- MiMo V2 Omni Free: 在免費期間,收集到的資料可能會用於改進模型。
|
||||
- Qwen3.6 Plus Free: 在免費期間,收集到的資料可能會用於改進模型。
|
||||
- Nemotron 3 Super Free(NVIDIA 免費端點):依據 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) 提供。僅供試用,不適用於正式環境或敏感資料。NVIDIA 會記錄提示詞與輸出內容,以改進其模型與服務。請勿提交個人或機密資料。
|
||||
- Nemotron 3 Super Free: 在免費期間,收集到的資料可能會用於改進模型。
|
||||
- OpenAI APIs: 請求會依據 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 保留 30 天。
|
||||
- Anthropic APIs: 請求會依據 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 保留 30 天。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user