Merge branch 'dev' into feat/canceled-prompts-in-history
This commit is contained in:
@@ -332,6 +332,163 @@ export async function withSession<T>(
|
||||
}
|
||||
}
|
||||
|
||||
const seedSystem = [
|
||||
"You are seeding deterministic e2e UI state.",
|
||||
"Follow the user's instruction exactly.",
|
||||
"When asked to call a tool, call exactly that tool exactly once with the exact JSON input.",
|
||||
"Do not call any extra tools.",
|
||||
].join(" ")
|
||||
|
||||
const wait = async <T>(input: { probe: () => Promise<T | undefined>; timeout?: number }) => {
|
||||
const timeout = input.timeout ?? 30_000
|
||||
const end = Date.now() + timeout
|
||||
while (Date.now() < end) {
|
||||
const value = await input.probe()
|
||||
if (value !== undefined) return value
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
}
|
||||
}
|
||||
|
||||
const seed = async <T>(input: {
|
||||
sessionID: string
|
||||
prompt: string
|
||||
sdk: ReturnType<typeof createSdk>
|
||||
probe: () => Promise<T | undefined>
|
||||
timeout?: number
|
||||
attempts?: number
|
||||
}) => {
|
||||
for (let i = 0; i < (input.attempts ?? 2); i++) {
|
||||
await input.sdk.session.promptAsync({
|
||||
sessionID: input.sessionID,
|
||||
agent: "build",
|
||||
system: seedSystem,
|
||||
parts: [{ type: "text", text: input.prompt }],
|
||||
})
|
||||
const value = await wait({ probe: input.probe, timeout: input.timeout })
|
||||
if (value !== undefined) return value
|
||||
}
|
||||
}
|
||||
|
||||
export async function seedSessionQuestion(
|
||||
sdk: ReturnType<typeof createSdk>,
|
||||
input: {
|
||||
sessionID: string
|
||||
questions: Array<{
|
||||
header: string
|
||||
question: string
|
||||
options: Array<{ label: string; description: string }>
|
||||
multiple?: boolean
|
||||
custom?: boolean
|
||||
}>
|
||||
},
|
||||
) {
|
||||
const first = input.questions[0]
|
||||
if (!first) throw new Error("Question seed requires at least one question")
|
||||
|
||||
const text = [
|
||||
"Your only valid response is one question tool call.",
|
||||
`Use this JSON input: ${JSON.stringify({ questions: input.questions })}`,
|
||||
"Do not output plain text.",
|
||||
"After calling the tool, wait for the user response.",
|
||||
].join("\n")
|
||||
|
||||
const result = await seed({
|
||||
sdk,
|
||||
sessionID: input.sessionID,
|
||||
prompt: text,
|
||||
timeout: 30_000,
|
||||
probe: async () => {
|
||||
const list = await sdk.question.list().then((x) => x.data ?? [])
|
||||
return list.find((item) => item.sessionID === input.sessionID && item.questions[0]?.header === first.header)
|
||||
},
|
||||
})
|
||||
|
||||
if (!result) throw new Error("Timed out seeding question request")
|
||||
return { id: result.id }
|
||||
}
|
||||
|
||||
export async function seedSessionPermission(
|
||||
sdk: ReturnType<typeof createSdk>,
|
||||
input: {
|
||||
sessionID: string
|
||||
permission: string
|
||||
patterns: string[]
|
||||
description?: string
|
||||
},
|
||||
) {
|
||||
const text = [
|
||||
"Your only valid response is one bash tool call.",
|
||||
`Use this JSON input: ${JSON.stringify({
|
||||
command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
|
||||
workdir: "/",
|
||||
description: input.description ?? `seed ${input.permission} permission request`,
|
||||
})}`,
|
||||
"Do not output plain text.",
|
||||
].join("\n")
|
||||
|
||||
const result = await seed({
|
||||
sdk,
|
||||
sessionID: input.sessionID,
|
||||
prompt: text,
|
||||
timeout: 30_000,
|
||||
probe: async () => {
|
||||
const list = await sdk.permission.list().then((x) => x.data ?? [])
|
||||
return list.find((item) => item.sessionID === input.sessionID)
|
||||
},
|
||||
})
|
||||
|
||||
if (!result) throw new Error("Timed out seeding permission request")
|
||||
return { id: result.id }
|
||||
}
|
||||
|
||||
export async function seedSessionTodos(
|
||||
sdk: ReturnType<typeof createSdk>,
|
||||
input: {
|
||||
sessionID: string
|
||||
todos: Array<{ content: string; status: string; priority: string }>
|
||||
},
|
||||
) {
|
||||
const text = [
|
||||
"Your only valid response is one todowrite tool call.",
|
||||
`Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
|
||||
"Do not output plain text.",
|
||||
].join("\n")
|
||||
const target = JSON.stringify(input.todos)
|
||||
|
||||
const result = await seed({
|
||||
sdk,
|
||||
sessionID: input.sessionID,
|
||||
prompt: text,
|
||||
timeout: 30_000,
|
||||
probe: async () => {
|
||||
const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
|
||||
if (JSON.stringify(todos) !== target) return
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
if (!result) throw new Error("Timed out seeding todos")
|
||||
return true
|
||||
}
|
||||
|
||||
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
||||
const [questions, permissions] = await Promise.all([
|
||||
sdk.question.list().then((x) => x.data ?? []),
|
||||
sdk.permission.list().then((x) => x.data ?? []),
|
||||
])
|
||||
|
||||
await Promise.all([
|
||||
...questions
|
||||
.filter((item) => item.sessionID === sessionID)
|
||||
.map((item) => sdk.question.reject({ requestID: item.id }).catch(() => undefined)),
|
||||
...permissions
|
||||
.filter((item) => item.sessionID === sessionID)
|
||||
.map((item) => sdk.permission.reply({ requestID: item.id, reply: "reject" }).catch(() => undefined)),
|
||||
])
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export async function openStatusPopover(page: Page) {
|
||||
await defocus(page)
|
||||
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { defocus, createTestProject, cleanupTestProject } from "../actions"
|
||||
import { projectSwitchSelector } from "../selectors"
|
||||
import { dirSlug } from "../utils"
|
||||
import {
|
||||
defocus,
|
||||
createTestProject,
|
||||
cleanupTestProject,
|
||||
openSidebar,
|
||||
setWorkspacesEnabled,
|
||||
sessionIDFromUrl,
|
||||
} from "../actions"
|
||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||
import { createSdk, dirSlug } from "../utils"
|
||||
|
||||
function slugFromUrl(url: string) {
|
||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||
}
|
||||
|
||||
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
@@ -33,3 +45,94 @@ test("can switch between projects from sidebar", async ({ page, withProject }) =
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
})
|
||||
|
||||
test("switching back to a project opens the latest workspace session", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const otherSlug = dirSlug(other)
|
||||
const stamp = Date.now()
|
||||
let rootDir: string | undefined
|
||||
let workspaceDir: string | undefined
|
||||
let sessionID: string | undefined
|
||||
|
||||
try {
|
||||
await withProject(
|
||||
async ({ directory, slug }) => {
|
||||
rootDir = directory
|
||||
await defocus(page)
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, slug, true)
|
||||
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const next = slugFromUrl(page.url())
|
||||
if (!next) return ""
|
||||
if (next === slug) return ""
|
||||
return next
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.not.toBe("")
|
||||
|
||||
const workspaceSlug = slugFromUrl(page.url())
|
||||
workspaceDir = base64Decode(workspaceSlug)
|
||||
await openSidebar(page)
|
||||
|
||||
const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
||||
await expect(workspace).toBeVisible()
|
||||
await workspace.hover()
|
||||
|
||||
const newSession = page.locator(workspaceNewSessionSelector(workspaceSlug)).first()
|
||||
await expect(newSession).toBeVisible()
|
||||
await newSession.click({ force: true })
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
await expect(prompt).toBeVisible()
|
||||
await prompt.fill(`project switch remembers workspace ${stamp}`)
|
||||
await prompt.press("Enter")
|
||||
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
||||
const created = sessionIDFromUrl(page.url())
|
||||
if (!created) throw new Error(`Failed to parse session id from URL: ${page.url()}`)
|
||||
sessionID = created
|
||||
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
|
||||
|
||||
await openSidebar(page)
|
||||
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||
|
||||
const rootButton = page.locator(projectSwitchSelector(slug)).first()
|
||||
await expect(rootButton).toBeVisible()
|
||||
await rootButton.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
} finally {
|
||||
if (sessionID) {
|
||||
const id = sessionID
|
||||
const dirs = [rootDir, workspaceDir].filter((x): x is string => !!x)
|
||||
await Promise.all(
|
||||
dirs.map((directory) =>
|
||||
createSdk(directory)
|
||||
.session.delete({ sessionID: id })
|
||||
.catch(() => undefined),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (workspaceDir) {
|
||||
await cleanupTestProject(workspaceDir)
|
||||
}
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
export const promptSelector = '[data-component="prompt-input"]'
|
||||
export const terminalSelector = '[data-component="terminal"]'
|
||||
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
||||
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
||||
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
|
||||
export const permissionRejectSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(1)`
|
||||
export const permissionAllowAlwaysSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(2)`
|
||||
export const permissionAllowOnceSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(3)`
|
||||
export const sessionTodoDockSelector = '[data-component="session-todo-dock"]'
|
||||
export const sessionTodoToggleSelector = '[data-action="session-todo-toggle"]'
|
||||
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
|
||||
export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
|
||||
|
||||
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
||||
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { clearSessionDockSeed, seedSessionPermission, seedSessionQuestion, seedSessionTodos } from "../actions"
|
||||
import {
|
||||
permissionDockSelector,
|
||||
promptSelector,
|
||||
questionDockSelector,
|
||||
sessionComposerDockSelector,
|
||||
sessionTodoDockSelector,
|
||||
sessionTodoListSelector,
|
||||
sessionTodoToggleButtonSelector,
|
||||
} from "../selectors"
|
||||
|
||||
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
|
||||
|
||||
async function withDockSession<T>(sdk: Sdk, title: string, fn: (session: { id: string; title: string }) => Promise<T>) {
|
||||
const session = await sdk.session.create({ title }).then((r) => r.data)
|
||||
if (!session?.id) throw new Error("Session create did not return an id")
|
||||
return fn(session)
|
||||
}
|
||||
|
||||
test.setTimeout(120_000)
|
||||
|
||||
async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
await clearSessionDockSeed(sdk, sessionID).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock default", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
||||
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
||||
|
||||
await page.locator(promptSelector).click()
|
||||
await expect(page.locator(promptSelector)).toBeFocused()
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock question", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
await seedSessionQuestion(sdk, {
|
||||
sessionID: session.id,
|
||||
questions: [
|
||||
{
|
||||
header: "Need input",
|
||||
question: "Pick one option",
|
||||
options: [
|
||||
{ label: "Continue", description: "Continue now" },
|
||||
{ label: "Stop", description: "Stop here" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const dock = page.locator(questionDockSelector)
|
||||
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
|
||||
await dock.locator('[data-slot="question-option"]').first().click()
|
||||
await dock.getByRole("button", { name: /submit/i }).click()
|
||||
|
||||
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
await seedSessionPermission(sdk, {
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["README.md"],
|
||||
description: "Need permission for command",
|
||||
})
|
||||
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
|
||||
await page
|
||||
.locator(permissionDockSelector)
|
||||
.getByRole("button", { name: /allow once/i })
|
||||
.click()
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
await seedSessionPermission(sdk, {
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["REJECT.md"],
|
||||
})
|
||||
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
|
||||
await page.locator(permissionDockSelector).getByRole("button", { name: /deny/i }).click()
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
await seedSessionPermission(sdk, {
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["README.md"],
|
||||
description: "Need permission for command",
|
||||
})
|
||||
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
|
||||
await page
|
||||
.locator(permissionDockSelector)
|
||||
.getByRole("button", { name: /allow always/i })
|
||||
.click()
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
await seedSessionTodos(sdk, {
|
||||
sessionID: session.id,
|
||||
todos: [
|
||||
{ content: "first task", status: "pending", priority: "high" },
|
||||
{ content: "second task", status: "in_progress", priority: "medium" },
|
||||
],
|
||||
})
|
||||
|
||||
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
|
||||
|
||||
await page.locator(sessionTodoToggleButtonSelector).click()
|
||||
await expect(page.locator(sessionTodoListSelector)).toBeHidden()
|
||||
|
||||
await page.locator(sessionTodoToggleButtonSelector).click()
|
||||
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
|
||||
|
||||
await seedSessionTodos(sdk, {
|
||||
sessionID: session.id,
|
||||
todos: [
|
||||
{ content: "first task", status: "completed", priority: "high" },
|
||||
{ content: "second task", status: "cancelled", priority: "medium" },
|
||||
],
|
||||
})
|
||||
|
||||
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock keyboard", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
await seedSessionQuestion(sdk, {
|
||||
sessionID: session.id,
|
||||
questions: [
|
||||
{
|
||||
header: "Need input",
|
||||
question: "Pick one option",
|
||||
options: [{ label: "Continue", description: "Continue now" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
|
||||
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
||||
await page.keyboard.type("abc")
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.6",
|
||||
"version": "1.2.8",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -427,6 +427,7 @@ export function DialogSelectServer() {
|
||||
}
|
||||
>
|
||||
{(i) => {
|
||||
const key = ServerConnection.key(i)
|
||||
return (
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
||||
<Show
|
||||
@@ -446,8 +447,8 @@ export function DialogSelectServer() {
|
||||
>
|
||||
<ServerRow
|
||||
conn={i}
|
||||
status={store.status[ServerConnection.key(i)]}
|
||||
dimmed={store.status[ServerConnection.key(i)]?.healthy === false}
|
||||
status={store.status[key]}
|
||||
dimmed={store.status[key]?.healthy === false}
|
||||
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
||||
badge={
|
||||
<Show when={defaultUrl() === i.http.url}>
|
||||
@@ -460,7 +461,7 @@ export function DialogSelectServer() {
|
||||
</Show>
|
||||
<Show when={store.editServer.id !== i.http.url}>
|
||||
<div class="flex items-center justify-center gap-5 pl-4">
|
||||
<Show when={current() === i}>
|
||||
<Show when={ServerConnection.key(current()) === key}>
|
||||
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
|
||||
</Show>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useParams } from "@solidjs/router"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||
@@ -1045,12 +1046,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
commandKeybind={command.keybind}
|
||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||
/>
|
||||
<form
|
||||
<DockShellForm
|
||||
onSubmit={handleSubmit}
|
||||
classList={{
|
||||
"group/prompt-input": true,
|
||||
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative z-10": true,
|
||||
"rounded-[12px] overflow-clip focus-within:shadow-xs-border": true,
|
||||
"focus-within:shadow-xs-border": true,
|
||||
"border-icon-info-active border-dashed": store.draggingType !== null,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
@@ -1243,10 +1243,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</form>
|
||||
</DockShellForm>
|
||||
<Show when={store.mode === "normal" || store.mode === "shell"}>
|
||||
<div class="-mt-3.5 bg-background-base border border-border-weak-base relative z-0 rounded-[12px] rounded-tl-0 rounded-tr-0 overflow-clip">
|
||||
<div class="px-2 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
|
||||
<DockTray attach="top">
|
||||
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<Show when={store.mode === "shell"}>
|
||||
<div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}>
|
||||
@@ -1254,7 +1254,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<div class="size-4 shrink-0" />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={store.mode === "normal"}>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
@@ -1354,7 +1353,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="shrink-0" data-component="prompt-mode-toggle">
|
||||
<div class="shrink-0">
|
||||
<RadioGroup
|
||||
options={["shell", "normal"] as const}
|
||||
current={store.mode}
|
||||
@@ -1385,7 +1384,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DockTray>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -73,12 +73,16 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const abort = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return Promise.resolve()
|
||||
|
||||
globalSync.todo.set(sessionID, [])
|
||||
const [, setStore] = globalSync.child(sdk.directory)
|
||||
setStore("todo", sessionID, [])
|
||||
|
||||
const queued = pending.get(sessionID)
|
||||
if (queued) {
|
||||
queued.abort.abort()
|
||||
queued.cleanup()
|
||||
pending.delete(sessionID)
|
||||
globalSync.todo.set(sessionID, undefined)
|
||||
return Promise.resolve()
|
||||
}
|
||||
return sdk.client.session
|
||||
@@ -86,9 +90,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
sessionID,
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
globalSync.todo.set(sessionID, undefined)
|
||||
})
|
||||
}
|
||||
|
||||
const restoreCommentItems = (items: CommentItem[]) => {
|
||||
|
||||
@@ -320,8 +320,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const mod = loaded.mod
|
||||
const g = loaded.ghostty
|
||||
|
||||
const once = { value: false }
|
||||
|
||||
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
||||
const restoreSize =
|
||||
restore &&
|
||||
@@ -416,20 +414,28 @@ export const Terminal = (props: TerminalProps) => {
|
||||
cleanups.push(() => window.removeEventListener("resize", handleResize))
|
||||
}
|
||||
|
||||
if (restore && restoreSize) {
|
||||
t.write(restore, () => {
|
||||
fit.fit()
|
||||
scheduleSize(t.cols, t.rows)
|
||||
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
|
||||
startResize()
|
||||
const write = (data: string) =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (!output) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
output.push(data)
|
||||
output.flush(resolve)
|
||||
})
|
||||
|
||||
if (restore && restoreSize) {
|
||||
await write(restore)
|
||||
fit.fit()
|
||||
scheduleSize(t.cols, t.rows)
|
||||
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
|
||||
startResize()
|
||||
} else {
|
||||
fit.fit()
|
||||
scheduleSize(t.cols, t.rows)
|
||||
if (restore) {
|
||||
t.write(restore, () => {
|
||||
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
|
||||
})
|
||||
await write(restore)
|
||||
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
|
||||
}
|
||||
startResize()
|
||||
}
|
||||
@@ -438,38 +444,32 @@ export const Terminal = (props: TerminalProps) => {
|
||||
// console.log("Scroll position:", ydisp)
|
||||
// })
|
||||
|
||||
const once = { value: false }
|
||||
let closing = false
|
||||
|
||||
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
|
||||
url.searchParams.set("directory", sdk.directory)
|
||||
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
url.username = server.current?.http.username ?? ""
|
||||
url.password = server.current?.http.password ?? ""
|
||||
|
||||
const socket = new WebSocket(url)
|
||||
socket.binaryType = "arraybuffer"
|
||||
ws = socket
|
||||
cleanups.push(() => {
|
||||
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
|
||||
})
|
||||
if (disposed) {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
const handleOpen = () => {
|
||||
local.onConnect?.()
|
||||
scheduleSize(t.cols, t.rows)
|
||||
}
|
||||
socket.addEventListener("open", handleOpen)
|
||||
cleanups.push(() => socket.removeEventListener("open", handleOpen))
|
||||
|
||||
if (socket.readyState === WebSocket.OPEN) handleOpen()
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (disposed) return
|
||||
if (closing) return
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
// WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }).
|
||||
const bytes = new Uint8Array(event.data)
|
||||
if (bytes[0] !== 0) return
|
||||
const json = decoder.decode(bytes.subarray(1))
|
||||
@@ -491,20 +491,20 @@ export const Terminal = (props: TerminalProps) => {
|
||||
cursor += data.length
|
||||
}
|
||||
socket.addEventListener("message", handleMessage)
|
||||
cleanups.push(() => socket.removeEventListener("message", handleMessage))
|
||||
|
||||
const handleError = (error: Event) => {
|
||||
if (disposed) return
|
||||
if (closing) return
|
||||
if (once.value) return
|
||||
once.value = true
|
||||
console.error("WebSocket error:", error)
|
||||
local.onConnectError?.(error)
|
||||
}
|
||||
socket.addEventListener("error", handleError)
|
||||
cleanups.push(() => socket.removeEventListener("error", handleError))
|
||||
|
||||
const handleClose = (event: CloseEvent) => {
|
||||
if (disposed) return
|
||||
if (closing) return
|
||||
// Normal closure (code 1000) means PTY process exited - server event handles cleanup
|
||||
// For other codes (network issues, server restart), trigger error handler
|
||||
if (event.code !== 1000) {
|
||||
@@ -514,7 +514,15 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
}
|
||||
socket.addEventListener("close", handleClose)
|
||||
cleanups.push(() => socket.removeEventListener("close", handleClose))
|
||||
|
||||
cleanups.push(() => {
|
||||
closing = true
|
||||
socket.removeEventListener("open", handleOpen)
|
||||
socket.removeEventListener("message", handleMessage)
|
||||
socket.removeEventListener("error", handleError)
|
||||
socket.removeEventListener("close", handleClose)
|
||||
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close(1000)
|
||||
})
|
||||
}
|
||||
|
||||
void run().catch((err) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
||||
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
||||
import type { Accessor } from "solid-js"
|
||||
|
||||
type PickerPaths = string | string[] | null
|
||||
@@ -58,7 +58,7 @@ export type Platform = {
|
||||
fetch?: typeof fetch
|
||||
|
||||
/** Get the configured default server URL (platform-specific) */
|
||||
getDefaultServerUrl?(): Promise<string | null> | string | null
|
||||
getDefaultServerUrl?(): Promise<string | null>
|
||||
|
||||
/** Set the default server URL to use on app startup (platform-specific) */
|
||||
setDefaultServerUrl?(url: string | null): Promise<void> | void
|
||||
|
||||
@@ -24,11 +24,15 @@ export function serverDisplayName(conn?: ServerConnection.Any) {
|
||||
function projectsKey(key: ServerConnection.Key) {
|
||||
if (!key) return ""
|
||||
if (key === "sidecar") return "local"
|
||||
const host = key.replace(/^https?:\/\//, "").split(":")[0]
|
||||
if (host === "localhost" || host === "127.0.0.1") return "local"
|
||||
if (isLocalHost(key)) return "local"
|
||||
return key
|
||||
}
|
||||
|
||||
function isLocalHost(url: string) {
|
||||
const host = url.replace(/^https?:\/\//, "").split(":")[0]
|
||||
if (host === "localhost" || host === "127.0.0.1") return "local"
|
||||
}
|
||||
|
||||
export namespace ServerConnection {
|
||||
type Base = { displayName?: string }
|
||||
|
||||
@@ -102,15 +106,19 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
}),
|
||||
)
|
||||
|
||||
const allServers = createMemo(
|
||||
(): Array<ServerConnection.Any> => [
|
||||
const allServers = createMemo((): Array<ServerConnection.Any> => {
|
||||
const servers = [
|
||||
...(props.servers ?? []),
|
||||
...store.list.map((value) => ({
|
||||
type: "http" as const,
|
||||
http: typeof value === "string" ? { url: value } : value,
|
||||
})),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
const deduped = new Map(servers.map((conn) => [ServerConnection.key(conn), conn]))
|
||||
|
||||
return [...deduped.values()]
|
||||
})
|
||||
|
||||
const [state, setState] = createStore({
|
||||
active: props.defaultServer,
|
||||
@@ -193,7 +201,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
)
|
||||
const isLocal = createMemo(() => {
|
||||
const c = current()
|
||||
return c?.type === "sidecar" && c.variant === "base"
|
||||
return (c?.type === "sidecar" && c.variant === "base") || (c?.type === "http" && isLocalHost(c.http.url))
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -106,7 +106,7 @@ const platform: Platform = {
|
||||
forward,
|
||||
restart,
|
||||
notify,
|
||||
getDefaultServerUrl: readDefaultServerUrl,
|
||||
getDefaultServerUrl: async () => readDefaultServerUrl(),
|
||||
setDefaultServerUrl: writeDefaultServerUrl,
|
||||
}
|
||||
|
||||
|
||||
@@ -527,8 +527,8 @@ export const dict = {
|
||||
"settings.tab.general": "一般",
|
||||
"settings.tab.shortcuts": "ショートカット",
|
||||
"settings.desktop.section.wsl": "WSL",
|
||||
"settings.desktop.wsl.title": "WSL統合",
|
||||
"settings.desktop.wsl.description": "Windows上のWSL内でOpenCodeサーバーを実行します。",
|
||||
"settings.desktop.wsl.title": "WSL連携",
|
||||
"settings.desktop.wsl.description": "WindowsのWSL環境でOpenCodeサーバーを実行します。",
|
||||
"settings.general.section.appearance": "外観",
|
||||
"settings.general.section.notifications": "システム通知",
|
||||
"settings.general.section.updates": "アップデート",
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function Layout(props: ParentProps) {
|
||||
const [store, setStore, , ready] = persisted(
|
||||
Persist.global("layout.page", ["layout.page.v1"]),
|
||||
createStore({
|
||||
lastSession: {} as { [directory: string]: string },
|
||||
lastProjectSession: {} as { [directory: string]: { directory: string; id: string; at: number } },
|
||||
activeProject: undefined as string | undefined,
|
||||
activeWorkspace: undefined as string | undefined,
|
||||
workspaceOrder: {} as Record<string, string[]>,
|
||||
@@ -1074,11 +1074,37 @@ export default function Layout(props: ParentProps) {
|
||||
dialog.show(() => <DialogSettings />)
|
||||
}
|
||||
|
||||
function projectRoot(directory: string) {
|
||||
const project = layout.projects
|
||||
.list()
|
||||
.find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
|
||||
if (project) return project.worktree
|
||||
|
||||
const known = Object.entries(store.workspaceOrder).find(
|
||||
([root, dirs]) => root === directory || dirs.includes(directory),
|
||||
)
|
||||
if (known) return known[0]
|
||||
|
||||
const [child] = globalSync.child(directory, { bootstrap: false })
|
||||
const id = child.project
|
||||
if (!id) return directory
|
||||
|
||||
const meta = globalSync.data.project.find((item) => item.id === id)
|
||||
return meta?.worktree ?? directory
|
||||
}
|
||||
|
||||
function navigateToProject(directory: string | undefined) {
|
||||
if (!directory) return
|
||||
server.projects.touch(directory)
|
||||
const lastSession = store.lastSession[directory]
|
||||
navigateWithSidebarReset(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
|
||||
const root = projectRoot(directory)
|
||||
server.projects.touch(root)
|
||||
|
||||
const projectSession = store.lastProjectSession[root]
|
||||
if (projectSession?.id) {
|
||||
navigateWithSidebarReset(`/${base64Encode(projectSession.directory)}/session/${projectSession.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
navigateWithSidebarReset(`/${base64Encode(root)}/session`)
|
||||
}
|
||||
|
||||
function navigateToSession(session: Session | undefined) {
|
||||
@@ -1432,7 +1458,8 @@ export default function Layout(props: ParentProps) {
|
||||
if (!dir || !id) return
|
||||
const directory = decode64(dir)
|
||||
if (!directory) return
|
||||
setStore("lastSession", directory, id)
|
||||
const at = Date.now()
|
||||
setStore("lastProjectSession", projectRoot(directory), { directory, id, at })
|
||||
notification.session.markViewed(id)
|
||||
const expanded = untrack(() => store.workspaceExpanded[directory])
|
||||
if (expanded === false) {
|
||||
|
||||
@@ -166,7 +166,7 @@ const SessionHoverPreview = (props: {
|
||||
when={props.hoverReady()}
|
||||
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
|
||||
>
|
||||
<div class="overflow-y-auto max-h-72 h-full">
|
||||
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
|
||||
<MessageNav
|
||||
messages={props.hoverMessages() ?? []}
|
||||
current={undefined}
|
||||
|
||||
@@ -27,7 +27,7 @@ import { SessionReviewTab, type DiffStyle, type SessionReviewTabProps } from "@/
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
import { MessageTimeline } from "@/pages/session/message-timeline"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
import { SessionPromptDock } from "@/pages/session/session-prompt-dock"
|
||||
import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer"
|
||||
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
|
||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||
@@ -54,11 +54,7 @@ export default function Page() {
|
||||
},
|
||||
})
|
||||
|
||||
const blocked = createMemo(() => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return false
|
||||
return !!sync.data.permission[sessionID]?.[0] || !!sync.data.question[sessionID]?.[0]
|
||||
})
|
||||
const composer = createSessionComposerState()
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const workspaceKey = createMemo(() => params.dir ?? "")
|
||||
@@ -401,7 +397,7 @@ export default function Page() {
|
||||
}
|
||||
|
||||
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
||||
if (blocked()) return
|
||||
if (composer.blocked()) return
|
||||
inputRef?.focus()
|
||||
}
|
||||
}
|
||||
@@ -1090,7 +1086,8 @@ export default function Page() {
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<SessionPromptDock
|
||||
<SessionComposerRegion
|
||||
state={composer}
|
||||
centered={centered()}
|
||||
inputRef={(el) => {
|
||||
inputRef = el
|
||||
@@ -1101,6 +1098,7 @@ export default function Page() {
|
||||
comments.clear()
|
||||
resumeScroll()
|
||||
}}
|
||||
onResponseSubmit={resumeScroll}
|
||||
setPromptDockRef={(el) => {
|
||||
promptDock = el
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { SessionComposerRegion } from "./session-composer-region"
|
||||
export { createSessionComposerBlocked, createSessionComposerState } from "./session-composer-state"
|
||||
export type { SessionComposerState } from "./session-composer-state"
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Show, createEffect, createMemo } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
|
||||
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
|
||||
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
|
||||
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
|
||||
|
||||
export function SessionComposerRegion(props: {
|
||||
state: SessionComposerState
|
||||
centered: boolean
|
||||
inputRef: (el: HTMLDivElement) => void
|
||||
newSessionWorktree: string
|
||||
onNewSessionWorktreeReset: () => void
|
||||
onSubmit: () => void
|
||||
onResponseSubmit: () => void
|
||||
setPromptDockRef: (el: HTMLDivElement) => void
|
||||
}) {
|
||||
const params = useParams()
|
||||
const prompt = usePrompt()
|
||||
const language = useLanguage()
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt)
|
||||
|
||||
const previewPrompt = () =>
|
||||
prompt
|
||||
.current()
|
||||
.map((part) => {
|
||||
if (part.type === "file") return `[file:${part.path}]`
|
||||
if (part.type === "agent") return `@${part.name}`
|
||||
if (part.type === "image") return `[image:${part.filename}]`
|
||||
return part.content
|
||||
})
|
||||
.join("")
|
||||
.trim()
|
||||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={props.setPromptDockRef}
|
||||
data-component="session-prompt-dock"
|
||||
class="shrink-0 w-full pb-3 flex flex-col justify-center items-center bg-background-stronger pointer-events-none"
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"w-full px-3 pointer-events-auto": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<Show when={props.state.questionRequest()} keyed>
|
||||
{(request) => (
|
||||
<div>
|
||||
<SessionQuestionDock request={request} onSubmit={props.onResponseSubmit} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={props.state.permissionRequest()} keyed>
|
||||
{(request) => (
|
||||
<div>
|
||||
<SessionPermissionDock
|
||||
request={request}
|
||||
responding={props.state.permissionResponding()}
|
||||
onDecide={(response) => {
|
||||
props.onResponseSubmit()
|
||||
props.state.decide(response)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={!props.state.blocked()}>
|
||||
<Show
|
||||
when={prompt.ready()}
|
||||
fallback={
|
||||
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
|
||||
{handoffPrompt() || language.t("prompt.loading")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={props.state.dock()}>
|
||||
<div
|
||||
classList={{
|
||||
"transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
|
||||
"max-h-[320px]": !props.state.closing(),
|
||||
"max-h-0 pointer-events-none": props.state.closing(),
|
||||
"opacity-0 translate-y-9": props.state.closing() || props.state.opening(),
|
||||
"opacity-100 translate-y-0": !props.state.closing() && !props.state.opening(),
|
||||
}}
|
||||
>
|
||||
<SessionTodoDock
|
||||
todos={props.state.todos()}
|
||||
title={language.t("session.todo.title")}
|
||||
collapseLabel={language.t("session.todo.collapse")}
|
||||
expandLabel={language.t("session.todo.expand")}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
classList={{
|
||||
"relative z-10": true,
|
||||
"transition-[margin] duration-[400ms] ease-out": true,
|
||||
"-mt-9": props.state.dock() && !props.state.closing(),
|
||||
"mt-0": !props.state.dock() || props.state.closing(),
|
||||
}}
|
||||
>
|
||||
<PromptInput
|
||||
ref={props.inputRef}
|
||||
newSessionWorktree={props.newSessionWorktree}
|
||||
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
|
||||
onSubmit={props.onSubmit}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
|
||||
export function createSessionComposerBlocked() {
|
||||
const params = useParams()
|
||||
const sync = useSync()
|
||||
return createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return false
|
||||
return !!sync.data.permission[id]?.[0] || !!sync.data.question[id]?.[0]
|
||||
})
|
||||
}
|
||||
|
||||
export function createSessionComposerState() {
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const globalSync = useGlobalSync()
|
||||
const language = useLanguage()
|
||||
|
||||
const questionRequest = createMemo((): QuestionRequest | undefined => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
return sync.data.question[id]?.[0]
|
||||
})
|
||||
|
||||
const permissionRequest = createMemo((): PermissionRequest | undefined => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
return sync.data.permission[id]?.[0]
|
||||
})
|
||||
|
||||
const blocked = createSessionComposerBlocked()
|
||||
|
||||
const todos = createMemo((): Todo[] => {
|
||||
const id = params.id
|
||||
if (!id) return []
|
||||
return globalSync.data.session_todo[id] ?? []
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
responding: undefined as string | undefined,
|
||||
dock: todos().length > 0,
|
||||
closing: false,
|
||||
opening: false,
|
||||
})
|
||||
|
||||
const permissionResponding = createMemo(() => {
|
||||
const perm = permissionRequest()
|
||||
if (!perm) return false
|
||||
return store.responding === perm.id
|
||||
})
|
||||
|
||||
const decide = (response: "once" | "always" | "reject") => {
|
||||
const perm = permissionRequest()
|
||||
if (!perm) return
|
||||
if (store.responding === perm.id) return
|
||||
|
||||
setStore("responding", perm.id)
|
||||
sdk.client.permission
|
||||
.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
|
||||
.catch((err: unknown) => {
|
||||
const description = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description })
|
||||
})
|
||||
.finally(() => {
|
||||
setStore("responding", (id) => (id === perm.id ? undefined : id))
|
||||
})
|
||||
}
|
||||
|
||||
const done = createMemo(
|
||||
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
|
||||
)
|
||||
|
||||
let timer: number | undefined
|
||||
let raf: number | undefined
|
||||
|
||||
const scheduleClose = () => {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = window.setTimeout(() => {
|
||||
setStore({ dock: false, closing: false })
|
||||
timer = undefined
|
||||
}, 400)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [todos().length, done()] as const,
|
||||
([count, complete], prev) => {
|
||||
if (raf) cancelAnimationFrame(raf)
|
||||
raf = undefined
|
||||
|
||||
if (count === 0) {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
setStore({ dock: false, closing: false, opening: false })
|
||||
return
|
||||
}
|
||||
|
||||
if (!complete) {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
const hidden = !store.dock || store.closing
|
||||
setStore({ dock: true, closing: false })
|
||||
if (hidden) {
|
||||
setStore("opening", true)
|
||||
raf = requestAnimationFrame(() => {
|
||||
setStore("opening", false)
|
||||
raf = undefined
|
||||
})
|
||||
return
|
||||
}
|
||||
setStore("opening", false)
|
||||
return
|
||||
}
|
||||
|
||||
if (prev && prev[1]) {
|
||||
if (store.closing && !timer) scheduleClose()
|
||||
return
|
||||
}
|
||||
|
||||
setStore({ dock: true, opening: false, closing: true })
|
||||
scheduleClose()
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
if (!timer) return
|
||||
window.clearTimeout(timer)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (!raf) return
|
||||
cancelAnimationFrame(raf)
|
||||
})
|
||||
|
||||
return {
|
||||
blocked,
|
||||
questionRequest,
|
||||
permissionRequest,
|
||||
permissionResponding,
|
||||
decide,
|
||||
todos,
|
||||
dock: () => store.dock,
|
||||
closing: () => store.closing,
|
||||
opening: () => store.opening,
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionComposerState = ReturnType<typeof createSessionComposerState>
|
||||
@@ -0,0 +1,74 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
export function SessionPermissionDock(props: {
|
||||
request: PermissionRequest
|
||||
responding: boolean
|
||||
onDecide: (response: "once" | "always" | "reject") => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
|
||||
const toolDescription = () => {
|
||||
const key = `settings.permissions.tool.${props.request.permission}.description`
|
||||
const value = language.t(key as Parameters<typeof language.t>[0])
|
||||
if (value === key) return ""
|
||||
return value
|
||||
}
|
||||
|
||||
return (
|
||||
<DockPrompt
|
||||
kind="permission"
|
||||
header={
|
||||
<div data-slot="permission-row" data-variant="header">
|
||||
<span data-slot="permission-icon">
|
||||
<Icon name="warning" size="normal" />
|
||||
</span>
|
||||
<div data-slot="permission-header-title">{language.t("notification.permission.title")}</div>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<div />
|
||||
<div data-slot="permission-footer-actions">
|
||||
<Button variant="ghost" size="normal" onClick={() => props.onDecide("reject")} disabled={props.responding}>
|
||||
{language.t("ui.permission.deny")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="normal"
|
||||
onClick={() => props.onDecide("always")}
|
||||
disabled={props.responding}
|
||||
>
|
||||
{language.t("ui.permission.allowAlways")}
|
||||
</Button>
|
||||
<Button variant="primary" size="normal" onClick={() => props.onDecide("once")} disabled={props.responding}>
|
||||
{language.t("ui.permission.allowOnce")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Show when={toolDescription()}>
|
||||
<div data-slot="permission-row">
|
||||
<span data-slot="permission-spacer" aria-hidden="true" />
|
||||
<div data-slot="permission-hint">{toolDescription()}</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.request.patterns.length > 0}>
|
||||
<div data-slot="permission-row">
|
||||
<span data-slot="permission-spacer" aria-hidden="true" />
|
||||
<div data-slot="permission-patterns">
|
||||
<For each={props.request.patterns}>
|
||||
{(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</DockPrompt>
|
||||
)
|
||||
}
|
||||
+3
-1
@@ -8,7 +8,7 @@ import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
|
||||
export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
|
||||
export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
|
||||
@@ -115,6 +115,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
||||
const reply = async (answers: QuestionAnswer[]) => {
|
||||
if (store.sending) return
|
||||
|
||||
props.onSubmit()
|
||||
setStore("sending", true)
|
||||
try {
|
||||
await sdk.client.question.reply({ requestID: props.request.id, answers })
|
||||
@@ -128,6 +129,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
||||
const reject = async () => {
|
||||
if (store.sending) return
|
||||
|
||||
props.onSubmit()
|
||||
setStore("sending", true)
|
||||
try {
|
||||
await sdk.client.question.reject({ requestID: props.request.id })
|
||||
+7
-4
@@ -1,5 +1,6 @@
|
||||
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||
import { Checkbox } from "@opencode-ai/ui/checkbox"
|
||||
import { DockTray } from "@opencode-ai/ui/dock-surface"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
@@ -54,13 +55,14 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
|
||||
const preview = createMemo(() => active()?.content ?? "")
|
||||
|
||||
return (
|
||||
<div
|
||||
<DockTray
|
||||
data-component="session-todo-dock"
|
||||
classList={{
|
||||
"bg-background-base border border-border-weak-base relative z-0 rounded-[12px] overflow-clip": true,
|
||||
"h-[78px]": store.collapsed,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-action="session-todo-toggle"
|
||||
class="pl-3 pr-2 py-2 flex items-center gap-2"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -81,6 +83,7 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
|
||||
</Show>
|
||||
<div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}>
|
||||
<IconButton
|
||||
data-action="session-todo-toggle-button"
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
@@ -98,10 +101,10 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div hidden={store.collapsed}>
|
||||
<div data-slot="session-todo-list" hidden={store.collapsed}>
|
||||
<TodoList todos={props.todos} open={!store.collapsed} />
|
||||
</div>
|
||||
</div>
|
||||
</DockTray>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -366,7 +366,10 @@ export function MessageTimeline(props: {
|
||||
}}
|
||||
onClick={props.onAutoScrollInteraction}
|
||||
class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
|
||||
style={{ "--session-title-height": showHeader() ? "40px" : "0px" }}
|
||||
style={{
|
||||
"--session-title-height": showHeader() ? "40px" : "0px",
|
||||
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
|
||||
}}
|
||||
>
|
||||
<Show when={showHeader()}>
|
||||
<div
|
||||
@@ -535,7 +538,7 @@ export function MessageTimeline(props: {
|
||||
classes={{
|
||||
root: "min-w-0 w-full relative",
|
||||
content: "flex flex-col justify-between !overflow-visible",
|
||||
container: "w-full px-4 md:px-6",
|
||||
container: "w-full px-4 md:px-5",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { QuestionDock } from "@/components/question-dock"
|
||||
import { SessionTodoDock } from "@/components/session-todo-dock"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
||||
|
||||
export function SessionPromptDock(props: {
|
||||
centered: boolean
|
||||
inputRef: (el: HTMLDivElement) => void
|
||||
newSessionWorktree: string
|
||||
onNewSessionWorktreeReset: () => void
|
||||
onSubmit: () => void
|
||||
setPromptDockRef: (el: HTMLDivElement) => void
|
||||
}) {
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const globalSync = useGlobalSync()
|
||||
const prompt = usePrompt()
|
||||
const language = useLanguage()
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt)
|
||||
|
||||
const todos = createMemo((): Todo[] => {
|
||||
const id = params.id
|
||||
if (!id) return []
|
||||
return globalSync.data.session_todo[id] ?? []
|
||||
})
|
||||
|
||||
const questionRequest = createMemo((): QuestionRequest | undefined => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
return sync.data.question[sessionID]?.[0]
|
||||
})
|
||||
|
||||
const permissionRequest = createMemo((): PermissionRequest | undefined => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
return sync.data.permission[sessionID]?.[0]
|
||||
})
|
||||
|
||||
const blocked = createMemo(() => !!permissionRequest() || !!questionRequest())
|
||||
|
||||
const previewPrompt = () =>
|
||||
prompt
|
||||
.current()
|
||||
.map((part) => {
|
||||
if (part.type === "file") return `[file:${part.path}]`
|
||||
if (part.type === "agent") return `@${part.name}`
|
||||
if (part.type === "image") return `[image:${part.filename}]`
|
||||
return part.content
|
||||
})
|
||||
.join("")
|
||||
.trim()
|
||||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
|
||||
})
|
||||
|
||||
const [responding, setResponding] = createSignal<string | undefined>()
|
||||
const permissionResponding = () => {
|
||||
const perm = permissionRequest()
|
||||
if (!perm) return false
|
||||
return responding() === perm.id
|
||||
}
|
||||
|
||||
const decide = (response: "once" | "always" | "reject") => {
|
||||
const perm = permissionRequest()
|
||||
if (!perm) return
|
||||
if (responding() === perm.id) return
|
||||
|
||||
setResponding(perm.id)
|
||||
sdk.client.permission
|
||||
.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
.finally(() => {
|
||||
setResponding((id) => (id === perm.id ? undefined : id))
|
||||
})
|
||||
}
|
||||
|
||||
const done = createMemo(
|
||||
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
|
||||
)
|
||||
|
||||
const [dock, setDock] = createSignal(todos().length > 0)
|
||||
const [closing, setClosing] = createSignal(false)
|
||||
const [opening, setOpening] = createSignal(false)
|
||||
let timer: number | undefined
|
||||
let raf: number | undefined
|
||||
|
||||
const scheduleClose = () => {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = window.setTimeout(() => {
|
||||
setDock(false)
|
||||
setClosing(false)
|
||||
timer = undefined
|
||||
}, 400)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [todos().length, done()] as const,
|
||||
([count, complete], prev) => {
|
||||
if (raf) cancelAnimationFrame(raf)
|
||||
raf = undefined
|
||||
|
||||
if (count === 0) {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
setDock(false)
|
||||
setClosing(false)
|
||||
setOpening(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!complete) {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
const wasHidden = !dock() || closing()
|
||||
setDock(true)
|
||||
setClosing(false)
|
||||
if (wasHidden) {
|
||||
setOpening(true)
|
||||
raf = requestAnimationFrame(() => {
|
||||
setOpening(false)
|
||||
raf = undefined
|
||||
})
|
||||
return
|
||||
}
|
||||
setOpening(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (prev && prev[1]) {
|
||||
if (closing() && !timer) scheduleClose()
|
||||
return
|
||||
}
|
||||
|
||||
setDock(true)
|
||||
setOpening(false)
|
||||
setClosing(true)
|
||||
scheduleClose()
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
if (!timer) return
|
||||
window.clearTimeout(timer)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (!raf) return
|
||||
cancelAnimationFrame(raf)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={props.setPromptDockRef}
|
||||
data-component="session-prompt-dock"
|
||||
class="shrink-0 w-full pb-4 flex flex-col justify-center items-center bg-background-stronger pointer-events-none"
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"w-full px-4 pointer-events-auto": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<Show when={questionRequest()} keyed>
|
||||
{(req) => {
|
||||
return (
|
||||
<div>
|
||||
<QuestionDock request={req} />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<Show when={permissionRequest()} keyed>
|
||||
{(perm) => {
|
||||
const toolDescription = () => {
|
||||
const key = `settings.permissions.tool.${perm.permission}.description`
|
||||
const value = language.t(key as Parameters<typeof language.t>[0])
|
||||
if (value === key) return ""
|
||||
return value
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DockPrompt
|
||||
kind="permission"
|
||||
header={
|
||||
<div data-slot="permission-row" data-variant="header">
|
||||
<span data-slot="permission-icon">
|
||||
<Icon name="warning" size="normal" />
|
||||
</span>
|
||||
<div data-slot="permission-header-title">{language.t("notification.permission.title")}</div>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<div />
|
||||
<div data-slot="permission-footer-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
onClick={() => decide("reject")}
|
||||
disabled={permissionResponding()}
|
||||
>
|
||||
{language.t("ui.permission.deny")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="normal"
|
||||
onClick={() => decide("always")}
|
||||
disabled={permissionResponding()}
|
||||
>
|
||||
{language.t("ui.permission.allowAlways")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="normal"
|
||||
onClick={() => decide("once")}
|
||||
disabled={permissionResponding()}
|
||||
>
|
||||
{language.t("ui.permission.allowOnce")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Show when={toolDescription()}>
|
||||
<div data-slot="permission-row">
|
||||
<span data-slot="permission-spacer" aria-hidden="true" />
|
||||
<div data-slot="permission-hint">{toolDescription()}</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={perm.patterns.length > 0}>
|
||||
<div data-slot="permission-row">
|
||||
<span data-slot="permission-spacer" aria-hidden="true" />
|
||||
<div data-slot="permission-patterns">
|
||||
<For each={perm.patterns}>
|
||||
{(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</DockPrompt>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<Show when={!blocked()}>
|
||||
<Show
|
||||
when={prompt.ready()}
|
||||
fallback={
|
||||
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
|
||||
{handoffPrompt() || language.t("prompt.loading")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={dock()}>
|
||||
<div
|
||||
classList={{
|
||||
"transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
|
||||
"max-h-[320px]": !closing(),
|
||||
"max-h-0 pointer-events-none": closing(),
|
||||
"opacity-0 translate-y-9": closing() || opening(),
|
||||
"opacity-100 translate-y-0": !closing() && !opening(),
|
||||
}}
|
||||
>
|
||||
<SessionTodoDock
|
||||
todos={todos()}
|
||||
title={language.t("session.todo.title")}
|
||||
collapseLabel={language.t("session.todo.collapse")}
|
||||
expandLabel={language.t("session.todo.expand")}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
classList={{
|
||||
"relative z-10": true,
|
||||
"transition-[margin] duration-[400ms] ease-out": true,
|
||||
"-mt-9": dock() && !closing(),
|
||||
"mt-0": !dock() || closing(),
|
||||
}}
|
||||
>
|
||||
<PromptInput
|
||||
ref={props.inputRef}
|
||||
newSessionWorktree={props.newSessionWorktree}
|
||||
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
|
||||
onSubmit={props.onSubmit}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.6",
|
||||
"version": "1.2.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.6",
|
||||
"version": "1.2.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.2.6",
|
||||
"version": "1.2.8",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.2.6",
|
||||
"version": "1.2.8",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.2.6",
|
||||
"version": "1.2.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
Generated
+1
@@ -3136,6 +3136,7 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"webkit2gtk",
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -54,6 +54,9 @@ chrono = "0.4"
|
||||
tokio-stream = { version = "0.1.18", features = ["sync"] }
|
||||
process-wrap = { version = "9.0.3", features = ["tokio1"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.62", features = ["Win32_System_Threading"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18.2"
|
||||
webkit2gtk = "=2.0.2"
|
||||
|
||||
@@ -3,7 +3,7 @@ use process_wrap::tokio::CommandWrap;
|
||||
#[cfg(unix)]
|
||||
use process_wrap::tokio::ProcessGroup;
|
||||
#[cfg(windows)]
|
||||
use process_wrap::tokio::{JobObject, KillOnDrop};
|
||||
use process_wrap::tokio::{CommandWrapper, JobObject, KillOnDrop};
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
use std::sync::Arc;
|
||||
@@ -18,9 +18,24 @@ use tokio::{
|
||||
};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tracing::Instrument;
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::System::Threading::{CREATE_NO_WINDOW, CREATE_SUSPENDED};
|
||||
|
||||
use crate::server::get_wsl_config;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
// Keep this as a custom wrapper instead of process_wrap::CreationFlags.
|
||||
// JobObject pre_spawn rewrites creation flags, so this must run after it.
|
||||
struct WinCreationFlags;
|
||||
|
||||
#[cfg(windows)]
|
||||
impl CommandWrapper for WinCreationFlags {
|
||||
fn pre_spawn(&mut self, command: &mut Command, _core: &CommandWrap) -> std::io::Result<()> {
|
||||
command.creation_flags((CREATE_NO_WINDOW | CREATE_SUSPENDED).0);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
const CLI_INSTALL_DIR: &str = ".opencode/bin";
|
||||
const CLI_BINARY_NAME: &str = "opencode";
|
||||
@@ -203,7 +218,7 @@ fn get_user_shell() -> String {
|
||||
}
|
||||
|
||||
fn is_wsl_enabled(_app: &tauri::AppHandle) -> bool {
|
||||
get_wsl_config(_app.clone()).is_ok_and(|v| v.enabled)
|
||||
get_wsl_config(_app.clone()).is_ok_and(|v| v.enabled)
|
||||
}
|
||||
|
||||
fn shell_escape(input: &str) -> String {
|
||||
@@ -318,9 +333,6 @@ pub fn spawn_command(
|
||||
cmd.stderr(Stdio::piped());
|
||||
cmd.stdin(Stdio::null());
|
||||
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
|
||||
let mut wrap = CommandWrap::from(cmd);
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -330,7 +342,7 @@ pub fn spawn_command(
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
wrap.wrap(JobObject).wrap(KillOnDrop);
|
||||
wrap.wrap(JobObject).wrap(WinCreationFlags).wrap(KillOnDrop);
|
||||
}
|
||||
|
||||
let mut child = wrap.spawn()?;
|
||||
|
||||
@@ -426,6 +426,12 @@ void listenForDeepLinks()
|
||||
render(() => {
|
||||
const platform = createPlatform()
|
||||
|
||||
const [defaultServer] = createResource(() =>
|
||||
platform.getDefaultServerUrl?.().then((url) => {
|
||||
if (url) return ServerConnection.key({ type: "http", http: { url } })
|
||||
}),
|
||||
)
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
|
||||
if (link?.href) {
|
||||
@@ -466,9 +472,11 @@ render(() => {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppInterface defaultServer={ServerConnection.key(server)} servers={[server]}>
|
||||
<Inner />
|
||||
</AppInterface>
|
||||
<Show when={!defaultServer.loading}>
|
||||
<AppInterface defaultServer={defaultServer.latest ?? ServerConnection.key(server)} servers={[server]}>
|
||||
<Inner />
|
||||
</AppInterface>
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</ServerGate>
|
||||
@@ -482,19 +490,34 @@ type ServerReadyData = { url: string; password: string | null }
|
||||
// Gate component that waits for the server to be ready
|
||||
function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.Element }) {
|
||||
const [serverData] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
|
||||
if (serverData.state === "errored") throw serverData.error
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={serverData.state !== "pending" && serverData()}
|
||||
when={serverData.state !== "errored"}
|
||||
fallback={
|
||||
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
|
||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base gap-4">
|
||||
<Splash class="w-16 h-20 opacity-50" />
|
||||
<div class="max-w-md px-4 text-center">
|
||||
<p class="text-sm font-medium text-red-400">Failed to start server</p>
|
||||
<p class="mt-2 text-xs text-zinc-400 break-words whitespace-pre-wrap">
|
||||
{String(serverData.error ?? "Unknown error")}
|
||||
</p>
|
||||
</div>
|
||||
<div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(data) => props.children(data)}
|
||||
<Show
|
||||
when={serverData.state !== "pending" && serverData()}
|
||||
fallback={
|
||||
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
|
||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||
<div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(data) => props.children(data)}
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.6",
|
||||
"version": "1.2.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.2.6"
|
||||
version = "1.2.8"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.6/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.8/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.6/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.8/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.6/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.8/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.6/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.8/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.6/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.8/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.2.6",
|
||||
"version": "1.2.8",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.2.6",
|
||||
"version": "1.2.8",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -55,25 +55,26 @@
|
||||
"@actions/core": "1.11.1",
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.14.1",
|
||||
"@ai-sdk/amazon-bedrock": "3.0.79",
|
||||
"@ai-sdk/anthropic": "2.0.62",
|
||||
"@ai-sdk/amazon-bedrock": "3.0.82",
|
||||
"@ai-sdk/anthropic": "2.0.65",
|
||||
"@ai-sdk/azure": "2.0.91",
|
||||
"@ai-sdk/cerebras": "1.0.36",
|
||||
"@ai-sdk/cohere": "2.0.22",
|
||||
"@ai-sdk/deepinfra": "1.0.36",
|
||||
"@ai-sdk/gateway": "2.0.30",
|
||||
"@ai-sdk/google": "2.0.52",
|
||||
"@ai-sdk/google-vertex": "3.0.103",
|
||||
"@ai-sdk/google": "2.0.54",
|
||||
"@ai-sdk/google-vertex": "3.0.106",
|
||||
"@ai-sdk/groq": "2.0.34",
|
||||
"@ai-sdk/mistral": "2.0.27",
|
||||
"@ai-sdk/openai": "2.0.89",
|
||||
"@ai-sdk/openai-compatible": "1.0.32",
|
||||
"@ai-sdk/perplexity": "2.0.23",
|
||||
"@ai-sdk/provider": "2.0.1",
|
||||
"@ai-sdk/provider-utils": "3.0.20",
|
||||
"@ai-sdk/provider-utils": "3.0.21",
|
||||
"@ai-sdk/togetherai": "1.0.34",
|
||||
"@ai-sdk/vercel": "1.0.33",
|
||||
"@ai-sdk/xai": "2.0.51",
|
||||
"@aws-sdk/credential-providers": "3.993.0",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
@@ -106,6 +107,8 @@
|
||||
"diff": "catalog:",
|
||||
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
||||
"fuzzysort": "3.1.0",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
"hono": "catalog:",
|
||||
"hono-openapi": "catalog:",
|
||||
|
||||
@@ -553,8 +553,12 @@ export const GithubRunCommand = cmd({
|
||||
const branch = await checkoutNewBranch(branchPrefix)
|
||||
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
|
||||
const response = await chat(userPrompt, promptFiles)
|
||||
const { dirty, uncommittedChanges } = await branchIsDirty(head)
|
||||
if (dirty) {
|
||||
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, branch)
|
||||
if (switched) {
|
||||
// Agent switched branches (likely created its own branch/PR)
|
||||
console.log("Agent managed its own branch, skipping infrastructure push/PR")
|
||||
console.log("Response:", response)
|
||||
} else if (dirty) {
|
||||
const summary = await summarize(response)
|
||||
// workflow_dispatch has an actor for co-author attribution, schedule does not
|
||||
await pushToNewBranch(summary, branch, uncommittedChanges, isScheduleEvent)
|
||||
@@ -565,7 +569,11 @@ export const GithubRunCommand = cmd({
|
||||
summary,
|
||||
`${response}\n\nTriggered by ${triggerType}${footer({ image: true })}`,
|
||||
)
|
||||
console.log(`Created PR #${pr}`)
|
||||
if (pr) {
|
||||
console.log(`Created PR #${pr}`)
|
||||
} else {
|
||||
console.log("Skipped PR creation (no new commits)")
|
||||
}
|
||||
} else {
|
||||
console.log("Response:", response)
|
||||
}
|
||||
@@ -580,8 +588,11 @@ export const GithubRunCommand = cmd({
|
||||
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
|
||||
const dataPrompt = buildPromptDataForPR(prData)
|
||||
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
|
||||
const { dirty, uncommittedChanges } = await branchIsDirty(head)
|
||||
if (dirty) {
|
||||
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, prData.headRefName)
|
||||
if (switched) {
|
||||
console.log("Agent managed its own branch, skipping infrastructure push")
|
||||
}
|
||||
if (dirty && !switched) {
|
||||
const summary = await summarize(response)
|
||||
await pushToLocalBranch(summary, uncommittedChanges)
|
||||
}
|
||||
@@ -591,12 +602,15 @@ export const GithubRunCommand = cmd({
|
||||
}
|
||||
// Fork PR
|
||||
else {
|
||||
await checkoutForkBranch(prData)
|
||||
const forkBranch = await checkoutForkBranch(prData)
|
||||
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
|
||||
const dataPrompt = buildPromptDataForPR(prData)
|
||||
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
|
||||
const { dirty, uncommittedChanges } = await branchIsDirty(head)
|
||||
if (dirty) {
|
||||
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, forkBranch)
|
||||
if (switched) {
|
||||
console.log("Agent managed its own branch, skipping infrastructure push")
|
||||
}
|
||||
if (dirty && !switched) {
|
||||
const summary = await summarize(response)
|
||||
await pushToForkBranch(summary, prData, uncommittedChanges)
|
||||
}
|
||||
@@ -612,8 +626,13 @@ export const GithubRunCommand = cmd({
|
||||
const issueData = await fetchIssue()
|
||||
const dataPrompt = buildPromptDataForIssue(issueData)
|
||||
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
|
||||
const { dirty, uncommittedChanges } = await branchIsDirty(head)
|
||||
if (dirty) {
|
||||
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, branch)
|
||||
if (switched) {
|
||||
// Agent switched branches (likely created its own branch/PR).
|
||||
// Don't push the stale infrastructure branch — just comment.
|
||||
await createComment(`${response}${footer({ image: true })}`)
|
||||
await removeReaction(commentType)
|
||||
} else if (dirty) {
|
||||
const summary = await summarize(response)
|
||||
await pushToNewBranch(summary, branch, uncommittedChanges, false)
|
||||
const pr = await createPR(
|
||||
@@ -622,7 +641,11 @@ export const GithubRunCommand = cmd({
|
||||
summary,
|
||||
`${response}\n\nCloses #${issueId}${footer({ image: true })}`,
|
||||
)
|
||||
await createComment(`Created PR #${pr}${footer({ image: true })}`)
|
||||
if (pr) {
|
||||
await createComment(`Created PR #${pr}${footer({ image: true })}`)
|
||||
} else {
|
||||
await createComment(`${response}${footer({ image: true })}`)
|
||||
}
|
||||
await removeReaction(commentType)
|
||||
} else {
|
||||
await createComment(`${response}${footer({ image: true })}`)
|
||||
@@ -1068,6 +1091,7 @@ export const GithubRunCommand = cmd({
|
||||
await $`git remote add fork https://github.com/${pr.headRepository.nameWithOwner}.git`
|
||||
await $`git fetch fork --depth=${depth} ${remoteBranch}`
|
||||
await $`git checkout -b ${localBranch} fork/${remoteBranch}`
|
||||
return localBranch
|
||||
}
|
||||
|
||||
function generateBranchName(type: "issue" | "pr" | "schedule" | "dispatch") {
|
||||
@@ -1125,23 +1149,44 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
||||
await $`git push fork HEAD:${remoteBranch}`
|
||||
}
|
||||
|
||||
async function branchIsDirty(originalHead: string) {
|
||||
async function branchIsDirty(originalHead: string, expectedBranch: string) {
|
||||
console.log("Checking if branch is dirty...")
|
||||
// Detect if the agent switched branches during chat (e.g. created
|
||||
// its own branch, committed, and possibly pushed/created a PR).
|
||||
const current = (await $`git rev-parse --abbrev-ref HEAD`).stdout.toString().trim()
|
||||
if (current !== expectedBranch) {
|
||||
console.log(`Branch changed during chat: expected ${expectedBranch}, now on ${current}`)
|
||||
return { dirty: true, uncommittedChanges: false, switched: true }
|
||||
}
|
||||
|
||||
const ret = await $`git status --porcelain`
|
||||
const status = ret.stdout.toString().trim()
|
||||
if (status.length > 0) {
|
||||
return {
|
||||
dirty: true,
|
||||
uncommittedChanges: true,
|
||||
}
|
||||
return { dirty: true, uncommittedChanges: true, switched: false }
|
||||
}
|
||||
const head = await $`git rev-parse HEAD`
|
||||
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
|
||||
return {
|
||||
dirty: head.stdout.toString().trim() !== originalHead,
|
||||
dirty: head !== originalHead,
|
||||
uncommittedChanges: false,
|
||||
switched: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Verify commits exist between base ref and a branch using rev-list.
|
||||
// Falls back to fetching from origin when local refs are missing
|
||||
// (common in shallow clones from actions/checkout).
|
||||
async function hasNewCommits(base: string, head: string) {
|
||||
const result = await $`git rev-list --count ${base}..${head}`.nothrow()
|
||||
if (result.exitCode !== 0) {
|
||||
console.log(`rev-list failed, fetching origin/${base}...`)
|
||||
await $`git fetch origin ${base} --depth=1`.nothrow()
|
||||
const retry = await $`git rev-list --count origin/${base}..${head}`.nothrow()
|
||||
if (retry.exitCode !== 0) return true // assume dirty if we can't tell
|
||||
return parseInt(retry.stdout.toString().trim()) > 0
|
||||
}
|
||||
return parseInt(result.stdout.toString().trim()) > 0
|
||||
}
|
||||
|
||||
async function assertPermissions() {
|
||||
// Only called for non-schedule events, so actor is defined
|
||||
console.log(`Asserting permissions for user ${actor}...`)
|
||||
@@ -1261,7 +1306,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
||||
})
|
||||
}
|
||||
|
||||
async function createPR(base: string, branch: string, title: string, body: string) {
|
||||
async function createPR(base: string, branch: string, title: string, body: string): Promise<number | null> {
|
||||
console.log("Creating pull request...")
|
||||
|
||||
// Check if an open PR already exists for this head→base combination
|
||||
@@ -1286,17 +1331,36 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
||||
console.log(`Failed to check for existing PR: ${e}`)
|
||||
}
|
||||
|
||||
const pr = await withRetry(() =>
|
||||
octoRest.rest.pulls.create({
|
||||
owner,
|
||||
repo,
|
||||
head: branch,
|
||||
base,
|
||||
title,
|
||||
body,
|
||||
}),
|
||||
)
|
||||
return pr.data.number
|
||||
// Verify there are commits between base and head before creating the PR.
|
||||
// In shallow clones, the branch can appear dirty but share the same
|
||||
// commit as the base, causing a 422 from GitHub.
|
||||
if (!(await hasNewCommits(base, branch))) {
|
||||
console.log(`No commits between ${base} and ${branch}, skipping PR creation`)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const pr = await withRetry(() =>
|
||||
octoRest.rest.pulls.create({
|
||||
owner,
|
||||
repo,
|
||||
head: branch,
|
||||
base,
|
||||
title,
|
||||
body,
|
||||
}),
|
||||
)
|
||||
return pr.data.number
|
||||
} catch (e: unknown) {
|
||||
// Handle "No commits between X and Y" validation error from GitHub.
|
||||
// This can happen when the branch was pushed but has no new commits
|
||||
// relative to the base (e.g. shallow clone edge cases).
|
||||
if (e instanceof Error && e.message.includes("No commits between")) {
|
||||
console.log(`GitHub rejected PR: ${e.message}`)
|
||||
return null
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function withRetry<T>(fn: () => Promise<T>, retries = 1, delayMs = 5000): Promise<T> {
|
||||
|
||||
@@ -168,12 +168,17 @@ function websearch(info: ToolProps<typeof WebSearchTool>) {
|
||||
}
|
||||
|
||||
function task(info: ToolProps<typeof TaskTool>) {
|
||||
const agent = Locale.titlecase(info.input.subagent_type)
|
||||
const desc = info.input.description
|
||||
const started = info.part.state.status === "running"
|
||||
const input = info.part.state.input
|
||||
const status = info.part.state.status
|
||||
const subagent =
|
||||
typeof input.subagent_type === "string" && input.subagent_type.trim().length > 0 ? input.subagent_type : "unknown"
|
||||
const agent = Locale.titlecase(subagent)
|
||||
const desc =
|
||||
typeof input.description === "string" && input.description.trim().length > 0 ? input.description : undefined
|
||||
const icon = status === "error" ? "✗" : status === "running" ? "•" : "✓"
|
||||
const name = desc ?? `${agent} Task`
|
||||
inline({
|
||||
icon: started ? "•" : "✓",
|
||||
icon,
|
||||
title: name,
|
||||
description: desc ? `${agent} Agent` : undefined,
|
||||
})
|
||||
@@ -451,9 +456,17 @@ export const RunCommand = cmd({
|
||||
const part = event.properties.part
|
||||
if (part.sessionID !== sessionID) continue
|
||||
|
||||
if (part.type === "tool" && part.state.status === "completed") {
|
||||
if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) {
|
||||
if (emit("tool_use", { part })) continue
|
||||
tool(part)
|
||||
if (part.state.status === "completed") {
|
||||
tool(part)
|
||||
continue
|
||||
}
|
||||
inline({
|
||||
icon: "✗",
|
||||
title: `${part.tool} failed`,
|
||||
})
|
||||
UI.error(part.state.error)
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import path from "path"
|
||||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "../../context/helper"
|
||||
import { appendFile } from "fs/promises"
|
||||
import { appendFile, writeFile } from "fs/promises"
|
||||
|
||||
function calculateFrecency(entry?: { frequency: number; lastOpen: number }): number {
|
||||
if (!entry) return 0
|
||||
@@ -17,9 +18,9 @@ const MAX_FRECENCY_ENTRIES = 1000
|
||||
export const { use: useFrecency, provider: FrecencyProvider } = createSimpleContext({
|
||||
name: "Frecency",
|
||||
init: () => {
|
||||
const frecencyFile = Bun.file(path.join(Global.Path.state, "frecency.jsonl"))
|
||||
const frecencyPath = path.join(Global.Path.state, "frecency.jsonl")
|
||||
onMount(async () => {
|
||||
const text = await frecencyFile.text().catch(() => "")
|
||||
const text = await Filesystem.readText(frecencyPath).catch(() => "")
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
@@ -53,7 +54,7 @@ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleCont
|
||||
|
||||
if (sorted.length > 0) {
|
||||
const content = sorted.map((entry) => JSON.stringify(entry)).join("\n") + "\n"
|
||||
Bun.write(frecencyFile, content).catch(() => {})
|
||||
writeFile(frecencyPath, content).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -68,7 +69,7 @@ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleCont
|
||||
lastOpen: Date.now(),
|
||||
}
|
||||
setStore("data", absolutePath, newEntry)
|
||||
appendFile(frecencyFile.name!, JSON.stringify({ path: absolutePath, ...newEntry }) + "\n").catch(() => {})
|
||||
appendFile(frecencyPath, JSON.stringify({ path: absolutePath, ...newEntry }) + "\n").catch(() => {})
|
||||
|
||||
if (Object.keys(store.data).length > MAX_FRECENCY_ENTRIES) {
|
||||
const sorted = Object.entries(store.data)
|
||||
@@ -76,7 +77,7 @@ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleCont
|
||||
.slice(0, MAX_FRECENCY_ENTRIES)
|
||||
setStore("data", Object.fromEntries(sorted))
|
||||
const content = sorted.map(([path, entry]) => JSON.stringify({ path, ...entry })).join("\n") + "\n"
|
||||
Bun.write(frecencyFile, content).catch(() => {})
|
||||
writeFile(frecencyPath, content).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from "path"
|
||||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { onMount } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { clone } from "remeda"
|
||||
@@ -30,9 +31,9 @@ const MAX_HISTORY_ENTRIES = 50
|
||||
export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({
|
||||
name: "PromptHistory",
|
||||
init: () => {
|
||||
const historyFile = Bun.file(path.join(Global.Path.state, "prompt-history.jsonl"))
|
||||
const historyPath = path.join(Global.Path.state, "prompt-history.jsonl")
|
||||
onMount(async () => {
|
||||
const text = await historyFile.text().catch(() => "")
|
||||
const text = await Filesystem.readText(historyPath).catch(() => "")
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
@@ -51,7 +52,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
|
||||
// Rewrite file with only valid entries to self-heal corruption
|
||||
if (lines.length > 0) {
|
||||
const content = lines.map((line) => JSON.stringify(line)).join("\n") + "\n"
|
||||
writeFile(historyFile.name!, content).catch(() => {})
|
||||
writeFile(historyPath, content).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -98,11 +99,11 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
|
||||
|
||||
if (trimmed) {
|
||||
const content = store.history.map((line) => JSON.stringify(line)).join("\n") + "\n"
|
||||
writeFile(historyFile.name!, content).catch(() => {})
|
||||
writeFile(historyPath, content).catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
appendFile(historyFile.name!, JSON.stringify(entry) + "\n").catch(() => {})
|
||||
appendFile(historyPath, JSON.stringify(entry) + "\n").catch(() => {})
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg } from "@opentui/core"
|
||||
import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
|
||||
import "opentui-spinner/solid"
|
||||
import path from "path"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { EmptyBorder } from "@tui/component/border"
|
||||
@@ -937,26 +939,26 @@ export function Prompt(props: PromptProps) {
|
||||
const isUrl = /^(https?):\/\//.test(filepath)
|
||||
if (!isUrl) {
|
||||
try {
|
||||
const file = Bun.file(filepath)
|
||||
const mime = Filesystem.mimeType(filepath)
|
||||
const filename = path.basename(filepath)
|
||||
// Handle SVG as raw text content, not as base64 image
|
||||
if (file.type === "image/svg+xml") {
|
||||
if (mime === "image/svg+xml") {
|
||||
event.preventDefault()
|
||||
const content = await file.text().catch(() => {})
|
||||
const content = await Filesystem.readText(filepath).catch(() => {})
|
||||
if (content) {
|
||||
pasteText(content, `[SVG: ${file.name ?? "image"}]`)
|
||||
pasteText(content, `[SVG: ${filename ?? "image"}]`)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (file.type.startsWith("image/")) {
|
||||
if (mime.startsWith("image/")) {
|
||||
event.preventDefault()
|
||||
const content = await file
|
||||
.arrayBuffer()
|
||||
const content = await Filesystem.readArrayBuffer(filepath)
|
||||
.then((buffer) => Buffer.from(buffer).toString("base64"))
|
||||
.catch(() => {})
|
||||
if (content) {
|
||||
await pasteImage({
|
||||
filename: file.name,
|
||||
mime: file.type,
|
||||
filename,
|
||||
mime,
|
||||
content,
|
||||
})
|
||||
return
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from "path"
|
||||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { onMount } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { clone } from "remeda"
|
||||
@@ -18,9 +19,9 @@ const MAX_STASH_ENTRIES = 50
|
||||
export const { use: usePromptStash, provider: PromptStashProvider } = createSimpleContext({
|
||||
name: "PromptStash",
|
||||
init: () => {
|
||||
const stashFile = Bun.file(path.join(Global.Path.state, "prompt-stash.jsonl"))
|
||||
const stashPath = path.join(Global.Path.state, "prompt-stash.jsonl")
|
||||
onMount(async () => {
|
||||
const text = await stashFile.text().catch(() => "")
|
||||
const text = await Filesystem.readText(stashPath).catch(() => "")
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
@@ -39,7 +40,7 @@ export const { use: usePromptStash, provider: PromptStashProvider } = createSimp
|
||||
// Rewrite file with only valid entries to self-heal corruption
|
||||
if (lines.length > 0) {
|
||||
const content = lines.map((line) => JSON.stringify(line)).join("\n") + "\n"
|
||||
writeFile(stashFile.name!, content).catch(() => {})
|
||||
writeFile(stashPath, content).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -66,11 +67,11 @@ export const { use: usePromptStash, provider: PromptStashProvider } = createSimp
|
||||
|
||||
if (trimmed) {
|
||||
const content = store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n"
|
||||
writeFile(stashFile.name!, content).catch(() => {})
|
||||
writeFile(stashPath, content).catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
appendFile(stashFile.name!, JSON.stringify(stash) + "\n").catch(() => {})
|
||||
appendFile(stashPath, JSON.stringify(stash) + "\n").catch(() => {})
|
||||
},
|
||||
pop() {
|
||||
if (store.entries.length === 0) return undefined
|
||||
@@ -82,7 +83,7 @@ export const { use: usePromptStash, provider: PromptStashProvider } = createSimp
|
||||
)
|
||||
const content =
|
||||
store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : ""
|
||||
writeFile(stashFile.name!, content).catch(() => {})
|
||||
writeFile(stashPath, content).catch(() => {})
|
||||
return entry
|
||||
},
|
||||
remove(index: number) {
|
||||
@@ -94,7 +95,7 @@ export const { use: usePromptStash, provider: PromptStashProvider } = createSimp
|
||||
)
|
||||
const content =
|
||||
store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : ""
|
||||
writeFile(stashFile.name!, content).catch(() => {})
|
||||
writeFile(stashPath, content).catch(() => {})
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -34,7 +34,6 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||
renderer.setTerminalTitle("")
|
||||
renderer.destroy()
|
||||
win32FlushInputBuffer()
|
||||
await input.onExit?.()
|
||||
if (reason) {
|
||||
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
|
||||
if (formatted) {
|
||||
@@ -43,7 +42,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||
}
|
||||
const text = store.get()
|
||||
if (text) process.stdout.write(text + "\n")
|
||||
process.exit(0)
|
||||
await input.onExit?.()
|
||||
},
|
||||
{
|
||||
message: store,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { createSignal, type Setter } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
@@ -9,10 +10,9 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
|
||||
init: () => {
|
||||
const [ready, setReady] = createSignal(false)
|
||||
const [store, setStore] = createStore<Record<string, any>>()
|
||||
const file = Bun.file(path.join(Global.Path.state, "kv.json"))
|
||||
const filePath = path.join(Global.Path.state, "kv.json")
|
||||
|
||||
file
|
||||
.json()
|
||||
Filesystem.readJson(filePath)
|
||||
.then((x) => {
|
||||
setStore(x)
|
||||
})
|
||||
@@ -44,7 +44,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
|
||||
},
|
||||
set(key: string, value: any) {
|
||||
setStore(key, value)
|
||||
Bun.write(file, JSON.stringify(store, null, 2))
|
||||
Filesystem.writeJson(filePath, store)
|
||||
},
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Provider } from "@/provider/provider"
|
||||
import { useArgs } from "./args"
|
||||
import { useSDK } from "./sdk"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
|
||||
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
name: "Local",
|
||||
@@ -119,7 +120,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
variant: {},
|
||||
})
|
||||
|
||||
const file = Bun.file(path.join(Global.Path.state, "model.json"))
|
||||
const filePath = path.join(Global.Path.state, "model.json")
|
||||
const state = {
|
||||
pending: false,
|
||||
}
|
||||
@@ -130,19 +131,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
return
|
||||
}
|
||||
state.pending = false
|
||||
Bun.write(
|
||||
file,
|
||||
JSON.stringify({
|
||||
recent: modelStore.recent,
|
||||
favorite: modelStore.favorite,
|
||||
variant: modelStore.variant,
|
||||
}),
|
||||
)
|
||||
Filesystem.writeJson(filePath, {
|
||||
recent: modelStore.recent,
|
||||
favorite: modelStore.favorite,
|
||||
variant: modelStore.variant,
|
||||
})
|
||||
}
|
||||
|
||||
file
|
||||
.json()
|
||||
.then((x) => {
|
||||
Filesystem.readJson(filePath)
|
||||
.then((x: any) => {
|
||||
if (Array.isArray(x.recent)) setModelStore("recent", x.recent)
|
||||
if (Array.isArray(x.favorite)) setModelStore("favorite", x.favorite)
|
||||
if (typeof x.variant === "object" && x.variant !== null) setModelStore("variant", x.variant)
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "path"
|
||||
import { createEffect, createMemo, onMount } from "solid-js"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { Glob } from "../../../../util/glob"
|
||||
import aura from "./theme/aura.json" with { type: "json" }
|
||||
import ayu from "./theme/ayu.json" with { type: "json" }
|
||||
import catppuccin from "./theme/catppuccin.json" with { type: "json" }
|
||||
@@ -391,7 +392,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
},
|
||||
})
|
||||
|
||||
const CUSTOM_THEME_GLOB = new Bun.Glob("themes/*.json")
|
||||
async function getCustomThemes() {
|
||||
const directories = [
|
||||
Global.Path.config,
|
||||
@@ -405,14 +405,14 @@ async function getCustomThemes() {
|
||||
|
||||
const result: Record<string, ThemeJson> = {}
|
||||
for (const dir of directories) {
|
||||
for await (const item of CUSTOM_THEME_GLOB.scan({
|
||||
absolute: true,
|
||||
followSymlinks: true,
|
||||
dot: true,
|
||||
for (const item of await Glob.scan("themes/*.json", {
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})) {
|
||||
const name = path.basename(item, ".json")
|
||||
result[name] = await Bun.file(item).json()
|
||||
result[name] = await Filesystem.readJson(item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -98,6 +98,7 @@ const context = createContext<{
|
||||
showThinking: () => boolean
|
||||
showTimestamps: () => boolean
|
||||
showDetails: () => boolean
|
||||
showGenericToolOutput: () => boolean
|
||||
diffWrapMode: () => "word" | "none"
|
||||
sync: ReturnType<typeof useSync>
|
||||
}>()
|
||||
@@ -152,6 +153,7 @@ export function Session() {
|
||||
const [showHeader, setShowHeader] = kv.signal("header_visible", true)
|
||||
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
|
||||
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
|
||||
const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false)
|
||||
|
||||
const wide = createMemo(() => dimensions().width > 120)
|
||||
const sidebarVisible = createMemo(() => {
|
||||
@@ -600,6 +602,15 @@ export function Session() {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: showGenericToolOutput() ? "Hide generic tool output" : "Show generic tool output",
|
||||
value: "session.toggle.generic_tool_output",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
setShowGenericToolOutput((prev) => !prev)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Page up",
|
||||
value: "session.page.up",
|
||||
@@ -974,6 +985,7 @@ export function Session() {
|
||||
showThinking,
|
||||
showTimestamps,
|
||||
showDetails,
|
||||
showGenericToolOutput,
|
||||
diffWrapMode,
|
||||
sync,
|
||||
}}
|
||||
@@ -1508,10 +1520,40 @@ type ToolProps<T extends Tool.Info> = {
|
||||
part: ToolPart
|
||||
}
|
||||
function GenericTool(props: ToolProps<any>) {
|
||||
const { theme } = useTheme()
|
||||
const ctx = use()
|
||||
const output = createMemo(() => props.output?.trim() ?? "")
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const lines = createMemo(() => output().split("\n"))
|
||||
const maxLines = 3
|
||||
const overflow = createMemo(() => lines().length > maxLines)
|
||||
const limited = createMemo(() => {
|
||||
if (expanded() || !overflow()) return output()
|
||||
return [...lines().slice(0, maxLines), "…"].join("\n")
|
||||
})
|
||||
|
||||
return (
|
||||
<InlineTool icon="⚙" pending="Writing command..." complete={true} part={props.part}>
|
||||
{props.tool} {input(props.input)}
|
||||
</InlineTool>
|
||||
<Show
|
||||
when={props.output && ctx.showGenericToolOutput()}
|
||||
fallback={
|
||||
<InlineTool icon="⚙" pending="Writing command..." complete={true} part={props.part}>
|
||||
{props.tool} {input(props.input)}
|
||||
</InlineTool>
|
||||
}
|
||||
>
|
||||
<BlockTool
|
||||
title={`# ${props.tool} ${input(props.input)}`}
|
||||
part={props.part}
|
||||
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
|
||||
>
|
||||
<box gap={1}>
|
||||
<text fg={theme.text}>{limited()}</text>
|
||||
<Show when={overflow()}>
|
||||
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</BlockTool>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@ import { tui } from "./app"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { type rpc } from "./worker"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { UI } from "@/cli/ui"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Log } from "@/util/log"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import type { Event } from "@opencode-ai/sdk/v2"
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
@@ -99,7 +101,7 @@ export const TuiThreadCommand = cmd({
|
||||
const distWorker = new URL("./cli/cmd/tui/worker.js", import.meta.url)
|
||||
const workerPath = await iife(async () => {
|
||||
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
|
||||
if (await Bun.file(distWorker).exists()) return distWorker
|
||||
if (await Filesystem.exists(fileURLToPath(distWorker))) return distWorker
|
||||
return localWorker
|
||||
})
|
||||
try {
|
||||
|
||||
@@ -104,6 +104,9 @@ export namespace UI {
|
||||
}
|
||||
|
||||
export function error(message: string) {
|
||||
if (message.startsWith("Error: ")) {
|
||||
message = message.slice("Error: ".length)
|
||||
}
|
||||
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import { constants, existsSync } from "fs"
|
||||
import { Bus } from "@/bus"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Event } from "../server/event"
|
||||
import { Glob } from "../util/glob"
|
||||
import { PackageRegistry } from "@/bun/registry"
|
||||
import { proxied } from "@/util/proxied"
|
||||
import { iife } from "@/util/iife"
|
||||
@@ -89,7 +90,13 @@ export namespace Config {
|
||||
const remoteConfig = wellknown.config ?? {}
|
||||
// Add $schema to prevent load() from trying to write back to a non-existent file
|
||||
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
|
||||
result = merge(result, await load(JSON.stringify(remoteConfig), `${key}/.well-known/opencode`))
|
||||
result = merge(
|
||||
result,
|
||||
await load(JSON.stringify(remoteConfig), {
|
||||
dir: path.dirname(`${key}/.well-known/opencode`),
|
||||
source: `${key}/.well-known/opencode`,
|
||||
}),
|
||||
)
|
||||
log.debug("loaded remote config from well-known", { url: key })
|
||||
}
|
||||
}
|
||||
@@ -177,8 +184,14 @@ export namespace Config {
|
||||
}
|
||||
|
||||
// Inline config content overrides all non-managed config sources.
|
||||
if (Flag.OPENCODE_CONFIG_CONTENT) {
|
||||
result = merge(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
|
||||
if (process.env.OPENCODE_CONFIG_CONTENT) {
|
||||
result = merge(
|
||||
result,
|
||||
await load(process.env.OPENCODE_CONFIG_CONTENT, {
|
||||
dir: Instance.directory,
|
||||
source: "OPENCODE_CONFIG_CONTENT",
|
||||
}),
|
||||
)
|
||||
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
||||
}
|
||||
|
||||
@@ -339,14 +352,13 @@ export namespace Config {
|
||||
return ext.length ? file.slice(0, -ext.length) : file
|
||||
}
|
||||
|
||||
const COMMAND_GLOB = new Bun.Glob("{command,commands}/**/*.md")
|
||||
async function loadCommand(dir: string) {
|
||||
const result: Record<string, Command> = {}
|
||||
for await (const item of COMMAND_GLOB.scan({
|
||||
absolute: true,
|
||||
followSymlinks: true,
|
||||
dot: true,
|
||||
for (const item of await Glob.scan("{command,commands}/**/*.md", {
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})) {
|
||||
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
@@ -378,15 +390,14 @@ export namespace Config {
|
||||
return result
|
||||
}
|
||||
|
||||
const AGENT_GLOB = new Bun.Glob("{agent,agents}/**/*.md")
|
||||
async function loadAgent(dir: string) {
|
||||
const result: Record<string, Agent> = {}
|
||||
|
||||
for await (const item of AGENT_GLOB.scan({
|
||||
absolute: true,
|
||||
followSymlinks: true,
|
||||
dot: true,
|
||||
for (const item of await Glob.scan("{agent,agents}/**/*.md", {
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})) {
|
||||
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
@@ -418,14 +429,13 @@ export namespace Config {
|
||||
return result
|
||||
}
|
||||
|
||||
const MODE_GLOB = new Bun.Glob("{mode,modes}/*.md")
|
||||
async function loadMode(dir: string) {
|
||||
const result: Record<string, Agent> = {}
|
||||
for await (const item of MODE_GLOB.scan({
|
||||
absolute: true,
|
||||
followSymlinks: true,
|
||||
dot: true,
|
||||
for (const item of await Glob.scan("{mode,modes}/*.md", {
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})) {
|
||||
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
@@ -455,15 +465,14 @@ export namespace Config {
|
||||
return result
|
||||
}
|
||||
|
||||
const PLUGIN_GLOB = new Bun.Glob("{plugin,plugins}/*.{ts,js}")
|
||||
async function loadPlugin(dir: string) {
|
||||
const plugins: string[] = []
|
||||
|
||||
for await (const item of PLUGIN_GLOB.scan({
|
||||
absolute: true,
|
||||
followSymlinks: true,
|
||||
dot: true,
|
||||
for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})) {
|
||||
plugins.push(pathToFileURL(item).href)
|
||||
}
|
||||
@@ -1236,24 +1245,27 @@ export namespace Config {
|
||||
throw new JsonError({ path: filepath }, { cause: err })
|
||||
})
|
||||
if (!text) return {}
|
||||
return load(text, filepath)
|
||||
return load(text, { path: filepath })
|
||||
}
|
||||
|
||||
async function load(text: string, configFilepath: string) {
|
||||
async function load(text: string, options: { path: string } | { dir: string; source: string }) {
|
||||
const original = text
|
||||
const configDir = "path" in options ? path.dirname(options.path) : options.dir
|
||||
const source = "path" in options ? options.path : options.source
|
||||
const isFile = "path" in options
|
||||
|
||||
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
||||
return process.env[varName] || ""
|
||||
})
|
||||
|
||||
const fileMatches = text.match(/\{file:[^}]+\}/g)
|
||||
if (fileMatches) {
|
||||
const configDir = path.dirname(configFilepath)
|
||||
const lines = text.split("\n")
|
||||
|
||||
for (const match of fileMatches) {
|
||||
const lineIndex = lines.findIndex((line) => line.includes(match))
|
||||
if (lineIndex !== -1 && lines[lineIndex].trim().startsWith("//")) {
|
||||
continue // Skip if line is commented
|
||||
continue
|
||||
}
|
||||
let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "")
|
||||
if (filePath.startsWith("~/")) {
|
||||
@@ -1261,21 +1273,22 @@ export namespace Config {
|
||||
}
|
||||
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
|
||||
const fileContent = (
|
||||
await Filesystem.readText(resolvedPath).catch((error: any) => {
|
||||
const errMsg = `bad file reference: "${match}"`
|
||||
if (error.code === "ENOENT") {
|
||||
throw new InvalidError(
|
||||
{
|
||||
path: configFilepath,
|
||||
message: errMsg + ` ${resolvedPath} does not exist`,
|
||||
},
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
throw new InvalidError({ path: configFilepath, message: errMsg }, { cause: error })
|
||||
})
|
||||
await Bun.file(resolvedPath)
|
||||
.text()
|
||||
.catch((error) => {
|
||||
const errMsg = `bad file reference: "${match}"`
|
||||
if (error.code === "ENOENT") {
|
||||
throw new InvalidError(
|
||||
{
|
||||
path: source,
|
||||
message: errMsg + ` ${resolvedPath} does not exist`,
|
||||
},
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
throw new InvalidError({ path: source, message: errMsg }, { cause: error })
|
||||
})
|
||||
).trim()
|
||||
// escape newlines/quotes, strip outer quotes
|
||||
text = text.replace(match, () => JSON.stringify(fileContent).slice(1, -1))
|
||||
}
|
||||
}
|
||||
@@ -1299,25 +1312,24 @@ export namespace Config {
|
||||
.join("\n")
|
||||
|
||||
throw new JsonError({
|
||||
path: configFilepath,
|
||||
path: source,
|
||||
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = Info.safeParse(data)
|
||||
if (parsed.success) {
|
||||
if (!parsed.data.$schema) {
|
||||
if (!parsed.data.$schema && isFile) {
|
||||
parsed.data.$schema = "https://opencode.ai/config.json"
|
||||
// Write the $schema to the original text to preserve variables like {env:VAR}
|
||||
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
|
||||
await Filesystem.write(configFilepath, updated).catch(() => {})
|
||||
await Bun.write(options.path, updated).catch(() => {})
|
||||
}
|
||||
const data = parsed.data
|
||||
if (data.plugin) {
|
||||
if (data.plugin && isFile) {
|
||||
for (let i = 0; i < data.plugin.length; i++) {
|
||||
const plugin = data.plugin[i]
|
||||
try {
|
||||
data.plugin[i] = import.meta.resolve!(plugin, configFilepath)
|
||||
data.plugin[i] = import.meta.resolve!(plugin, options.path)
|
||||
} catch (err) {}
|
||||
}
|
||||
}
|
||||
@@ -1325,7 +1337,7 @@ export namespace Config {
|
||||
}
|
||||
|
||||
throw new InvalidError({
|
||||
path: configFilepath,
|
||||
path: source,
|
||||
issues: parsed.error.issues,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { sep } from "node:path"
|
||||
import { Glob } from "../util/glob"
|
||||
|
||||
export namespace FileIgnore {
|
||||
const FOLDERS = new Set([
|
||||
@@ -53,19 +54,17 @@ export namespace FileIgnore {
|
||||
"**/.nyc_output/**",
|
||||
]
|
||||
|
||||
const FILE_GLOBS = FILES.map((p) => new Bun.Glob(p))
|
||||
|
||||
export const PATTERNS = [...FILES, ...FOLDERS]
|
||||
|
||||
export function match(
|
||||
filepath: string,
|
||||
opts?: {
|
||||
extra?: Bun.Glob[]
|
||||
whitelist?: Bun.Glob[]
|
||||
extra?: string[]
|
||||
whitelist?: string[]
|
||||
},
|
||||
) {
|
||||
for (const glob of opts?.whitelist || []) {
|
||||
if (glob.match(filepath)) return false
|
||||
for (const pattern of opts?.whitelist || []) {
|
||||
if (Glob.match(pattern, filepath)) return false
|
||||
}
|
||||
|
||||
const parts = filepath.split(sep)
|
||||
@@ -74,8 +73,8 @@ export namespace FileIgnore {
|
||||
}
|
||||
|
||||
const extra = opts?.extra || []
|
||||
for (const glob of [...FILE_GLOBS, ...extra]) {
|
||||
if (glob.match(filepath)) return true
|
||||
for (const pattern of [...FILES, ...extra]) {
|
||||
if (Glob.match(pattern, filepath)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
@@ -166,7 +166,6 @@ export namespace File {
|
||||
"efi",
|
||||
"rom",
|
||||
"com",
|
||||
"bat",
|
||||
"cmd",
|
||||
"ps1",
|
||||
"sh",
|
||||
@@ -203,11 +202,77 @@ export namespace File {
|
||||
"x3f",
|
||||
])
|
||||
|
||||
const textExtensions = new Set([
|
||||
"ts",
|
||||
"tsx",
|
||||
"mts",
|
||||
"cts",
|
||||
"mtsx",
|
||||
"ctsx",
|
||||
"js",
|
||||
"jsx",
|
||||
"mjs",
|
||||
"cjs",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"fish",
|
||||
"ps1",
|
||||
"psm1",
|
||||
"cmd",
|
||||
"bat",
|
||||
"json",
|
||||
"jsonc",
|
||||
"json5",
|
||||
"yaml",
|
||||
"yml",
|
||||
"toml",
|
||||
"md",
|
||||
"mdx",
|
||||
"txt",
|
||||
"xml",
|
||||
"html",
|
||||
"htm",
|
||||
"css",
|
||||
"scss",
|
||||
"sass",
|
||||
"less",
|
||||
"graphql",
|
||||
"gql",
|
||||
"sql",
|
||||
"ini",
|
||||
"cfg",
|
||||
"conf",
|
||||
"env",
|
||||
])
|
||||
|
||||
const textNames = new Set([
|
||||
"dockerfile",
|
||||
"makefile",
|
||||
".gitignore",
|
||||
".gitattributes",
|
||||
".editorconfig",
|
||||
".npmrc",
|
||||
".nvmrc",
|
||||
".prettierrc",
|
||||
".eslintrc",
|
||||
])
|
||||
|
||||
function isImageByExtension(filepath: string): boolean {
|
||||
const ext = path.extname(filepath).toLowerCase().slice(1)
|
||||
return imageExtensions.has(ext)
|
||||
}
|
||||
|
||||
function isTextByExtension(filepath: string): boolean {
|
||||
const ext = path.extname(filepath).toLowerCase().slice(1)
|
||||
return textExtensions.has(ext)
|
||||
}
|
||||
|
||||
function isTextByName(filepath: string): boolean {
|
||||
const name = path.basename(filepath).toLowerCase()
|
||||
return textNames.has(name)
|
||||
}
|
||||
|
||||
function getImageMimeType(filepath: string): string {
|
||||
const ext = path.extname(filepath).toLowerCase().slice(1)
|
||||
const mimeTypes: Record<string, string> = {
|
||||
@@ -417,10 +482,13 @@ export namespace File {
|
||||
}
|
||||
}
|
||||
|
||||
return changedFiles.map((x) => ({
|
||||
...x,
|
||||
path: path.relative(Instance.directory, x.path),
|
||||
}))
|
||||
return changedFiles.map((x) => {
|
||||
const full = path.isAbsolute(x.path) ? x.path : path.join(Instance.directory, x.path)
|
||||
return {
|
||||
...x,
|
||||
path: path.relative(Instance.directory, full),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function read(file: string): Promise<Content> {
|
||||
@@ -445,7 +513,9 @@ export namespace File {
|
||||
return { type: "text", content: "" }
|
||||
}
|
||||
|
||||
if (isBinaryByExtension(file)) {
|
||||
const text = isTextByExtension(file) || isTextByName(file)
|
||||
|
||||
if (isBinaryByExtension(file) && !text) {
|
||||
return { type: "binary", content: "" }
|
||||
}
|
||||
|
||||
@@ -454,7 +524,7 @@ export namespace File {
|
||||
}
|
||||
|
||||
const mimeType = Filesystem.mimeType(full)
|
||||
const encode = await shouldEncode(mimeType)
|
||||
const encode = text ? false : await shouldEncode(mimeType)
|
||||
|
||||
if (encode && !isImage(mimeType)) {
|
||||
return { type: "binary", content: "", mimeType }
|
||||
|
||||
@@ -1654,22 +1654,17 @@ export namespace LSPServer {
|
||||
|
||||
if (!bin) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("downloading terraform-ls from GitHub releases")
|
||||
log.info("downloading terraform-ls from HashiCorp releases")
|
||||
|
||||
const releaseResponse = await fetch("https://api.github.com/repos/hashicorp/terraform-ls/releases/latest")
|
||||
const releaseResponse = await fetch("https://api.releases.hashicorp.com/v1/releases/terraform-ls/latest")
|
||||
if (!releaseResponse.ok) {
|
||||
log.error("Failed to fetch terraform-ls release info")
|
||||
return
|
||||
}
|
||||
|
||||
const release = (await releaseResponse.json()) as {
|
||||
tag_name?: string
|
||||
assets?: { name?: string; browser_download_url?: string }[]
|
||||
}
|
||||
const version = release.tag_name?.replace("v", "")
|
||||
if (!version) {
|
||||
log.error("terraform-ls release did not include a version tag")
|
||||
return
|
||||
version?: string
|
||||
builds?: { arch?: string; os?: string; url?: string }[]
|
||||
}
|
||||
|
||||
const platform = process.platform
|
||||
@@ -1678,22 +1673,20 @@ export namespace LSPServer {
|
||||
const tfArch = arch === "arm64" ? "arm64" : "amd64"
|
||||
const tfPlatform = platform === "win32" ? "windows" : platform
|
||||
|
||||
const assetName = `terraform-ls_${version}_${tfPlatform}_${tfArch}.zip`
|
||||
|
||||
const assets = release.assets ?? []
|
||||
const asset = assets.find((a) => a.name === assetName)
|
||||
if (!asset?.browser_download_url) {
|
||||
log.error(`Could not find asset ${assetName} in terraform-ls release`)
|
||||
const builds = release.builds ?? []
|
||||
const build = builds.find((b) => b.arch === tfArch && b.os === tfPlatform)
|
||||
if (!build?.url) {
|
||||
log.error(`Could not find build for ${tfPlatform}/${tfArch} terraform-ls release version ${release.version}`)
|
||||
return
|
||||
}
|
||||
|
||||
const downloadResponse = await fetch(asset.browser_download_url)
|
||||
const downloadResponse = await fetch(build.url)
|
||||
if (!downloadResponse.ok) {
|
||||
log.error("Failed to download terraform-ls")
|
||||
return
|
||||
}
|
||||
|
||||
const tempPath = path.join(Global.Path.bin, assetName)
|
||||
const tempPath = path.join(Global.Path.bin, "terraform-ls.zip")
|
||||
if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body)
|
||||
|
||||
const ok = await Archive.extractZip(tempPath, Global.Path.bin)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Global } from "../global"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
export namespace McpAuth {
|
||||
export const Tokens = z.object({
|
||||
@@ -53,25 +54,22 @@ export namespace McpAuth {
|
||||
}
|
||||
|
||||
export async function all(): Promise<Record<string, Entry>> {
|
||||
const file = Bun.file(filepath)
|
||||
return file.json().catch(() => ({}))
|
||||
return Filesystem.readJson<Record<string, Entry>>(filepath).catch(() => ({}))
|
||||
}
|
||||
|
||||
export async function set(mcpName: string, entry: Entry, serverUrl?: string): Promise<void> {
|
||||
const file = Bun.file(filepath)
|
||||
const data = await all()
|
||||
// Always update serverUrl if provided
|
||||
if (serverUrl) {
|
||||
entry.serverUrl = serverUrl
|
||||
}
|
||||
await Bun.write(file, JSON.stringify({ ...data, [mcpName]: entry }, null, 2), { mode: 0o600 })
|
||||
await Filesystem.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600)
|
||||
}
|
||||
|
||||
export async function remove(mcpName: string): Promise<void> {
|
||||
const file = Bun.file(filepath)
|
||||
const data = await all()
|
||||
delete data[mcpName]
|
||||
await Bun.write(file, JSON.stringify(data, null, 2), { mode: 0o600 })
|
||||
await Filesystem.writeJson(filepath, data, 0o600)
|
||||
}
|
||||
|
||||
export async function updateTokens(mcpName: string, tokens: Tokens, serverUrl?: string): Promise<void> {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { iife } from "@/util/iife"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { existsSync } from "fs"
|
||||
import { git } from "../util/git"
|
||||
import { Glob } from "../util/glob"
|
||||
|
||||
export namespace Project {
|
||||
const log = Log.create({ service: "project" })
|
||||
@@ -86,8 +87,7 @@ export namespace Project {
|
||||
const gitBinary = Bun.which("git")
|
||||
|
||||
// cached id calculation
|
||||
let id = await Bun.file(path.join(dotgit, "opencode"))
|
||||
.text()
|
||||
let id = await Filesystem.readText(path.join(dotgit, "opencode"))
|
||||
.then((x) => x.trim())
|
||||
.catch(() => undefined)
|
||||
|
||||
@@ -125,9 +125,7 @@ export namespace Project {
|
||||
|
||||
id = roots[0]
|
||||
if (id) {
|
||||
void Bun.file(path.join(dotgit, "opencode"))
|
||||
.write(id)
|
||||
.catch(() => undefined)
|
||||
void Filesystem.write(path.join(dotgit, "opencode"), id).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,22 +263,16 @@ export namespace Project {
|
||||
if (input.vcs !== "git") return
|
||||
if (input.icon?.override) return
|
||||
if (input.icon?.url) return
|
||||
const glob = new Bun.Glob("**/{favicon}.{ico,png,svg,jpg,jpeg,webp}")
|
||||
const matches = await Array.fromAsync(
|
||||
glob.scan({
|
||||
cwd: input.worktree,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
followSymlinks: false,
|
||||
dot: false,
|
||||
}),
|
||||
)
|
||||
const matches = await Glob.scan("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
|
||||
cwd: input.worktree,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
})
|
||||
const shortest = matches.sort((a, b) => a.length - b.length)[0]
|
||||
if (!shortest) return
|
||||
const file = Bun.file(shortest)
|
||||
const buffer = await file.arrayBuffer()
|
||||
const base64 = Buffer.from(buffer).toString("base64")
|
||||
const mime = file.type || "image/png"
|
||||
const buffer = await Filesystem.readBytes(shortest)
|
||||
const base64 = buffer.toString("base64")
|
||||
const mime = Filesystem.mimeType(shortest) || "image/png"
|
||||
const url = `data:${mime};base64,${base64}`
|
||||
await update({
|
||||
projectID: input.id,
|
||||
@@ -381,10 +373,8 @@ export namespace Project {
|
||||
const data = fromRow(row)
|
||||
const valid: string[] = []
|
||||
for (const dir of data.sandboxes) {
|
||||
const stat = await Bun.file(dir)
|
||||
.stat()
|
||||
.catch(() => undefined)
|
||||
if (stat?.isDirectory()) valid.push(dir)
|
||||
const s = Filesystem.stat(dir)
|
||||
if (s?.isDirectory()) valid.push(dir)
|
||||
}
|
||||
return valid
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import z from "zod"
|
||||
import { Installation } from "../installation"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
// Try to import bundled snapshot (generated at build time)
|
||||
// Falls back to undefined in dev mode when snapshot doesn't exist
|
||||
@@ -85,8 +86,7 @@ export namespace ModelsDev {
|
||||
}
|
||||
|
||||
export const Data = lazy(async () => {
|
||||
const file = Bun.file(Flag.OPENCODE_MODELS_PATH ?? filepath)
|
||||
const result = await file.json().catch(() => {})
|
||||
const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {})
|
||||
if (result) return result
|
||||
// @ts-ignore
|
||||
const snapshot = await import("./models-snapshot")
|
||||
@@ -104,7 +104,6 @@ export namespace ModelsDev {
|
||||
}
|
||||
|
||||
export async function refresh() {
|
||||
const file = Bun.file(filepath)
|
||||
const result = await fetch(`${url()}/api.json`, {
|
||||
headers: {
|
||||
"User-Agent": Installation.USER_AGENT,
|
||||
@@ -116,7 +115,7 @@ export namespace ModelsDev {
|
||||
})
|
||||
})
|
||||
if (result && result.ok) {
|
||||
await Bun.write(file, await result.text())
|
||||
await Filesystem.write(filepath, await result.text())
|
||||
ModelsDev.Data.reset()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Flag } from "../flag/flag"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Global } from "../global"
|
||||
import path from "path"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
// Direct imports for bundled providers
|
||||
import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
|
||||
@@ -39,6 +40,8 @@ import { createTogetherAI } from "@ai-sdk/togetherai"
|
||||
import { createPerplexity } from "@ai-sdk/perplexity"
|
||||
import { createVercel } from "@ai-sdk/vercel"
|
||||
import { createGitLab, VERSION as GITLAB_PROVIDER_VERSION } from "@gitlab/gitlab-ai-provider"
|
||||
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
||||
import { GoogleAuth } from "google-auth-library"
|
||||
import { ProviderTransform } from "./transform"
|
||||
import { Installation } from "../installation"
|
||||
|
||||
@@ -251,8 +254,6 @@ export namespace Provider {
|
||||
// Only use credential chain if no bearer token exists
|
||||
// Bearer token takes precedence over credential chain (profiles, access keys, IAM roles, web identity tokens)
|
||||
if (!awsBearerToken) {
|
||||
const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers"))
|
||||
|
||||
// Build credential provider options (only pass profile if specified)
|
||||
const credentialProviderOptions = profile ? { profile } : {}
|
||||
|
||||
@@ -395,11 +396,9 @@ export namespace Provider {
|
||||
project,
|
||||
location,
|
||||
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const { GoogleAuth } = await import(await BunProc.install("google-auth-library"))
|
||||
const auth = new GoogleAuth()
|
||||
const client = await auth.getApplicationDefault()
|
||||
const credentials = await client.credential
|
||||
const token = await credentials.getAccessToken()
|
||||
const token = await client.credential.getAccessToken()
|
||||
|
||||
const headers = new Headers(init?.headers)
|
||||
headers.set("Authorization", `Bearer ${token.token}`)
|
||||
@@ -1291,8 +1290,9 @@ export namespace Provider {
|
||||
if (cfg.model) return parseModel(cfg.model)
|
||||
|
||||
const providers = await list()
|
||||
const recent = (await Bun.file(path.join(Global.Path.state, "model.json"))
|
||||
.json()
|
||||
const recent = (await Filesystem.readJson<{ recent?: { providerID: string; modelID: string }[] }>(
|
||||
path.join(Global.Path.state, "model.json"),
|
||||
)
|
||||
.then((x) => (Array.isArray(x.recent) ? x.recent : []))
|
||||
.catch(() => [])) as { providerID: string; modelID: string }[]
|
||||
for (const entry of recent) {
|
||||
|
||||
@@ -333,6 +333,10 @@ export namespace ProviderTransform {
|
||||
if (!model.capabilities.reasoning) return {}
|
||||
|
||||
const id = model.id.toLowerCase()
|
||||
const isAnthropicAdaptive = ["opus-4-6", "opus-4.6", "sonnet-4-6", "sonnet-4.6"].some((v) =>
|
||||
model.api.id.includes(v),
|
||||
)
|
||||
const adaptiveEfforts = ["low", "medium", "high", "max"]
|
||||
if (
|
||||
id.includes("deepseek") ||
|
||||
id.includes("minimax") ||
|
||||
@@ -366,6 +370,19 @@ export namespace ProviderTransform {
|
||||
|
||||
case "@ai-sdk/gateway":
|
||||
if (model.id.includes("anthropic")) {
|
||||
if (isAnthropicAdaptive) {
|
||||
return Object.fromEntries(
|
||||
adaptiveEfforts.map((effort) => [
|
||||
effort,
|
||||
{
|
||||
thinking: {
|
||||
type: "adaptive",
|
||||
},
|
||||
effort,
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
return {
|
||||
high: {
|
||||
thinking: {
|
||||
@@ -502,10 +519,9 @@ export namespace ProviderTransform {
|
||||
case "@ai-sdk/google-vertex/anthropic":
|
||||
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex#anthropic-provider
|
||||
|
||||
if (model.api.id.includes("opus-4-6") || model.api.id.includes("opus-4.6")) {
|
||||
const efforts = ["low", "medium", "high", "max"]
|
||||
if (isAnthropicAdaptive) {
|
||||
return Object.fromEntries(
|
||||
efforts.map((effort) => [
|
||||
adaptiveEfforts.map((effort) => [
|
||||
effort,
|
||||
{
|
||||
thinking: {
|
||||
@@ -534,10 +550,9 @@ export namespace ProviderTransform {
|
||||
|
||||
case "@ai-sdk/amazon-bedrock":
|
||||
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/amazon-bedrock
|
||||
if (model.api.id.includes("opus-4-6") || model.api.id.includes("opus-4.6")) {
|
||||
const efforts = ["low", "medium", "high", "max"]
|
||||
if (isAnthropicAdaptive) {
|
||||
return Object.fromEntries(
|
||||
efforts.map((effort) => [
|
||||
adaptiveEfforts.map((effort) => [
|
||||
effort,
|
||||
{
|
||||
reasoningConfig: {
|
||||
@@ -599,12 +614,19 @@ export namespace ProviderTransform {
|
||||
},
|
||||
}
|
||||
}
|
||||
let levels = ["low", "high"]
|
||||
if (id.includes("3.1")) {
|
||||
levels = ["low", "medium", "high"]
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
["low", "high"].map((effort) => [
|
||||
levels.map((effort) => [
|
||||
effort,
|
||||
{
|
||||
includeThoughts: true,
|
||||
thinkingLevel: effort,
|
||||
thinkingConfig: {
|
||||
includeThoughts: true,
|
||||
thinkingLevel: effort,
|
||||
},
|
||||
},
|
||||
]),
|
||||
)
|
||||
@@ -624,8 +646,7 @@ export namespace ProviderTransform {
|
||||
groqEffort.map((effort) => [
|
||||
effort,
|
||||
{
|
||||
includeThoughts: true,
|
||||
thinkingLevel: effort,
|
||||
reasoningEffort: effort,
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -18,17 +18,31 @@ export namespace Pty {
|
||||
|
||||
type Socket = {
|
||||
readyState: number
|
||||
data: object
|
||||
send: (data: string | Uint8Array<ArrayBuffer> | ArrayBuffer) => void
|
||||
data?: unknown
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => void
|
||||
close: (code?: number, reason?: string) => void
|
||||
}
|
||||
|
||||
// Bun's ServerWebSocket has a per-connection `.data` object (set during
|
||||
// `server.upgrade`) that changes when the underlying connection is recycled.
|
||||
// We keep a reference to a stable part of it so output can't leak even when
|
||||
// websocket objects are reused.
|
||||
type Subscriber = {
|
||||
id: number
|
||||
token: unknown
|
||||
}
|
||||
|
||||
const sockets = new WeakMap<object, number>()
|
||||
const owners = new WeakMap<object, string>()
|
||||
let socketCounter = 0
|
||||
|
||||
const tagSocket = (ws: Socket) => {
|
||||
if (!ws || typeof ws !== "object") return
|
||||
const next = (socketCounter = (socketCounter + 1) % Number.MAX_SAFE_INTEGER)
|
||||
sockets.set(ws, next)
|
||||
return next
|
||||
}
|
||||
|
||||
const token = (ws: Socket) => {
|
||||
const data = ws.data
|
||||
if (!data || typeof data !== "object") return
|
||||
|
||||
const events = (data as { events?: unknown }).events
|
||||
if (events && typeof events === "object") return events
|
||||
|
||||
@@ -38,7 +52,7 @@ export namespace Pty {
|
||||
return data
|
||||
}
|
||||
|
||||
// WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }).
|
||||
// WebSocket control frame: 0x00 + UTF-8 JSON.
|
||||
const meta = (cursor: number) => {
|
||||
const json = JSON.stringify({ cursor })
|
||||
const bytes = encoder.encode(json)
|
||||
@@ -102,7 +116,7 @@ export namespace Pty {
|
||||
buffer: string
|
||||
bufferCursor: number
|
||||
cursor: number
|
||||
subscribers: Map<Socket, object>
|
||||
subscribers: Map<Socket, Subscriber>
|
||||
}
|
||||
|
||||
const state = Instance.state(
|
||||
@@ -185,16 +199,22 @@ export namespace Pty {
|
||||
ptyProcess.onData((chunk) => {
|
||||
session.cursor += chunk.length
|
||||
|
||||
for (const [ws, data] of session.subscribers) {
|
||||
for (const [ws, sub] of session.subscribers) {
|
||||
if (ws.readyState !== 1) {
|
||||
session.subscribers.delete(ws)
|
||||
continue
|
||||
}
|
||||
|
||||
if (token(ws) !== data) {
|
||||
if (typeof ws === "object" && sockets.get(ws) !== sub.id) {
|
||||
session.subscribers.delete(ws)
|
||||
continue
|
||||
}
|
||||
|
||||
if (sub.token !== undefined && token(ws) !== sub.token) {
|
||||
session.subscribers.delete(ws)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
ws.send(chunk)
|
||||
} catch {
|
||||
@@ -280,6 +300,25 @@ export namespace Pty {
|
||||
}
|
||||
log.info("client connected to session", { id })
|
||||
|
||||
const socketId = tagSocket(ws)
|
||||
if (socketId === undefined) {
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
|
||||
const previous = owners.get(ws)
|
||||
if (previous && previous !== id) {
|
||||
state().get(previous)?.subscribers.delete(ws)
|
||||
}
|
||||
|
||||
owners.set(ws, id)
|
||||
session.subscribers.set(ws, { id: socketId, token: token(ws) })
|
||||
|
||||
const cleanup = () => {
|
||||
session.subscribers.delete(ws)
|
||||
if (owners.get(ws) === id) owners.delete(ws)
|
||||
}
|
||||
|
||||
const start = session.bufferCursor
|
||||
const end = session.cursor
|
||||
|
||||
@@ -300,6 +339,7 @@ export namespace Pty {
|
||||
ws.send(data.slice(i, i + BUFFER_CHUNK))
|
||||
}
|
||||
} catch {
|
||||
cleanup()
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
@@ -308,23 +348,17 @@ export namespace Pty {
|
||||
try {
|
||||
ws.send(meta(end))
|
||||
} catch {
|
||||
cleanup()
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (!ws.data || typeof ws.data !== "object") {
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
|
||||
session.subscribers.set(ws, token(ws))
|
||||
return {
|
||||
onMessage: (message: string | ArrayBuffer) => {
|
||||
session.process.write(String(message))
|
||||
},
|
||||
onClose: () => {
|
||||
log.info("client disconnected from session", { id })
|
||||
session.subscribers.delete(ws)
|
||||
cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,18 +163,13 @@ export const PtyRoutes = lazy(() =>
|
||||
|
||||
type Socket = {
|
||||
readyState: number
|
||||
data: object
|
||||
send: (data: string | Uint8Array<ArrayBuffer> | ArrayBuffer) => void
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => void
|
||||
close: (code?: number, reason?: string) => void
|
||||
}
|
||||
|
||||
const isSocket = (value: unknown): value is Socket => {
|
||||
if (!value || typeof value !== "object") return false
|
||||
if (!("readyState" in value)) return false
|
||||
if (!("data" in value)) return false
|
||||
if (!((value as { data?: unknown }).data && typeof (value as { data?: unknown }).data === "object")) {
|
||||
return false
|
||||
}
|
||||
if (!("send" in value) || typeof (value as { send?: unknown }).send !== "function") return false
|
||||
if (!("close" in value) || typeof (value as { close?: unknown }).close !== "function") return false
|
||||
return typeof (value as { readyState?: unknown }).readyState === "number"
|
||||
@@ -182,12 +177,12 @@ export const PtyRoutes = lazy(() =>
|
||||
|
||||
return {
|
||||
onOpen(_event, ws) {
|
||||
const raw = ws.raw
|
||||
if (!isSocket(raw)) {
|
||||
const socket = ws.raw
|
||||
if (!isSocket(socket)) {
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
handler = Pty.connect(id, raw, cursor)
|
||||
handler = Pty.connect(id, socket, cursor)
|
||||
},
|
||||
onMessage(event) {
|
||||
if (typeof event.data !== "string") return
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Config } from "../config/config"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Log } from "../util/log"
|
||||
import { Glob } from "../util/glob"
|
||||
import type { MessageV2 } from "./message-v2"
|
||||
|
||||
const log = Log.create({ service: "instruction" })
|
||||
@@ -85,7 +86,7 @@ export namespace InstructionPrompt {
|
||||
}
|
||||
|
||||
for (const file of globalFiles()) {
|
||||
if (await Bun.file(file).exists()) {
|
||||
if (await Filesystem.exists(file)) {
|
||||
paths.add(path.resolve(file))
|
||||
break
|
||||
}
|
||||
@@ -98,13 +99,11 @@ export namespace InstructionPrompt {
|
||||
instruction = path.join(os.homedir(), instruction.slice(2))
|
||||
}
|
||||
const matches = path.isAbsolute(instruction)
|
||||
? await Array.fromAsync(
|
||||
new Bun.Glob(path.basename(instruction)).scan({
|
||||
cwd: path.dirname(instruction),
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
}),
|
||||
).catch(() => [])
|
||||
? await Glob.scan(path.basename(instruction), {
|
||||
cwd: path.dirname(instruction),
|
||||
absolute: true,
|
||||
include: "file",
|
||||
}).catch(() => [])
|
||||
: await resolveRelative(instruction)
|
||||
matches.forEach((p) => {
|
||||
paths.add(path.resolve(p))
|
||||
@@ -120,9 +119,7 @@ export namespace InstructionPrompt {
|
||||
const paths = await systemPaths()
|
||||
|
||||
const files = Array.from(paths).map(async (p) => {
|
||||
const content = await Bun.file(p)
|
||||
.text()
|
||||
.catch(() => "")
|
||||
const content = await Filesystem.readText(p).catch(() => "")
|
||||
return content ? "Instructions from: " + p + "\n" + content : ""
|
||||
})
|
||||
|
||||
@@ -164,7 +161,7 @@ export namespace InstructionPrompt {
|
||||
export async function find(dir: string) {
|
||||
for (const file of FILES) {
|
||||
const filepath = path.resolve(path.join(dir, file))
|
||||
if (await Bun.file(filepath).exists()) return filepath
|
||||
if (await Filesystem.exists(filepath)) return filepath
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,9 +179,7 @@ export namespace InstructionPrompt {
|
||||
|
||||
if (found && found !== target && !system.has(found) && !already.has(found) && !isClaimed(messageID, found)) {
|
||||
claim(messageID, found)
|
||||
const content = await Bun.file(found)
|
||||
.text()
|
||||
.catch(() => undefined)
|
||||
const content = await Filesystem.readText(found).catch(() => undefined)
|
||||
if (content) {
|
||||
results.push({ filepath: found, content: "Instructions from: " + found + "\n" + content })
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from "path"
|
||||
import os from "os"
|
||||
import fs from "fs/promises"
|
||||
import z from "zod"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Identifier } from "../id/id"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { Log } from "../util/log"
|
||||
@@ -1082,11 +1083,9 @@ export namespace SessionPrompt {
|
||||
// have to normalize, symbol search returns absolute paths
|
||||
// Decode the pathname since URL constructor doesn't automatically decode it
|
||||
const filepath = fileURLToPath(part.url)
|
||||
const stat = await Bun.file(filepath)
|
||||
.stat()
|
||||
.catch(() => undefined)
|
||||
const s = Filesystem.stat(filepath)
|
||||
|
||||
if (stat?.isDirectory()) {
|
||||
if (s?.isDirectory()) {
|
||||
part.mime = "application/x-directory"
|
||||
}
|
||||
|
||||
@@ -1233,14 +1232,13 @@ export namespace SessionPrompt {
|
||||
]
|
||||
}
|
||||
|
||||
const file = Bun.file(filepath)
|
||||
FileTime.read(input.sessionID, filepath)
|
||||
return [
|
||||
{
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
text: `Called the Read tool with the following input: {\"filePath\":\"${filepath}\"}`,
|
||||
text: `Called the Read tool with the following input: {"filePath":"${filepath}"}`,
|
||||
synthetic: true,
|
||||
},
|
||||
{
|
||||
@@ -1248,7 +1246,7 @@ export namespace SessionPrompt {
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "file",
|
||||
url: `data:${part.mime};base64,` + Buffer.from(await file.bytes()).toString("base64"),
|
||||
url: `data:${part.mime};base64,` + (await Filesystem.readBytes(filepath)).toString("base64"),
|
||||
mime: part.mime,
|
||||
filename: part.filename!,
|
||||
source: part.source,
|
||||
@@ -1354,7 +1352,7 @@ export namespace SessionPrompt {
|
||||
// Switching from plan mode to build mode
|
||||
if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") {
|
||||
const plan = Session.plan(input.session)
|
||||
const exists = await Bun.file(plan).exists()
|
||||
const exists = await Filesystem.exists(plan)
|
||||
if (exists) {
|
||||
const part = await Session.updatePart({
|
||||
id: Identifier.ascending("part"),
|
||||
@@ -1373,7 +1371,7 @@ export namespace SessionPrompt {
|
||||
// Entering plan mode
|
||||
if (input.agent.name === "plan" && assistantMessage?.info.agent !== "plan") {
|
||||
const plan = Session.plan(input.session)
|
||||
const exists = await Bun.file(plan).exists()
|
||||
const exists = await Filesystem.exists(plan)
|
||||
if (!exists) await fs.mkdir(path.dirname(plan), { recursive: true })
|
||||
const part = await Session.updatePart({
|
||||
id: Identifier.ascending("part"),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import path from "path"
|
||||
import { spawn, type ChildProcess } from "child_process"
|
||||
|
||||
@@ -43,7 +44,7 @@ export namespace Shell {
|
||||
// git.exe is typically at: C:\Program Files\Git\cmd\git.exe
|
||||
// bash.exe is at: C:\Program Files\Git\bin\bash.exe
|
||||
const bash = path.join(git, "..", "..", "bin", "bash.exe")
|
||||
if (Bun.file(bash).size) return bash
|
||||
if (Filesystem.stat(bash)?.size) return bash
|
||||
}
|
||||
return process.env.COMSPEC || "cmd.exe"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from "path"
|
||||
import { mkdir } from "fs/promises"
|
||||
import { Log } from "../util/log"
|
||||
import { Global } from "../global"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
export namespace Discovery {
|
||||
const log = Log.create({ service: "skill-discovery" })
|
||||
@@ -19,14 +20,14 @@ export namespace Discovery {
|
||||
}
|
||||
|
||||
async function get(url: string, dest: string): Promise<boolean> {
|
||||
if (await Bun.file(dest).exists()) return true
|
||||
if (await Filesystem.exists(dest)) return true
|
||||
return fetch(url)
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
log.error("failed to download", { url, status: response.status })
|
||||
return false
|
||||
}
|
||||
await Bun.write(dest, await response.text())
|
||||
if (response.body) await Filesystem.writeStream(dest, response.body)
|
||||
return true
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -88,7 +89,7 @@ export namespace Discovery {
|
||||
)
|
||||
|
||||
const md = path.join(root, "SKILL.md")
|
||||
if (await Bun.file(md).exists()) result.push(root)
|
||||
if (await Filesystem.exists(md)) result.push(root)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Flag } from "@/flag/flag"
|
||||
import { Bus } from "@/bus"
|
||||
import { Session } from "@/session"
|
||||
import { Discovery } from "./discovery"
|
||||
import { Glob } from "../util/glob"
|
||||
|
||||
export namespace Skill {
|
||||
const log = Log.create({ service: "skill" })
|
||||
@@ -44,10 +45,9 @@ export namespace Skill {
|
||||
// External skill directories to search for (project-level and global)
|
||||
// These follow the directory layout used by Claude Code and other agents.
|
||||
const EXTERNAL_DIRS = [".claude", ".agents"]
|
||||
const EXTERNAL_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
|
||||
|
||||
const OPENCODE_SKILL_GLOB = new Bun.Glob("{skill,skills}/**/SKILL.md")
|
||||
const SKILL_GLOB = new Bun.Glob("**/SKILL.md")
|
||||
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
|
||||
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
|
||||
const SKILL_PATTERN = "**/SKILL.md"
|
||||
|
||||
export const state = Instance.state(async () => {
|
||||
const skills: Record<string, Info> = {}
|
||||
@@ -88,15 +88,13 @@ export namespace Skill {
|
||||
}
|
||||
|
||||
const scanExternal = async (root: string, scope: "global" | "project") => {
|
||||
return Array.fromAsync(
|
||||
EXTERNAL_SKILL_GLOB.scan({
|
||||
cwd: root,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
followSymlinks: true,
|
||||
dot: true,
|
||||
}),
|
||||
)
|
||||
return Glob.scan(EXTERNAL_SKILL_PATTERN, {
|
||||
cwd: root,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.then((matches) => Promise.all(matches.map(addSkill)))
|
||||
.catch((error) => {
|
||||
log.error(`failed to scan ${scope} skills`, { dir: root, error })
|
||||
@@ -123,12 +121,13 @@ export namespace Skill {
|
||||
|
||||
// Scan .opencode/skill/ directories
|
||||
for (const dir of await Config.directories()) {
|
||||
for await (const match of OPENCODE_SKILL_GLOB.scan({
|
||||
const matches = await Glob.scan(OPENCODE_SKILL_PATTERN, {
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
followSymlinks: true,
|
||||
})) {
|
||||
include: "file",
|
||||
symlink: true,
|
||||
})
|
||||
for (const match of matches) {
|
||||
await addSkill(match)
|
||||
}
|
||||
}
|
||||
@@ -142,12 +141,13 @@ export namespace Skill {
|
||||
log.warn("skill path not found", { path: resolved })
|
||||
continue
|
||||
}
|
||||
for await (const match of SKILL_GLOB.scan({
|
||||
const matches = await Glob.scan(SKILL_PATTERN, {
|
||||
cwd: resolved,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
followSymlinks: true,
|
||||
})) {
|
||||
include: "file",
|
||||
symlink: true,
|
||||
})
|
||||
for (const match of matches) {
|
||||
await addSkill(match)
|
||||
}
|
||||
}
|
||||
@@ -157,12 +157,13 @@ export namespace Skill {
|
||||
const list = await Discovery.pull(url)
|
||||
for (const dir of list) {
|
||||
dirs.add(dir)
|
||||
for await (const match of SKILL_GLOB.scan({
|
||||
const matches = await Glob.scan(SKILL_PATTERN, {
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
followSymlinks: true,
|
||||
})) {
|
||||
include: "file",
|
||||
symlink: true,
|
||||
})
|
||||
for (const match of matches) {
|
||||
await addSkill(match)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Log } from "../util/log"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import z from "zod"
|
||||
import path from "path"
|
||||
import { readFileSync, readdirSync } from "fs"
|
||||
import { readFileSync, readdirSync, existsSync } from "fs"
|
||||
import * as schema from "./schema"
|
||||
|
||||
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number }[] | undefined
|
||||
@@ -54,7 +54,7 @@ export namespace Database {
|
||||
const sql = dirs
|
||||
.map((name) => {
|
||||
const file = path.join(dir, name, "migration.sql")
|
||||
if (!Bun.file(file).size) return
|
||||
if (!existsSync(file)) return
|
||||
return {
|
||||
sql: readFileSync(file, "utf-8"),
|
||||
timestamp: time(name),
|
||||
|
||||
@@ -7,6 +7,8 @@ import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } fro
|
||||
import { SessionShareTable } from "../share/share.sql"
|
||||
import path from "path"
|
||||
import { existsSync } from "fs"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Glob } from "../util/glob"
|
||||
|
||||
export namespace JsonMigration {
|
||||
const log = Log.create({ service: "json-migration" })
|
||||
@@ -70,19 +72,14 @@ export namespace JsonMigration {
|
||||
const now = Date.now()
|
||||
|
||||
async function list(pattern: string) {
|
||||
const items: string[] = []
|
||||
const scan = new Bun.Glob(pattern)
|
||||
for await (const file of scan.scan({ cwd: storageDir, absolute: true })) {
|
||||
items.push(file)
|
||||
}
|
||||
return items
|
||||
return Glob.scan(pattern, { cwd: storageDir, absolute: true })
|
||||
}
|
||||
|
||||
async function read(files: string[], start: number, end: number) {
|
||||
const count = end - start
|
||||
const tasks = new Array(count)
|
||||
for (let i = 0; i < count; i++) {
|
||||
tasks[i] = Bun.file(files[start + i]).json()
|
||||
tasks[i] = Filesystem.readJson(files[start + i])
|
||||
}
|
||||
const results = await Promise.allSettled(tasks)
|
||||
const items = new Array(count)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Lock } from "../util/lock"
|
||||
import { $ } from "bun"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import z from "zod"
|
||||
import { Glob } from "../util/glob"
|
||||
|
||||
export namespace Storage {
|
||||
const log = Log.create({ service: "storage" })
|
||||
@@ -25,21 +26,24 @@ export namespace Storage {
|
||||
async (dir) => {
|
||||
const project = path.resolve(dir, "../project")
|
||||
if (!(await Filesystem.isDir(project))) return
|
||||
for await (const projectDir of new Bun.Glob("*").scan({
|
||||
const projectDirs = await Glob.scan("*", {
|
||||
cwd: project,
|
||||
onlyFiles: false,
|
||||
})) {
|
||||
include: "all",
|
||||
})
|
||||
for (const projectDir of projectDirs) {
|
||||
const fullPath = path.join(project, projectDir)
|
||||
if (!(await Filesystem.isDir(fullPath))) continue
|
||||
log.info(`migrating project ${projectDir}`)
|
||||
let projectID = projectDir
|
||||
const fullProjectDir = path.join(project, projectDir)
|
||||
let worktree = "/"
|
||||
|
||||
if (projectID !== "global") {
|
||||
for await (const msgFile of new Bun.Glob("storage/session/message/*/*.json").scan({
|
||||
for (const msgFile of await Glob.scan("storage/session/message/*/*.json", {
|
||||
cwd: path.join(project, projectDir),
|
||||
absolute: true,
|
||||
})) {
|
||||
const json = await Bun.file(msgFile).json()
|
||||
const json = await Filesystem.readJson<any>(msgFile)
|
||||
worktree = json.path?.root
|
||||
if (worktree) break
|
||||
}
|
||||
@@ -60,21 +64,18 @@ export namespace Storage {
|
||||
if (!id) continue
|
||||
projectID = id
|
||||
|
||||
await Bun.write(
|
||||
path.join(dir, "project", projectID + ".json"),
|
||||
JSON.stringify({
|
||||
id,
|
||||
vcs: "git",
|
||||
worktree,
|
||||
time: {
|
||||
created: Date.now(),
|
||||
initialized: Date.now(),
|
||||
},
|
||||
}),
|
||||
)
|
||||
await Filesystem.writeJson(path.join(dir, "project", projectID + ".json"), {
|
||||
id,
|
||||
vcs: "git",
|
||||
worktree,
|
||||
time: {
|
||||
created: Date.now(),
|
||||
initialized: Date.now(),
|
||||
},
|
||||
})
|
||||
|
||||
log.info(`migrating sessions for project ${projectID}`)
|
||||
for await (const sessionFile of new Bun.Glob("storage/session/info/*.json").scan({
|
||||
for (const sessionFile of await Glob.scan("storage/session/info/*.json", {
|
||||
cwd: fullProjectDir,
|
||||
absolute: true,
|
||||
})) {
|
||||
@@ -83,10 +84,10 @@ export namespace Storage {
|
||||
sessionFile,
|
||||
dest,
|
||||
})
|
||||
const session = await Bun.file(sessionFile).json()
|
||||
await Bun.write(dest, JSON.stringify(session))
|
||||
const session = await Filesystem.readJson<any>(sessionFile)
|
||||
await Filesystem.writeJson(dest, session)
|
||||
log.info(`migrating messages for session ${session.id}`)
|
||||
for await (const msgFile of new Bun.Glob(`storage/session/message/${session.id}/*.json`).scan({
|
||||
for (const msgFile of await Glob.scan(`storage/session/message/${session.id}/*.json`, {
|
||||
cwd: fullProjectDir,
|
||||
absolute: true,
|
||||
})) {
|
||||
@@ -95,23 +96,21 @@ export namespace Storage {
|
||||
msgFile,
|
||||
dest,
|
||||
})
|
||||
const message = await Bun.file(msgFile).json()
|
||||
await Bun.write(dest, JSON.stringify(message))
|
||||
const message = await Filesystem.readJson<any>(msgFile)
|
||||
await Filesystem.writeJson(dest, message)
|
||||
|
||||
log.info(`migrating parts for message ${message.id}`)
|
||||
for await (const partFile of new Bun.Glob(`storage/session/part/${session.id}/${message.id}/*.json`).scan(
|
||||
{
|
||||
cwd: fullProjectDir,
|
||||
absolute: true,
|
||||
},
|
||||
)) {
|
||||
for (const partFile of await Glob.scan(`storage/session/part/${session.id}/${message.id}/*.json`, {
|
||||
cwd: fullProjectDir,
|
||||
absolute: true,
|
||||
})) {
|
||||
const dest = path.join(dir, "part", message.id, path.basename(partFile))
|
||||
const part = await Bun.file(partFile).json()
|
||||
const part = await Filesystem.readJson(partFile)
|
||||
log.info("copying", {
|
||||
partFile,
|
||||
dest,
|
||||
})
|
||||
await Bun.write(dest, JSON.stringify(part))
|
||||
await Filesystem.writeJson(dest, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,39 +118,36 @@ export namespace Storage {
|
||||
}
|
||||
},
|
||||
async (dir) => {
|
||||
for await (const item of new Bun.Glob("session/*/*.json").scan({
|
||||
for (const item of await Glob.scan("session/*/*.json", {
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
})) {
|
||||
const session = await Bun.file(item).json()
|
||||
const session = await Filesystem.readJson<any>(item)
|
||||
if (!session.projectID) continue
|
||||
if (!session.summary?.diffs) continue
|
||||
const { diffs } = session.summary
|
||||
await Bun.file(path.join(dir, "session_diff", session.id + ".json")).write(JSON.stringify(diffs))
|
||||
await Bun.file(path.join(dir, "session", session.projectID, session.id + ".json")).write(
|
||||
JSON.stringify({
|
||||
...session,
|
||||
summary: {
|
||||
additions: diffs.reduce((sum: any, x: any) => sum + x.additions, 0),
|
||||
deletions: diffs.reduce((sum: any, x: any) => sum + x.deletions, 0),
|
||||
},
|
||||
}),
|
||||
)
|
||||
await Filesystem.write(path.join(dir, "session_diff", session.id + ".json"), JSON.stringify(diffs))
|
||||
await Filesystem.writeJson(path.join(dir, "session", session.projectID, session.id + ".json"), {
|
||||
...session,
|
||||
summary: {
|
||||
additions: diffs.reduce((sum: any, x: any) => sum + x.additions, 0),
|
||||
deletions: diffs.reduce((sum: any, x: any) => sum + x.deletions, 0),
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
const state = lazy(async () => {
|
||||
const dir = path.join(Global.Path.data, "storage")
|
||||
const migration = await Bun.file(path.join(dir, "migration"))
|
||||
.json()
|
||||
const migration = await Filesystem.readJson<string>(path.join(dir, "migration"))
|
||||
.then((x) => parseInt(x))
|
||||
.catch(() => 0)
|
||||
for (let index = migration; index < MIGRATIONS.length; index++) {
|
||||
log.info("running migration", { index })
|
||||
const migration = MIGRATIONS[index]
|
||||
await migration(dir).catch(() => log.error("failed to run migration", { index }))
|
||||
await Bun.write(path.join(dir, "migration"), (index + 1).toString())
|
||||
await Filesystem.write(path.join(dir, "migration"), (index + 1).toString())
|
||||
}
|
||||
return {
|
||||
dir,
|
||||
@@ -171,7 +167,7 @@ export namespace Storage {
|
||||
const target = path.join(dir, ...key) + ".json"
|
||||
return withErrorHandling(async () => {
|
||||
using _ = await Lock.read(target)
|
||||
const result = await Bun.file(target).json()
|
||||
const result = await Filesystem.readJson<T>(target)
|
||||
return result as T
|
||||
})
|
||||
}
|
||||
@@ -181,10 +177,10 @@ export namespace Storage {
|
||||
const target = path.join(dir, ...key) + ".json"
|
||||
return withErrorHandling(async () => {
|
||||
using _ = await Lock.write(target)
|
||||
const content = await Bun.file(target).json()
|
||||
fn(content)
|
||||
await Bun.write(target, JSON.stringify(content, null, 2))
|
||||
return content as T
|
||||
const content = await Filesystem.readJson<T>(target)
|
||||
fn(content as T)
|
||||
await Filesystem.writeJson(target, content)
|
||||
return content
|
||||
})
|
||||
}
|
||||
|
||||
@@ -193,7 +189,7 @@ export namespace Storage {
|
||||
const target = path.join(dir, ...key) + ".json"
|
||||
return withErrorHandling(async () => {
|
||||
using _ = await Lock.write(target)
|
||||
await Bun.write(target, JSON.stringify(content, null, 2))
|
||||
await Filesystem.writeJson(target, content)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -208,16 +204,13 @@ export namespace Storage {
|
||||
})
|
||||
}
|
||||
|
||||
const glob = new Bun.Glob("**/*")
|
||||
export async function list(prefix: string[]) {
|
||||
const dir = await state().then((x) => x.dir)
|
||||
try {
|
||||
const result = await Array.fromAsync(
|
||||
glob.scan({
|
||||
cwd: path.join(dir, ...prefix),
|
||||
onlyFiles: true,
|
||||
}),
|
||||
).then((results) => results.map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)]))
|
||||
const result = await Glob.scan("**/*", {
|
||||
cwd: path.join(dir, ...prefix),
|
||||
include: "file",
|
||||
}).then((results) => results.map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)]))
|
||||
result.sort()
|
||||
return result
|
||||
} catch {
|
||||
|
||||
@@ -49,7 +49,7 @@ export const EditTool = Tool.define("edit", {
|
||||
let contentNew = ""
|
||||
await FileTime.withLock(filePath, async () => {
|
||||
if (params.oldString === "") {
|
||||
const existed = await Bun.file(filePath).exists()
|
||||
const existed = await Filesystem.exists(filePath)
|
||||
contentNew = params.newString
|
||||
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
|
||||
await ctx.ask({
|
||||
@@ -61,7 +61,7 @@ export const EditTool = Tool.define("edit", {
|
||||
diff,
|
||||
},
|
||||
})
|
||||
await Bun.write(filePath, params.newString)
|
||||
await Filesystem.write(filePath, params.newString)
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: filePath,
|
||||
})
|
||||
@@ -73,12 +73,11 @@ export const EditTool = Tool.define("edit", {
|
||||
return
|
||||
}
|
||||
|
||||
const file = Bun.file(filePath)
|
||||
const stats = await file.stat().catch(() => {})
|
||||
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 file.text()
|
||||
contentOld = await Filesystem.readText(filePath)
|
||||
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
|
||||
|
||||
diff = trimDiff(
|
||||
@@ -94,7 +93,7 @@ export const EditTool = Tool.define("edit", {
|
||||
},
|
||||
})
|
||||
|
||||
await file.write(contentNew)
|
||||
await Filesystem.write(filePath, contentNew)
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: filePath,
|
||||
})
|
||||
@@ -102,7 +101,7 @@ export const EditTool = Tool.define("edit", {
|
||||
file: filePath,
|
||||
event: "change",
|
||||
})
|
||||
contentNew = await file.text()
|
||||
contentNew = await Filesystem.readText(filePath)
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import z from "zod"
|
||||
import path from "path"
|
||||
import { Tool } from "./tool"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import DESCRIPTION from "./glob.txt"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { Instance } from "../project/instance"
|
||||
@@ -45,10 +46,7 @@ export const GlobTool = Tool.define("glob", {
|
||||
break
|
||||
}
|
||||
const full = path.resolve(search, file)
|
||||
const stats = await Bun.file(full)
|
||||
.stat()
|
||||
.then((x) => x.mtime.getTime())
|
||||
.catch(() => 0)
|
||||
const stats = Filesystem.stat(full)?.mtime.getTime() ?? 0
|
||||
files.push({
|
||||
path: full,
|
||||
mtime: stats,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
|
||||
import DESCRIPTION from "./grep.txt"
|
||||
@@ -83,8 +84,7 @@ export const GrepTool = Tool.define("grep", {
|
||||
const lineNum = parseInt(lineNumStr, 10)
|
||||
const lineText = lineTextParts.join("|")
|
||||
|
||||
const file = Bun.file(filePath)
|
||||
const stats = await file.stat().catch(() => null)
|
||||
const stats = Filesystem.stat(filePath)
|
||||
if (!stats) continue
|
||||
|
||||
matches.push({
|
||||
|
||||
@@ -6,6 +6,7 @@ import DESCRIPTION from "./lsp.txt"
|
||||
import { Instance } from "../project/instance"
|
||||
import { pathToFileURL } from "url"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
const operations = [
|
||||
"goToDefinition",
|
||||
@@ -47,7 +48,7 @@ export const LspTool = Tool.define("lsp", {
|
||||
const relPath = path.relative(Instance.worktree, file)
|
||||
const title = `${args.operation} ${relPath}:${args.line}:${args.character}`
|
||||
|
||||
const exists = await Bun.file(file).exists()
|
||||
const exists = await Filesystem.exists(file)
|
||||
if (!exists) {
|
||||
throw new Error(`File not found: ${file}`)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import DESCRIPTION from "./read.txt"
|
||||
import { Instance } from "../project/instance"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
import { InstructionPrompt } from "../session/instruction"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
const DEFAULT_READ_LIMIT = 2000
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
@@ -34,8 +35,7 @@ export const ReadTool = Tool.define("read", {
|
||||
}
|
||||
const title = path.relative(Instance.worktree, filepath)
|
||||
|
||||
const file = Bun.file(filepath)
|
||||
const stat = await file.stat().catch(() => undefined)
|
||||
const stat = Filesystem.stat(filepath)
|
||||
|
||||
await assertExternalDirectory(ctx, filepath, {
|
||||
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
|
||||
@@ -118,11 +118,10 @@ export const ReadTool = Tool.define("read", {
|
||||
const instructions = await InstructionPrompt.resolve(ctx.messages, filepath, ctx.messageID)
|
||||
|
||||
// Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files)
|
||||
const isImage =
|
||||
file.type.startsWith("image/") && file.type !== "image/svg+xml" && file.type !== "image/vnd.fastbidsheet"
|
||||
const isPdf = file.type === "application/pdf"
|
||||
const mime = Filesystem.mimeType(filepath)
|
||||
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
|
||||
const isPdf = mime === "application/pdf"
|
||||
if (isImage || isPdf) {
|
||||
const mime = file.type
|
||||
const msg = `${isImage ? "Image" : "PDF"} read successfully`
|
||||
return {
|
||||
title,
|
||||
@@ -136,13 +135,13 @@ export const ReadTool = Tool.define("read", {
|
||||
{
|
||||
type: "file",
|
||||
mime,
|
||||
url: `data:${mime};base64,${Buffer.from(await file.bytes()).toString("base64")}`,
|
||||
url: `data:${mime};base64,${Buffer.from(await Filesystem.readBytes(filepath)).toString("base64")}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const isBinary = await isBinaryFile(filepath, stat.size)
|
||||
const isBinary = await isBinaryFile(filepath, Number(stat.size))
|
||||
if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`)
|
||||
|
||||
const stream = createReadStream(filepath, { encoding: "utf8" })
|
||||
|
||||
@@ -27,16 +27,18 @@ import { LspTool } from "./lsp"
|
||||
import { Truncate } from "./truncation"
|
||||
import { PlanExitTool, PlanEnterTool } from "./plan"
|
||||
import { ApplyPatchTool } from "./apply_patch"
|
||||
import { Glob } from "../util/glob"
|
||||
|
||||
export namespace ToolRegistry {
|
||||
const log = Log.create({ service: "tool.registry" })
|
||||
|
||||
export const state = Instance.state(async () => {
|
||||
const custom = [] as Tool.Info[]
|
||||
const glob = new Bun.Glob("{tool,tools}/*.{js,ts}")
|
||||
|
||||
const matches = await Config.directories().then((dirs) =>
|
||||
dirs.flatMap((dir) => [...glob.scanSync({ cwd: dir, absolute: true, followSymlinks: true, dot: true })]),
|
||||
dirs.flatMap((dir) =>
|
||||
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
|
||||
),
|
||||
)
|
||||
if (matches.length) await Config.waitForDependencies()
|
||||
for (const match of matches) {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Identifier } from "../id/id"
|
||||
import { PermissionNext } from "../permission/next"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import { Scheduler } from "../scheduler"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Glob } from "../util/glob"
|
||||
|
||||
export namespace Truncate {
|
||||
export const MAX_LINES = 2000
|
||||
@@ -33,8 +35,7 @@ export namespace Truncate {
|
||||
|
||||
export async function cleanup() {
|
||||
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - RETENTION_MS))
|
||||
const glob = new Bun.Glob("tool_*")
|
||||
const entries = await Array.fromAsync(glob.scan({ cwd: DIR, onlyFiles: true })).catch(() => [] as string[])
|
||||
const entries = await Glob.scan("tool_*", { cwd: DIR, include: "file" }).catch(() => [] as string[])
|
||||
for (const entry of entries) {
|
||||
if (Identifier.timestamp(entry) >= cutoff) continue
|
||||
await fs.unlink(path.join(DIR, entry)).catch(() => {})
|
||||
@@ -91,7 +92,7 @@ export namespace Truncate {
|
||||
|
||||
const id = Identifier.ascending("tool")
|
||||
const filepath = path.join(DIR, id)
|
||||
await Bun.write(Bun.file(filepath), text)
|
||||
await Filesystem.write(filepath, text)
|
||||
|
||||
const hint = hasTaskTool(agent)
|
||||
? `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
|
||||
|
||||
@@ -26,9 +26,8 @@ export const WriteTool = Tool.define("write", {
|
||||
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
|
||||
await assertExternalDirectory(ctx, filepath)
|
||||
|
||||
const file = Bun.file(filepath)
|
||||
const exists = await file.exists()
|
||||
const contentOld = exists ? await file.text() : ""
|
||||
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))
|
||||
@@ -42,7 +41,7 @@ export const WriteTool = Tool.define("write", {
|
||||
},
|
||||
})
|
||||
|
||||
await Bun.write(filepath, params.content)
|
||||
await Filesystem.write(filepath, params.content)
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: filepath,
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { realpathSync } from "fs"
|
||||
import { dirname, join, relative } from "path"
|
||||
import { Readable } from "stream"
|
||||
import { pipeline } from "stream/promises"
|
||||
import { Glob } from "./glob"
|
||||
|
||||
export namespace Filesystem {
|
||||
// Fast sync version for metadata checks
|
||||
@@ -41,11 +42,16 @@ export namespace Filesystem {
|
||||
return readFile(p)
|
||||
}
|
||||
|
||||
export async function readArrayBuffer(p: string): Promise<ArrayBuffer> {
|
||||
const buf = await readFile(p)
|
||||
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer
|
||||
}
|
||||
|
||||
function isEnoent(e: unknown): e is { code: "ENOENT" } {
|
||||
return typeof e === "object" && e !== null && "code" in e && (e as { code: string }).code === "ENOENT"
|
||||
}
|
||||
|
||||
export async function write(p: string, content: string | Buffer, mode?: number): Promise<void> {
|
||||
export async function write(p: string, content: string | Buffer | Uint8Array, mode?: number): Promise<void> {
|
||||
try {
|
||||
if (mode) {
|
||||
await writeFile(p, content, { mode })
|
||||
@@ -151,16 +157,13 @@ export namespace Filesystem {
|
||||
const result = []
|
||||
while (true) {
|
||||
try {
|
||||
const glob = new Bun.Glob(pattern)
|
||||
for await (const match of glob.scan({
|
||||
const matches = await Glob.scan(pattern, {
|
||||
cwd: current,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
followSymlinks: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
})) {
|
||||
result.push(match)
|
||||
}
|
||||
})
|
||||
result.push(...matches)
|
||||
} catch {
|
||||
// Skip invalid glob patterns
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { glob, globSync, type GlobOptions } from "glob"
|
||||
import { minimatch } from "minimatch"
|
||||
|
||||
export namespace Glob {
|
||||
export interface Options {
|
||||
cwd?: string
|
||||
absolute?: boolean
|
||||
include?: "file" | "all"
|
||||
dot?: boolean
|
||||
symlink?: boolean
|
||||
}
|
||||
|
||||
function toGlobOptions(options: Options): GlobOptions {
|
||||
return {
|
||||
cwd: options.cwd,
|
||||
absolute: options.absolute,
|
||||
dot: options.dot,
|
||||
follow: options.symlink ?? false,
|
||||
nodir: options.include !== "all",
|
||||
}
|
||||
}
|
||||
|
||||
export async function scan(pattern: string, options: Options = {}): Promise<string[]> {
|
||||
return glob(pattern, toGlobOptions(options)) as Promise<string[]>
|
||||
}
|
||||
|
||||
export function scanSync(pattern: string, options: Options = {}): string[] {
|
||||
return globSync(pattern, toGlobOptions(options)) as string[]
|
||||
}
|
||||
|
||||
export function match(pattern: string, filepath: string): boolean {
|
||||
return minimatch(filepath, pattern, { dot: true })
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { createWriteStream } from "fs"
|
||||
import { Global } from "../global"
|
||||
import z from "zod"
|
||||
import { Glob } from "./glob"
|
||||
|
||||
export namespace Log {
|
||||
export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" })
|
||||
@@ -63,24 +65,24 @@ export namespace Log {
|
||||
Global.Path.log,
|
||||
options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
|
||||
)
|
||||
const logfile = Bun.file(logpath)
|
||||
await fs.truncate(logpath).catch(() => {})
|
||||
const writer = logfile.writer()
|
||||
const stream = createWriteStream(logpath, { flags: "a" })
|
||||
write = async (msg: any) => {
|
||||
const num = writer.write(msg)
|
||||
writer.flush()
|
||||
return num
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.write(msg, (err) => {
|
||||
if (err) reject(err)
|
||||
else resolve(msg.length)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup(dir: string) {
|
||||
const glob = new Bun.Glob("????-??-??T??????.log")
|
||||
const files = await Array.fromAsync(
|
||||
glob.scan({
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
}),
|
||||
)
|
||||
const files = await Glob.scan("????-??-??T??????.log", {
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
})
|
||||
if (files.length <= 5) return
|
||||
|
||||
const filesToDelete = files.slice(0, -10)
|
||||
|
||||
@@ -7,6 +7,7 @@ import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Global } from "../../src/global"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
|
||||
// Get managed config directory from environment (set in preload.ts)
|
||||
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
||||
@@ -17,11 +18,11 @@ afterEach(async () => {
|
||||
|
||||
async function writeManagedSettings(settings: object, filename = "opencode.json") {
|
||||
await fs.mkdir(managedConfigDir, { recursive: true })
|
||||
await Bun.write(path.join(managedConfigDir, filename), JSON.stringify(settings))
|
||||
await Filesystem.write(path.join(managedConfigDir, filename), JSON.stringify(settings))
|
||||
}
|
||||
|
||||
async function writeConfig(dir: string, config: object, name = "opencode.json") {
|
||||
await Bun.write(path.join(dir, name), JSON.stringify(config))
|
||||
await Filesystem.write(path.join(dir, name), JSON.stringify(config))
|
||||
}
|
||||
|
||||
test("loads config with defaults when no files exist", async () => {
|
||||
@@ -58,7 +59,7 @@ test("loads JSON config file", async () => {
|
||||
test("loads JSONC config file", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.jsonc"),
|
||||
`{
|
||||
// This is a comment
|
||||
@@ -144,7 +145,7 @@ test("preserves env variables when adding $schema to config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
// Config without $schema - should trigger auto-add
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
theme: "{env:PRESERVE_VAR}",
|
||||
@@ -159,7 +160,7 @@ test("preserves env variables when adding $schema to config", async () => {
|
||||
expect(config.theme).toBe("secret_value")
|
||||
|
||||
// Read the file to verify the env variable was preserved
|
||||
const content = await Bun.file(path.join(tmp.path, "opencode.json")).text()
|
||||
const content = await Filesystem.readText(path.join(tmp.path, "opencode.json"))
|
||||
expect(content).toContain("{env:PRESERVE_VAR}")
|
||||
expect(content).not.toContain("secret_value")
|
||||
expect(content).toContain("$schema")
|
||||
@@ -177,7 +178,7 @@ test("preserves env variables when adding $schema to config", async () => {
|
||||
test("handles file inclusion substitution", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "included.txt"), "test_theme")
|
||||
await Filesystem.write(path.join(dir, "included.txt"), "test_theme")
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
theme: "{file:included.txt}",
|
||||
@@ -196,7 +197,7 @@ test("handles file inclusion substitution", async () => {
|
||||
test("handles file inclusion with replacement tokens", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "included.md"), "const out = await Bun.$`echo hi`")
|
||||
await Filesystem.write(path.join(dir, "included.md"), "const out = await Bun.$`echo hi`")
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
theme: "{file:included.md}",
|
||||
@@ -233,7 +234,7 @@ test("validates config schema and throws on invalid fields", async () => {
|
||||
test("throws error for invalid JSON", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "opencode.json"), "{ invalid json }")
|
||||
await Filesystem.write(path.join(dir, "opencode.json"), "{ invalid json }")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
@@ -336,7 +337,7 @@ test("handles command configuration", async () => {
|
||||
test("migrates autoshare to share field", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -358,7 +359,7 @@ test("migrates autoshare to share field", async () => {
|
||||
test("migrates mode field to agent field", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -395,7 +396,7 @@ test("loads config from .opencode directory", async () => {
|
||||
const agentDir = path.join(opencodeDir, "agent")
|
||||
await fs.mkdir(agentDir, { recursive: true })
|
||||
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(agentDir, "test.md"),
|
||||
`---
|
||||
model: test/model
|
||||
@@ -428,7 +429,7 @@ test("loads agents from .opencode/agents (plural)", async () => {
|
||||
const agentsDir = path.join(opencodeDir, "agents")
|
||||
await fs.mkdir(path.join(agentsDir, "nested"), { recursive: true })
|
||||
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(agentsDir, "helper.md"),
|
||||
`---
|
||||
model: test/model
|
||||
@@ -437,7 +438,7 @@ mode: subagent
|
||||
Helper agent prompt`,
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(agentsDir, "nested", "child.md"),
|
||||
`---
|
||||
model: test/model
|
||||
@@ -479,7 +480,7 @@ test("loads commands from .opencode/command (singular)", async () => {
|
||||
const commandDir = path.join(opencodeDir, "command")
|
||||
await fs.mkdir(path.join(commandDir, "nested"), { recursive: true })
|
||||
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(commandDir, "hello.md"),
|
||||
`---
|
||||
description: Test command
|
||||
@@ -487,7 +488,7 @@ description: Test command
|
||||
Hello from singular command`,
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(commandDir, "nested", "child.md"),
|
||||
`---
|
||||
description: Nested command
|
||||
@@ -524,7 +525,7 @@ test("loads commands from .opencode/commands (plural)", async () => {
|
||||
const commandsDir = path.join(opencodeDir, "commands")
|
||||
await fs.mkdir(path.join(commandsDir, "nested"), { recursive: true })
|
||||
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(commandsDir, "hello.md"),
|
||||
`---
|
||||
description: Test command
|
||||
@@ -532,7 +533,7 @@ description: Test command
|
||||
Hello from plural commands`,
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(commandsDir, "nested", "child.md"),
|
||||
`---
|
||||
description: Nested command
|
||||
@@ -568,7 +569,7 @@ test("updates config and writes to file", async () => {
|
||||
const newConfig = { model: "updated/model" }
|
||||
await Config.update(newConfig as any)
|
||||
|
||||
const writtenConfig = JSON.parse(await Bun.file(path.join(tmp.path, "config.json")).text())
|
||||
const writtenConfig = await Filesystem.readJson(path.join(tmp.path, "config.json"))
|
||||
expect(writtenConfig.model).toBe("updated/model")
|
||||
},
|
||||
})
|
||||
@@ -639,8 +640,8 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(await Bun.file(path.join(tmp.extra, "package.json")).exists()).toBe(true)
|
||||
expect(await Bun.file(path.join(tmp.extra, ".gitignore")).exists()).toBe(true)
|
||||
expect(await Filesystem.exists(path.join(tmp.extra, "package.json"))).toBe(true)
|
||||
expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true)
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR
|
||||
else process.env.OPENCODE_CONFIG_DIR = prev
|
||||
@@ -653,12 +654,12 @@ test("resolves scoped npm plugins in config", async () => {
|
||||
const pluginDir = path.join(dir, "node_modules", "@scope", "plugin")
|
||||
await fs.mkdir(pluginDir, { recursive: true })
|
||||
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "package.json"),
|
||||
JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2),
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(pluginDir, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
@@ -672,9 +673,9 @@ test("resolves scoped npm plugins in config", async () => {
|
||||
),
|
||||
)
|
||||
|
||||
await Bun.write(path.join(pluginDir, "index.js"), "export default {}\n")
|
||||
await Filesystem.write(path.join(pluginDir, "index.js"), "export default {}\n")
|
||||
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: ["@scope/plugin"] }, null, 2),
|
||||
)
|
||||
@@ -708,7 +709,7 @@ test("merges plugin arrays from global and local configs", async () => {
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
|
||||
// Global config with plugins
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -717,7 +718,7 @@ test("merges plugin arrays from global and local configs", async () => {
|
||||
)
|
||||
|
||||
// Local .opencode config with different plugins
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -753,7 +754,7 @@ test("does not error when only custom agent is a subagent", async () => {
|
||||
const agentDir = path.join(opencodeDir, "agent")
|
||||
await fs.mkdir(agentDir, { recursive: true })
|
||||
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(agentDir, "helper.md"),
|
||||
`---
|
||||
model: test/model
|
||||
@@ -784,7 +785,7 @@ test("merges instructions arrays from global and local configs", async () => {
|
||||
const opencodeDir = path.join(projectDir, ".opencode")
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -792,7 +793,7 @@ test("merges instructions arrays from global and local configs", async () => {
|
||||
}),
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -823,7 +824,7 @@ test("deduplicates duplicate instructions from global and local configs", async
|
||||
const opencodeDir = path.join(projectDir, ".opencode")
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -831,7 +832,7 @@ test("deduplicates duplicate instructions from global and local configs", async
|
||||
}),
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -867,7 +868,7 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
|
||||
// Global config with plugins
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -876,7 +877,7 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
|
||||
)
|
||||
|
||||
// Local .opencode config with some overlapping plugins
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -915,7 +916,7 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
|
||||
test("migrates legacy tools config to permissions - allow", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -946,7 +947,7 @@ test("migrates legacy tools config to permissions - allow", async () => {
|
||||
test("migrates legacy tools config to permissions - deny", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -977,7 +978,7 @@ test("migrates legacy tools config to permissions - deny", async () => {
|
||||
test("migrates legacy write tool to edit permission", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -1086,7 +1087,7 @@ test("missing managed settings file is not an error", async () => {
|
||||
test("migrates legacy edit tool to edit permission", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -1115,7 +1116,7 @@ test("migrates legacy edit tool to edit permission", async () => {
|
||||
test("migrates legacy patch tool to edit permission", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -1144,7 +1145,7 @@ test("migrates legacy patch tool to edit permission", async () => {
|
||||
test("migrates legacy multiedit tool to edit permission", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -1173,7 +1174,7 @@ test("migrates legacy multiedit tool to edit permission", async () => {
|
||||
test("migrates mixed legacy tools config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -1208,7 +1209,7 @@ test("migrates mixed legacy tools config", async () => {
|
||||
test("merges legacy tools with existing permission config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -1241,7 +1242,7 @@ test("merges legacy tools with existing permission config", async () => {
|
||||
test("permission config preserves key order", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -1289,7 +1290,7 @@ test("project config can override MCP server enabled status", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
// Simulates a base config (like from remote .well-known) with disabled MCP
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.jsonc"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -1308,7 +1309,7 @@ test("project config can override MCP server enabled status", async () => {
|
||||
}),
|
||||
)
|
||||
// Project config enables just jira
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -1347,7 +1348,7 @@ test("MCP config deep merges preserving base config properties", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
// Base config with full MCP definition
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.jsonc"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -1364,7 +1365,7 @@ test("MCP config deep merges preserving base config properties", async () => {
|
||||
}),
|
||||
)
|
||||
// Override just enables it, should preserve other properties
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -1399,7 +1400,7 @@ test("local .opencode config can override MCP from project config", async () =>
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
// Project config with disabled MCP
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -1415,7 +1416,7 @@ test("local .opencode config can override MCP from project config", async () =>
|
||||
// Local .opencode directory config enables it
|
||||
const opencodeDir = path.join(dir, ".opencode")
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -1483,7 +1484,7 @@ test("project config overrides remote well-known config", async () => {
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
// Project config enables jira (overriding remote default)
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -1576,7 +1577,7 @@ describe("deduplicatePlugins", () => {
|
||||
const pluginDir = path.join(opencodeDir, "plugin")
|
||||
await fs.mkdir(pluginDir, { recursive: true })
|
||||
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -1584,7 +1585,7 @@ describe("deduplicatePlugins", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
await Bun.write(path.join(pluginDir, "my-plugin.js"), "export default {}")
|
||||
await Filesystem.write(path.join(pluginDir, "my-plugin.js"), "export default {}")
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1611,7 +1612,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
// Create a project config that would normally be loaded
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -1649,7 +1650,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
|
||||
// Create a .opencode directory with a command
|
||||
const opencodeDir = path.join(dir, ".opencode", "command")
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
await Bun.write(path.join(opencodeDir, "test-cmd.md"), "# Test Command\nThis is a test command.")
|
||||
await Filesystem.write(path.join(opencodeDir, "test-cmd.md"), "# Test Command\nThis is a test command.")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
@@ -1706,7 +1707,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
// Create a config with relative instruction path
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -1714,7 +1715,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
|
||||
}),
|
||||
)
|
||||
// Create the instruction file (should be skipped)
|
||||
await Bun.write(path.join(dir, "CUSTOM.md"), "# Custom Instructions")
|
||||
await Filesystem.write(path.join(dir, "CUSTOM.md"), "# Custom Instructions")
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1752,7 +1753,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
|
||||
await using configDirTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
// Create config in the custom config dir
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -1765,7 +1766,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
|
||||
await using projectTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
// Create config in project (should be ignored)
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -1800,3 +1801,66 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
|
||||
test("substitutes {env:} tokens in OPENCODE_CONFIG_CONTENT", async () => {
|
||||
const originalEnv = process.env["OPENCODE_CONFIG_CONTENT"]
|
||||
const originalTestVar = process.env["TEST_CONFIG_VAR"]
|
||||
process.env["TEST_CONFIG_VAR"] = "test_api_key_12345"
|
||||
process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
theme: "{env:TEST_CONFIG_VAR}",
|
||||
})
|
||||
|
||||
try {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.theme).toBe("test_api_key_12345")
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env["OPENCODE_CONFIG_CONTENT"] = originalEnv
|
||||
} else {
|
||||
delete process.env["OPENCODE_CONFIG_CONTENT"]
|
||||
}
|
||||
if (originalTestVar !== undefined) {
|
||||
process.env["TEST_CONFIG_VAR"] = originalTestVar
|
||||
} else {
|
||||
delete process.env["TEST_CONFIG_VAR"]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("substitutes {file:} tokens in OPENCODE_CONFIG_CONTENT", async () => {
|
||||
const originalEnv = process.env["OPENCODE_CONFIG_CONTENT"]
|
||||
|
||||
try {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "api_key.txt"), "secret_key_from_file")
|
||||
process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
theme: "{file:./api_key.txt}",
|
||||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.theme).toBe("secret_key_from_file")
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env["OPENCODE_CONFIG_CONTENT"] = originalEnv
|
||||
} else {
|
||||
delete process.env["OPENCODE_CONFIG_CONTENT"]
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,11 +3,12 @@ import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { File } from "../../src/file"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("file/index Bun.file patterns", () => {
|
||||
describe("file/index Filesystem patterns", () => {
|
||||
describe("File.read() - text content", () => {
|
||||
test("reads text file via Bun.file().text()", async () => {
|
||||
test("reads text file via Filesystem.readText()", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "test.txt")
|
||||
await fs.writeFile(filepath, "Hello World", "utf-8")
|
||||
@@ -22,7 +23,7 @@ describe("file/index Bun.file patterns", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("reads with Bun.file().exists() check", async () => {
|
||||
test("reads with Filesystem.exists() check", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await Instance.provide({
|
||||
@@ -81,7 +82,7 @@ describe("file/index Bun.file patterns", () => {
|
||||
})
|
||||
|
||||
describe("File.read() - binary content", () => {
|
||||
test("reads binary file via Bun.file().arrayBuffer()", async () => {
|
||||
test("reads binary file via Filesystem.readArrayBuffer()", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "image.png")
|
||||
const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
@@ -115,8 +116,8 @@ describe("file/index Bun.file patterns", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("File.read() - Bun.file().type", () => {
|
||||
test("detects MIME type via Bun.file().type", async () => {
|
||||
describe("File.read() - Filesystem.mimeType()", () => {
|
||||
test("detects MIME type via Filesystem.mimeType()", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "test.json")
|
||||
await fs.writeFile(filepath, '{"key": "value"}', "utf-8")
|
||||
@@ -124,8 +125,7 @@ describe("file/index Bun.file patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bunFile = Bun.file(filepath)
|
||||
expect(bunFile.type).toContain("application/json")
|
||||
expect(Filesystem.mimeType(filepath)).toContain("application/json")
|
||||
|
||||
const result = await File.read("test.json")
|
||||
expect(result.type).toBe("text")
|
||||
@@ -149,16 +149,15 @@ describe("file/index Bun.file patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bunFile = Bun.file(filepath)
|
||||
expect(bunFile.type).toContain(mime)
|
||||
expect(Filesystem.mimeType(filepath)).toContain(mime)
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("File.list() - Bun.file().exists() and .text()", () => {
|
||||
test("reads .gitignore via Bun.file().exists() and .text()", async () => {
|
||||
describe("File.list() - Filesystem.exists() and readText()", () => {
|
||||
test("reads .gitignore via Filesystem.exists() and readText()", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
@@ -168,10 +167,9 @@ describe("file/index Bun.file patterns", () => {
|
||||
await fs.writeFile(gitignorePath, "node_modules\ndist\n", "utf-8")
|
||||
|
||||
// This is used internally in File.list()
|
||||
const bunFile = Bun.file(gitignorePath)
|
||||
expect(await bunFile.exists()).toBe(true)
|
||||
expect(await Filesystem.exists(gitignorePath)).toBe(true)
|
||||
|
||||
const content = await bunFile.text()
|
||||
const content = await Filesystem.readText(gitignorePath)
|
||||
expect(content).toContain("node_modules")
|
||||
},
|
||||
})
|
||||
@@ -186,9 +184,8 @@ describe("file/index Bun.file patterns", () => {
|
||||
const ignorePath = path.join(tmp.path, ".ignore")
|
||||
await fs.writeFile(ignorePath, "*.log\n.env\n", "utf-8")
|
||||
|
||||
const bunFile = Bun.file(ignorePath)
|
||||
expect(await bunFile.exists()).toBe(true)
|
||||
expect(await bunFile.text()).toContain("*.log")
|
||||
expect(await Filesystem.exists(ignorePath)).toBe(true)
|
||||
expect(await Filesystem.readText(ignorePath)).toContain("*.log")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -200,8 +197,7 @@ describe("file/index Bun.file patterns", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const gitignorePath = path.join(tmp.path, ".gitignore")
|
||||
const bunFile = Bun.file(gitignorePath)
|
||||
expect(await bunFile.exists()).toBe(false)
|
||||
expect(await Filesystem.exists(gitignorePath)).toBe(false)
|
||||
|
||||
// File.list() should still work
|
||||
const nodes = await File.list()
|
||||
@@ -211,8 +207,8 @@ describe("file/index Bun.file patterns", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("File.changed() - Bun.file().text() for untracked files", () => {
|
||||
test("reads untracked files via Bun.file().text()", async () => {
|
||||
describe("File.changed() - Filesystem.readText() for untracked files", () => {
|
||||
test("reads untracked files via Filesystem.readText()", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
@@ -222,8 +218,7 @@ describe("file/index Bun.file patterns", () => {
|
||||
await fs.writeFile(untrackedPath, "new content\nwith multiple lines", "utf-8")
|
||||
|
||||
// This is how File.changed() reads untracked files
|
||||
const bunFile = Bun.file(untrackedPath)
|
||||
const content = await bunFile.text()
|
||||
const content = await Filesystem.readText(untrackedPath)
|
||||
const lines = content.split("\n").length
|
||||
expect(lines).toBe(2)
|
||||
},
|
||||
@@ -232,7 +227,7 @@ describe("file/index Bun.file patterns", () => {
|
||||
})
|
||||
|
||||
describe("Error handling", () => {
|
||||
test("handles errors gracefully in Bun.file().text()", async () => {
|
||||
test("handles errors gracefully in Filesystem.readText()", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "readonly.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
@@ -240,9 +235,9 @@ describe("file/index Bun.file patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nonExistentFile = Bun.file(path.join(tmp.path, "does-not-exist.txt"))
|
||||
// Bun.file().text() on non-existent file throws
|
||||
await expect(nonExistentFile.text()).rejects.toThrow()
|
||||
const nonExistentPath = path.join(tmp.path, "does-not-exist.txt")
|
||||
// Filesystem.readText() on non-existent file throws
|
||||
await expect(Filesystem.readText(nonExistentPath)).rejects.toThrow()
|
||||
|
||||
// But File.read() handles this gracefully
|
||||
const result = await File.read("does-not-exist.txt")
|
||||
@@ -251,14 +246,14 @@ describe("file/index Bun.file patterns", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("handles errors in Bun.file().arrayBuffer()", async () => {
|
||||
test("handles errors in Filesystem.readArrayBuffer()", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nonExistentFile = Bun.file(path.join(tmp.path, "does-not-exist.bin"))
|
||||
const buffer = await nonExistentFile.arrayBuffer().catch(() => new ArrayBuffer(0))
|
||||
const nonExistentPath = path.join(tmp.path, "does-not-exist.bin")
|
||||
const buffer = await Filesystem.readArrayBuffer(nonExistentPath).catch(() => new ArrayBuffer(0))
|
||||
expect(buffer.byteLength).toBe(0)
|
||||
},
|
||||
})
|
||||
@@ -272,7 +267,6 @@ describe("file/index Bun.file patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bunFile = Bun.file(filepath)
|
||||
// File.read() handles missing images gracefully
|
||||
const result = await File.read("broken.png")
|
||||
expect(result.type).toBe("text")
|
||||
@@ -283,6 +277,66 @@ describe("file/index Bun.file patterns", () => {
|
||||
})
|
||||
|
||||
describe("shouldEncode() logic", () => {
|
||||
test("treats .ts files as text", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "test.ts")
|
||||
await fs.writeFile(filepath, "export const value = 1", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("test.ts")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("export const value = 1")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("treats .mts files as text", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "test.mts")
|
||||
await fs.writeFile(filepath, "export const value = 1", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("test.mts")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("export const value = 1")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("treats .sh files as text", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "test.sh")
|
||||
await fs.writeFile(filepath, "#!/usr/bin/env bash\necho hello", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("test.sh")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("#!/usr/bin/env bash\necho hello")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("treats Dockerfile as text", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "Dockerfile")
|
||||
await fs.writeFile(filepath, "FROM alpine:3.20", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("Dockerfile")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("FROM alpine:3.20")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns encoding info for text files", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "test.txt")
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { FileTime } from "../../src/file/time"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("file/time", () => {
|
||||
@@ -312,8 +313,8 @@ describe("file/time", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("stat() Bun.file pattern", () => {
|
||||
test("reads file modification time via Bun.file().stat()", async () => {
|
||||
describe("stat() Filesystem.stat pattern", () => {
|
||||
test("reads file modification time via Filesystem.stat()", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
@@ -323,9 +324,9 @@ describe("file/time", () => {
|
||||
fn: async () => {
|
||||
FileTime.read(sessionID, filepath)
|
||||
|
||||
const stats = await Bun.file(filepath).stat()
|
||||
expect(stats.mtime).toBeInstanceOf(Date)
|
||||
expect(stats.mtime.getTime()).toBeGreaterThan(0)
|
||||
const stats = Filesystem.stat(filepath)
|
||||
expect(stats?.mtime).toBeInstanceOf(Date)
|
||||
expect(stats!.mtime.getTime()).toBeGreaterThan(0)
|
||||
|
||||
// FileTime.assert uses this stat internally
|
||||
await FileTime.assert(sessionID, filepath)
|
||||
@@ -343,14 +344,14 @@ describe("file/time", () => {
|
||||
fn: async () => {
|
||||
FileTime.read(sessionID, filepath)
|
||||
|
||||
const originalStat = await Bun.file(filepath).stat()
|
||||
const originalStat = Filesystem.stat(filepath)
|
||||
|
||||
// Wait and modify
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
await fs.writeFile(filepath, "modified", "utf-8")
|
||||
|
||||
const newStat = await Bun.file(filepath).stat()
|
||||
expect(newStat.mtime.getTime()).toBeGreaterThan(originalStat.mtime.getTime())
|
||||
const newStat = Filesystem.stat(filepath)
|
||||
expect(newStat!.mtime.getTime()).toBeGreaterThan(originalStat!.mtime.getTime())
|
||||
|
||||
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow()
|
||||
},
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
name: agents-sdk
|
||||
description: Build AI agents on Cloudflare Workers using the Agents SDK. Load when creating stateful agents, durable workflows, real-time WebSocket apps, scheduled tasks, MCP servers, or chat applications. Covers Agent class, state management, callable RPC, Workflows integration, and React hooks.
|
||||
---
|
||||
|
||||
# Cloudflare Agents SDK
|
||||
|
||||
**STOP.** Your knowledge of the Agents SDK may be outdated. Prefer retrieval over pre-training for any Agents SDK task.
|
||||
|
||||
## Documentation
|
||||
|
||||
Fetch current docs from `https://github.com/cloudflare/agents/tree/main/docs` before implementing.
|
||||
|
||||
| Topic | Doc | Use for |
|
||||
| ------------------- | ----------------------------- | ---------------------------------------------- |
|
||||
| Getting started | `docs/getting-started.md` | First agent, project setup |
|
||||
| State | `docs/state.md` | `setState`, `validateStateChange`, persistence |
|
||||
| Routing | `docs/routing.md` | URL patterns, `routeAgentRequest`, `basePath` |
|
||||
| Callable methods | `docs/callable-methods.md` | `@callable`, RPC, streaming, timeouts |
|
||||
| Scheduling | `docs/scheduling.md` | `schedule()`, `scheduleEvery()`, cron |
|
||||
| Workflows | `docs/workflows.md` | `AgentWorkflow`, durable multi-step tasks |
|
||||
| HTTP/WebSockets | `docs/http-websockets.md` | Lifecycle hooks, hibernation |
|
||||
| Email | `docs/email.md` | Email routing, secure reply resolver |
|
||||
| MCP client | `docs/mcp-client.md` | Connecting to MCP servers |
|
||||
| MCP server | `docs/mcp-servers.md` | Building MCP servers with `McpAgent` |
|
||||
| Client SDK | `docs/client-sdk.md` | `useAgent`, `useAgentChat`, React hooks |
|
||||
| Human-in-the-loop | `docs/human-in-the-loop.md` | Approval flows, pausing workflows |
|
||||
| Resumable streaming | `docs/resumable-streaming.md` | Stream recovery on disconnect |
|
||||
|
||||
Cloudflare docs: https://developers.cloudflare.com/agents/
|
||||
|
||||
## Capabilities
|
||||
|
||||
The Agents SDK provides:
|
||||
|
||||
- **Persistent state** - SQLite-backed, auto-synced to clients
|
||||
- **Callable RPC** - `@callable()` methods invoked over WebSocket
|
||||
- **Scheduling** - One-time, recurring (`scheduleEvery`), and cron tasks
|
||||
- **Workflows** - Durable multi-step background processing via `AgentWorkflow`
|
||||
- **MCP integration** - Connect to MCP servers or build your own with `McpAgent`
|
||||
- **Email handling** - Receive and reply to emails with secure routing
|
||||
- **Streaming chat** - `AIChatAgent` with resumable streams
|
||||
- **React hooks** - `useAgent`, `useAgentChat` for client apps
|
||||
|
||||
## FIRST: Verify Installation
|
||||
|
||||
```bash
|
||||
npm ls agents # Should show agents package
|
||||
```
|
||||
|
||||
If not installed:
|
||||
|
||||
```bash
|
||||
npm install agents
|
||||
```
|
||||
|
||||
## Wrangler Configuration
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"durable_objects": {
|
||||
"bindings": [{ "name": "MyAgent", "class_name": "MyAgent" }],
|
||||
},
|
||||
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["MyAgent"] }],
|
||||
}
|
||||
```
|
||||
|
||||
## Agent Class
|
||||
|
||||
```typescript
|
||||
import { Agent, routeAgentRequest, callable } from "agents"
|
||||
|
||||
type State = { count: number }
|
||||
|
||||
export class Counter extends Agent<Env, State> {
|
||||
initialState = { count: 0 }
|
||||
|
||||
// Validation hook - runs before state persists (sync, throwing rejects the update)
|
||||
validateStateChange(nextState: State, source: Connection | "server") {
|
||||
if (nextState.count < 0) throw new Error("Count cannot be negative")
|
||||
}
|
||||
|
||||
// Notification hook - runs after state persists (async, non-blocking)
|
||||
onStateUpdate(state: State, source: Connection | "server") {
|
||||
console.log("State updated:", state)
|
||||
}
|
||||
|
||||
@callable()
|
||||
increment() {
|
||||
this.setState({ count: this.state.count + 1 })
|
||||
return this.state.count
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
fetch: (req, env) => routeAgentRequest(req, env) ?? new Response("Not found", { status: 404 }),
|
||||
}
|
||||
```
|
||||
|
||||
## Routing
|
||||
|
||||
Requests route to `/agents/{agent-name}/{instance-name}`:
|
||||
|
||||
| Class | URL |
|
||||
| ---------- | -------------------------- |
|
||||
| `Counter` | `/agents/counter/user-123` |
|
||||
| `ChatRoom` | `/agents/chat-room/lobby` |
|
||||
|
||||
Client: `useAgent({ agent: "Counter", name: "user-123" })`
|
||||
|
||||
## Core APIs
|
||||
|
||||
| Task | API |
|
||||
| ------------------- | ------------------------------------------------------ |
|
||||
| Read state | `this.state.count` |
|
||||
| Write state | `this.setState({ count: 1 })` |
|
||||
| SQL query | `` this.sql`SELECT * FROM users WHERE id = ${id}` `` |
|
||||
| Schedule (delay) | `await this.schedule(60, "task", payload)` |
|
||||
| Schedule (cron) | `await this.schedule("0 * * * *", "task", payload)` |
|
||||
| Schedule (interval) | `await this.scheduleEvery(30, "poll")` |
|
||||
| RPC method | `@callable() myMethod() { ... }` |
|
||||
| Streaming RPC | `@callable({ streaming: true }) stream(res) { ... }` |
|
||||
| Start workflow | `await this.runWorkflow("ProcessingWorkflow", params)` |
|
||||
|
||||
## React Client
|
||||
|
||||
```tsx
|
||||
import { useAgent } from "agents/react"
|
||||
|
||||
function App() {
|
||||
const [state, setLocalState] = useState({ count: 0 })
|
||||
|
||||
const agent = useAgent({
|
||||
agent: "Counter",
|
||||
name: "my-instance",
|
||||
onStateUpdate: (newState) => setLocalState(newState),
|
||||
onIdentity: (name, agentType) => console.log(`Connected to ${name}`),
|
||||
})
|
||||
|
||||
return <button onClick={() => agent.setState({ count: state.count + 1 })}>Count: {state.count}</button>
|
||||
}
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- **[references/workflows.md](references/workflows.md)** - Durable Workflows integration
|
||||
- **[references/callable.md](references/callable.md)** - RPC methods, streaming, timeouts
|
||||
- **[references/state-scheduling.md](references/state-scheduling.md)** - State persistence, scheduling
|
||||
- **[references/streaming-chat.md](references/streaming-chat.md)** - AIChatAgent, resumable streams
|
||||
- **[references/mcp.md](references/mcp.md)** - MCP server integration
|
||||
- **[references/email.md](references/email.md)** - Email routing and handling
|
||||
- **[references/codemode.md](references/codemode.md)** - Code Mode (experimental)
|
||||
@@ -0,0 +1,92 @@
|
||||
# Callable Methods
|
||||
|
||||
Fetch `docs/callable-methods.md` from `https://github.com/cloudflare/agents/tree/main/docs` for complete documentation.
|
||||
|
||||
## Overview
|
||||
|
||||
`@callable()` exposes agent methods to clients via WebSocket RPC.
|
||||
|
||||
```typescript
|
||||
import { Agent, callable } from "agents"
|
||||
|
||||
export class MyAgent extends Agent<Env, State> {
|
||||
@callable()
|
||||
async greet(name: string): Promise<string> {
|
||||
return `Hello, ${name}!`
|
||||
}
|
||||
|
||||
@callable()
|
||||
async processData(data: unknown): Promise<Result> {
|
||||
// Long-running work
|
||||
return result
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Client Usage
|
||||
|
||||
```typescript
|
||||
// Basic call
|
||||
const greeting = await agent.call("greet", ["World"])
|
||||
|
||||
// With timeout
|
||||
const result = await agent.call("processData", [data], {
|
||||
timeout: 5000, // 5 second timeout
|
||||
})
|
||||
```
|
||||
|
||||
## Streaming Responses
|
||||
|
||||
```typescript
|
||||
import { Agent, callable, StreamingResponse } from "agents"
|
||||
|
||||
export class MyAgent extends Agent<Env, State> {
|
||||
@callable({ streaming: true })
|
||||
async streamResults(stream: StreamingResponse, query: string) {
|
||||
for await (const item of fetchResults(query)) {
|
||||
stream.send(JSON.stringify(item))
|
||||
}
|
||||
stream.close()
|
||||
}
|
||||
|
||||
@callable({ streaming: true })
|
||||
async streamWithError(stream: StreamingResponse) {
|
||||
try {
|
||||
// ... work
|
||||
} catch (error) {
|
||||
stream.error(error.message) // Signal error to client
|
||||
return
|
||||
}
|
||||
stream.close()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Client with streaming:
|
||||
|
||||
```typescript
|
||||
await agent.call("streamResults", ["search term"], {
|
||||
stream: {
|
||||
onChunk: (data) => console.log("Chunk:", data),
|
||||
onDone: () => console.log("Complete"),
|
||||
onError: (error) => console.error("Error:", error),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Introspection
|
||||
|
||||
```typescript
|
||||
// Get list of callable methods on an agent
|
||||
const methods = await agent.call("getCallableMethods", [])
|
||||
// Returns: ["greet", "processData", "streamResults", ...]
|
||||
```
|
||||
|
||||
## When to Use
|
||||
|
||||
| Scenario | Use |
|
||||
| ------------------------------------ | --------------------------- |
|
||||
| Browser/mobile calling agent | `@callable()` |
|
||||
| External service calling agent | `@callable()` |
|
||||
| Worker calling agent (same codebase) | DO RPC directly |
|
||||
| Agent calling another agent | `getAgentByName()` + DO RPC |
|
||||
@@ -0,0 +1,211 @@
|
||||
---
|
||||
name: cloudflare
|
||||
description: Comprehensive Cloudflare platform skill covering Workers, Pages, storage (KV, D1, R2), AI (Workers AI, Vectorize, Agents SDK), networking (Tunnel, Spectrum), security (WAF, DDoS), and infrastructure-as-code (Terraform, Pulumi). Use for any Cloudflare development task.
|
||||
references:
|
||||
- workers
|
||||
- pages
|
||||
- d1
|
||||
- durable-objects
|
||||
- workers-ai
|
||||
---
|
||||
|
||||
# Cloudflare Platform Skill
|
||||
|
||||
Consolidated skill for building on the Cloudflare platform. Use decision trees below to find the right product, then load detailed references.
|
||||
|
||||
## Quick Decision Trees
|
||||
|
||||
### "I need to run code"
|
||||
|
||||
```
|
||||
Need to run code?
|
||||
├─ Serverless functions at the edge → workers/
|
||||
├─ Full-stack web app with Git deploys → pages/
|
||||
├─ Stateful coordination/real-time → durable-objects/
|
||||
├─ Long-running multi-step jobs → workflows/
|
||||
├─ Run containers → containers/
|
||||
├─ Multi-tenant (customers deploy code) → workers-for-platforms/
|
||||
├─ Scheduled tasks (cron) → cron-triggers/
|
||||
├─ Lightweight edge logic (modify HTTP) → snippets/
|
||||
├─ Process Worker execution events (logs/observability) → tail-workers/
|
||||
└─ Optimize latency to backend infrastructure → smart-placement/
|
||||
```
|
||||
|
||||
### "I need to store data"
|
||||
|
||||
```
|
||||
Need storage?
|
||||
├─ Key-value (config, sessions, cache) → kv/
|
||||
├─ Relational SQL → d1/ (SQLite) or hyperdrive/ (existing Postgres/MySQL)
|
||||
├─ Object/file storage (S3-compatible) → r2/
|
||||
├─ Message queue (async processing) → queues/
|
||||
├─ Vector embeddings (AI/semantic search) → vectorize/
|
||||
├─ Strongly-consistent per-entity state → durable-objects/ (DO storage)
|
||||
├─ Secrets management → secrets-store/
|
||||
├─ Streaming ETL to R2 → pipelines/
|
||||
└─ Persistent cache (long-term retention) → cache-reserve/
|
||||
```
|
||||
|
||||
### "I need AI/ML"
|
||||
|
||||
```
|
||||
Need AI?
|
||||
├─ Run inference (LLMs, embeddings, images) → workers-ai/
|
||||
├─ Vector database for RAG/search → vectorize/
|
||||
├─ Build stateful AI agents → agents-sdk/
|
||||
├─ Gateway for any AI provider (caching, routing) → ai-gateway/
|
||||
└─ AI-powered search widget → ai-search/
|
||||
```
|
||||
|
||||
### "I need networking/connectivity"
|
||||
|
||||
```
|
||||
Need networking?
|
||||
├─ Expose local service to internet → tunnel/
|
||||
├─ TCP/UDP proxy (non-HTTP) → spectrum/
|
||||
├─ WebRTC TURN server → turn/
|
||||
├─ Private network connectivity → network-interconnect/
|
||||
├─ Optimize routing → argo-smart-routing/
|
||||
├─ Optimize latency to backend (not user) → smart-placement/
|
||||
└─ Real-time video/audio → realtimekit/ or realtime-sfu/
|
||||
```
|
||||
|
||||
### "I need security"
|
||||
|
||||
```
|
||||
Need security?
|
||||
├─ Web Application Firewall → waf/
|
||||
├─ DDoS protection → ddos/
|
||||
├─ Bot detection/management → bot-management/
|
||||
├─ API protection → api-shield/
|
||||
├─ CAPTCHA alternative → turnstile/
|
||||
└─ Credential leak detection → waf/ (managed ruleset)
|
||||
```
|
||||
|
||||
### "I need media/content"
|
||||
|
||||
```
|
||||
Need media?
|
||||
├─ Image optimization/transformation → images/
|
||||
├─ Video streaming/encoding → stream/
|
||||
├─ Browser automation/screenshots → browser-rendering/
|
||||
└─ Third-party script management → zaraz/
|
||||
```
|
||||
|
||||
### "I need infrastructure-as-code"
|
||||
|
||||
```
|
||||
Need IaC? → pulumi/ (Pulumi), terraform/ (Terraform), or api/ (REST API)
|
||||
```
|
||||
|
||||
## Product Index
|
||||
|
||||
### Compute & Runtime
|
||||
|
||||
| Product | Reference |
|
||||
| --------------------- | ----------------------------------- |
|
||||
| Workers | `references/workers/` |
|
||||
| Pages | `references/pages/` |
|
||||
| Pages Functions | `references/pages-functions/` |
|
||||
| Durable Objects | `references/durable-objects/` |
|
||||
| Workflows | `references/workflows/` |
|
||||
| Containers | `references/containers/` |
|
||||
| Workers for Platforms | `references/workers-for-platforms/` |
|
||||
| Cron Triggers | `references/cron-triggers/` |
|
||||
| Tail Workers | `references/tail-workers/` |
|
||||
| Snippets | `references/snippets/` |
|
||||
| Smart Placement | `references/smart-placement/` |
|
||||
|
||||
### Storage & Data
|
||||
|
||||
| Product | Reference |
|
||||
| --------------- | ----------------------------- |
|
||||
| KV | `references/kv/` |
|
||||
| D1 | `references/d1/` |
|
||||
| R2 | `references/r2/` |
|
||||
| Queues | `references/queues/` |
|
||||
| Hyperdrive | `references/hyperdrive/` |
|
||||
| DO Storage | `references/do-storage/` |
|
||||
| Secrets Store | `references/secrets-store/` |
|
||||
| Pipelines | `references/pipelines/` |
|
||||
| R2 Data Catalog | `references/r2-data-catalog/` |
|
||||
| R2 SQL | `references/r2-sql/` |
|
||||
|
||||
### AI & Machine Learning
|
||||
|
||||
| Product | Reference |
|
||||
| ---------- | ------------------------ |
|
||||
| Workers AI | `references/workers-ai/` |
|
||||
| Vectorize | `references/vectorize/` |
|
||||
| Agents SDK | `references/agents-sdk/` |
|
||||
| AI Gateway | `references/ai-gateway/` |
|
||||
| AI Search | `references/ai-search/` |
|
||||
|
||||
### Networking & Connectivity
|
||||
|
||||
| Product | Reference |
|
||||
| -------------------- | ---------------------------------- |
|
||||
| Tunnel | `references/tunnel/` |
|
||||
| Spectrum | `references/spectrum/` |
|
||||
| TURN | `references/turn/` |
|
||||
| Network Interconnect | `references/network-interconnect/` |
|
||||
| Argo Smart Routing | `references/argo-smart-routing/` |
|
||||
| Workers VPC | `references/workers-vpc/` |
|
||||
|
||||
### Security
|
||||
|
||||
| Product | Reference |
|
||||
| --------------- | ---------------------------- |
|
||||
| WAF | `references/waf/` |
|
||||
| DDoS Protection | `references/ddos/` |
|
||||
| Bot Management | `references/bot-management/` |
|
||||
| API Shield | `references/api-shield/` |
|
||||
| Turnstile | `references/turnstile/` |
|
||||
|
||||
### Media & Content
|
||||
|
||||
| Product | Reference |
|
||||
| ----------------- | ------------------------------- |
|
||||
| Images | `references/images/` |
|
||||
| Stream | `references/stream/` |
|
||||
| Browser Rendering | `references/browser-rendering/` |
|
||||
| Zaraz | `references/zaraz/` |
|
||||
|
||||
### Real-Time Communication
|
||||
|
||||
| Product | Reference |
|
||||
| ------------ | -------------------------- |
|
||||
| RealtimeKit | `references/realtimekit/` |
|
||||
| Realtime SFU | `references/realtime-sfu/` |
|
||||
|
||||
### Developer Tools
|
||||
|
||||
| Product | Reference |
|
||||
| ------------------ | -------------------------------- |
|
||||
| Wrangler | `references/wrangler/` |
|
||||
| Miniflare | `references/miniflare/` |
|
||||
| C3 | `references/c3/` |
|
||||
| Observability | `references/observability/` |
|
||||
| Analytics Engine | `references/analytics-engine/` |
|
||||
| Web Analytics | `references/web-analytics/` |
|
||||
| Sandbox | `references/sandbox/` |
|
||||
| Workerd | `references/workerd/` |
|
||||
| Workers Playground | `references/workers-playground/` |
|
||||
|
||||
### Infrastructure as Code
|
||||
|
||||
| Product | Reference |
|
||||
| --------- | ----------------------- |
|
||||
| Pulumi | `references/pulumi/` |
|
||||
| Terraform | `references/terraform/` |
|
||||
| API | `references/api/` |
|
||||
|
||||
### Other Services
|
||||
|
||||
| Product | Reference |
|
||||
| ------------- | --------------------------- |
|
||||
| Email Routing | `references/email-routing/` |
|
||||
| Email Workers | `references/email-workers/` |
|
||||
| Static Assets | `references/static-assets/` |
|
||||
| Bindings | `references/bindings/` |
|
||||
| Cache Reserve | `references/cache-reserve/` |
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"skills": [
|
||||
{ "name": "agents-sdk", "description": "Cloudflare Agents SDK", "files": ["SKILL.md", "references/callable.md"] },
|
||||
{ "name": "cloudflare", "description": "Cloudflare Platform Skill", "files": ["SKILL.md"] }
|
||||
]
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Log } from "../../src/util/log"
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
|
||||
Log.init({ print: false })
|
||||
@@ -78,7 +79,7 @@ describe("Project.fromDirectory", () => {
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
|
||||
const opencodeFile = path.join(tmp.path, ".git", "opencode")
|
||||
const fileExists = await Bun.file(opencodeFile).exists()
|
||||
const fileExists = await Filesystem.exists(opencodeFile)
|
||||
expect(fileExists).toBe(false)
|
||||
})
|
||||
|
||||
@@ -94,7 +95,7 @@ describe("Project.fromDirectory", () => {
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
|
||||
const opencodeFile = path.join(tmp.path, ".git", "opencode")
|
||||
const fileExists = await Bun.file(opencodeFile).exists()
|
||||
const fileExists = await Filesystem.exists(opencodeFile)
|
||||
expect(fileExists).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Worktree } from "../../src/worktree"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("Worktree.remove", () => {
|
||||
@@ -53,7 +54,7 @@ describe("Worktree.remove", () => {
|
||||
})()
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(await Bun.file(dir).exists()).toBe(false)
|
||||
expect(await Filesystem.exists(dir)).toBe(false)
|
||||
|
||||
const list = await $`git worktree list --porcelain`.cwd(root).quiet().text()
|
||||
expect(list).not.toContain(`worktree ${dir}`)
|
||||
|
||||
@@ -7,11 +7,12 @@ import { Instance } from "../../src/project/instance"
|
||||
import { Provider } from "../../src/provider/provider"
|
||||
import { Env } from "../../src/env"
|
||||
import { Global } from "../../src/global"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
|
||||
test("Bedrock: config region takes precedence over AWS_REGION env var", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -43,7 +44,7 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async ()
|
||||
test("Bedrock: falls back to AWS_REGION env var when no config region", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -68,7 +69,7 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async ()
|
||||
test("Bedrock: loads when bearer token from auth.json is present", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -89,14 +90,14 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {
|
||||
// Save original auth.json if it exists
|
||||
let originalAuth: string | undefined
|
||||
try {
|
||||
originalAuth = await Bun.file(authPath).text()
|
||||
originalAuth = await Filesystem.readText(authPath)
|
||||
} catch {
|
||||
// File doesn't exist, that's fine
|
||||
}
|
||||
|
||||
try {
|
||||
// Write test auth.json
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
"amazon-bedrock": {
|
||||
@@ -122,7 +123,7 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {
|
||||
} finally {
|
||||
// Restore original or delete
|
||||
if (originalAuth !== undefined) {
|
||||
await Bun.write(authPath, originalAuth)
|
||||
await Filesystem.write(authPath, originalAuth)
|
||||
} else {
|
||||
try {
|
||||
await unlink(authPath)
|
||||
@@ -136,7 +137,7 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {
|
||||
test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -169,7 +170,7 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async
|
||||
test("Bedrock: includes custom endpoint in options when specified", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -202,7 +203,7 @@ test("Bedrock: includes custom endpoint in options when specified", async () =>
|
||||
test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -240,7 +241,7 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async ()
|
||||
test("Bedrock: model with us. prefix should not be double-prefixed", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -277,7 +278,7 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () =>
|
||||
test("Bedrock: model with global. prefix should not be prefixed", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -313,7 +314,7 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => {
|
||||
test("Bedrock: model with eu. prefix should not be double-prefixed", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -349,7 +350,7 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () =>
|
||||
test("Bedrock: model without prefix in US region should get us. prefix added", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
|
||||
@@ -1705,6 +1705,66 @@ describe("ProviderTransform.variants", () => {
|
||||
})
|
||||
|
||||
describe("@ai-sdk/gateway", () => {
|
||||
test("anthropic sonnet 4.6 models return adaptive thinking options", () => {
|
||||
const model = createMockModel({
|
||||
id: "anthropic/claude-sonnet-4-6",
|
||||
providerID: "gateway",
|
||||
api: {
|
||||
id: "anthropic/claude-sonnet-4-6",
|
||||
url: "https://gateway.ai",
|
||||
npm: "@ai-sdk/gateway",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"])
|
||||
expect(result.medium).toEqual({
|
||||
thinking: {
|
||||
type: "adaptive",
|
||||
},
|
||||
effort: "medium",
|
||||
})
|
||||
})
|
||||
|
||||
test("anthropic sonnet 4.6 dot-format models return adaptive thinking options", () => {
|
||||
const model = createMockModel({
|
||||
id: "anthropic/claude-sonnet-4-6",
|
||||
providerID: "gateway",
|
||||
api: {
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
url: "https://gateway.ai",
|
||||
npm: "@ai-sdk/gateway",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"])
|
||||
expect(result.medium).toEqual({
|
||||
thinking: {
|
||||
type: "adaptive",
|
||||
},
|
||||
effort: "medium",
|
||||
})
|
||||
})
|
||||
|
||||
test("anthropic opus 4.6 dot-format models return adaptive thinking options", () => {
|
||||
const model = createMockModel({
|
||||
id: "anthropic/claude-opus-4-6",
|
||||
providerID: "gateway",
|
||||
api: {
|
||||
id: "anthropic/claude-opus-4.6",
|
||||
url: "https://gateway.ai",
|
||||
npm: "@ai-sdk/gateway",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"])
|
||||
expect(result.high).toEqual({
|
||||
thinking: {
|
||||
type: "adaptive",
|
||||
},
|
||||
effort: "high",
|
||||
})
|
||||
})
|
||||
|
||||
test("anthropic models return anthropic thinking options", () => {
|
||||
const model = createMockModel({
|
||||
id: "anthropic/claude-sonnet-4",
|
||||
@@ -2064,6 +2124,26 @@ describe("ProviderTransform.variants", () => {
|
||||
})
|
||||
|
||||
describe("@ai-sdk/anthropic", () => {
|
||||
test("sonnet 4.6 returns adaptive thinking options", () => {
|
||||
const model = createMockModel({
|
||||
id: "anthropic/claude-sonnet-4-6",
|
||||
providerID: "anthropic",
|
||||
api: {
|
||||
id: "claude-sonnet-4-6",
|
||||
url: "https://api.anthropic.com",
|
||||
npm: "@ai-sdk/anthropic",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"])
|
||||
expect(result.high).toEqual({
|
||||
thinking: {
|
||||
type: "adaptive",
|
||||
},
|
||||
effort: "high",
|
||||
})
|
||||
})
|
||||
|
||||
test("returns high and max with thinking config", () => {
|
||||
const model = createMockModel({
|
||||
id: "anthropic/claude-4",
|
||||
@@ -2092,6 +2172,26 @@ describe("ProviderTransform.variants", () => {
|
||||
})
|
||||
|
||||
describe("@ai-sdk/amazon-bedrock", () => {
|
||||
test("anthropic sonnet 4.6 returns adaptive reasoning options", () => {
|
||||
const model = createMockModel({
|
||||
id: "bedrock/anthropic-claude-sonnet-4-6",
|
||||
providerID: "bedrock",
|
||||
api: {
|
||||
id: "anthropic.claude-sonnet-4-6",
|
||||
url: "https://bedrock.amazonaws.com",
|
||||
npm: "@ai-sdk/amazon-bedrock",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"])
|
||||
expect(result.max).toEqual({
|
||||
reasoningConfig: {
|
||||
type: "adaptive",
|
||||
maxReasoningEffort: "max",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns WIDELY_SUPPORTED_EFFORTS with reasoningConfig", () => {
|
||||
const model = createMockModel({
|
||||
id: "bedrock/llama-4",
|
||||
@@ -2153,12 +2253,16 @@ describe("ProviderTransform.variants", () => {
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "high"])
|
||||
expect(result.low).toEqual({
|
||||
includeThoughts: true,
|
||||
thinkingLevel: "low",
|
||||
thinkingConfig: {
|
||||
includeThoughts: true,
|
||||
thinkingLevel: "low",
|
||||
},
|
||||
})
|
||||
expect(result.high).toEqual({
|
||||
includeThoughts: true,
|
||||
thinkingLevel: "high",
|
||||
thinkingConfig: {
|
||||
includeThoughts: true,
|
||||
thinkingLevel: "high",
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2223,12 +2327,10 @@ describe("ProviderTransform.variants", () => {
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["none", "low", "medium", "high"])
|
||||
expect(result.none).toEqual({
|
||||
includeThoughts: true,
|
||||
thinkingLevel: "none",
|
||||
reasoningEffort: "none",
|
||||
})
|
||||
expect(result.low).toEqual({
|
||||
includeThoughts: true,
|
||||
thinkingLevel: "low",
|
||||
reasoningEffort: "low",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,8 +80,8 @@ describe("pty", () => {
|
||||
Pty.connect(a.id, ws as any)
|
||||
outA.length = 0
|
||||
|
||||
// Simulate Bun reusing the same websocket object for another connection
|
||||
// before the new onOpen handler has a chance to tag it.
|
||||
// Simulate Bun reusing the same websocket object for another
|
||||
// connection before the next onOpen calls Pty.connect.
|
||||
ws.data = { events: { connection: "b" } }
|
||||
ws.send = (data: unknown) => {
|
||||
outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Instance } from "../../src/project/instance"
|
||||
import { Provider } from "../../src/provider/provider"
|
||||
import { ProviderTransform } from "../../src/provider/transform"
|
||||
import { ModelsDev } from "../../src/provider/models"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import type { Agent } from "../../src/agent/agent"
|
||||
import type { MessageV2 } from "../../src/session/message-v2"
|
||||
@@ -185,7 +186,7 @@ function createChatStream(text: string) {
|
||||
|
||||
async function loadFixture(providerID: string, modelID: string) {
|
||||
const fixturePath = path.join(import.meta.dir, "../tool/fixtures/models-api.json")
|
||||
const data = (await Bun.file(fixturePath).json()) as Record<string, ModelsDev.Provider>
|
||||
const data = await Filesystem.readJson<Record<string, ModelsDev.Provider>>(fixturePath)
|
||||
const provider = data[providerID]
|
||||
if (!provider) {
|
||||
throw new Error(`Missing provider in fixture: ${providerID}`)
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { describe, test, expect, beforeAll, afterAll } from "bun:test"
|
||||
import { Discovery } from "../../src/skill/discovery"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { rm } from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
const CLOUDFLARE_SKILLS_URL = "https://developers.cloudflare.com/.well-known/skills/"
|
||||
let CLOUDFLARE_SKILLS_URL: string
|
||||
let server: ReturnType<typeof Bun.serve>
|
||||
let downloadCount = 0
|
||||
|
||||
const fixturePath = path.join(import.meta.dir, "../fixture/skills")
|
||||
|
||||
beforeAll(async () => {
|
||||
await rm(Discovery.dir(), { recursive: true, force: true })
|
||||
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
|
||||
// route /.well-known/skills/* to the fixture directory
|
||||
if (url.pathname.startsWith("/.well-known/skills/")) {
|
||||
const filePath = url.pathname.replace("/.well-known/skills/", "")
|
||||
const fullPath = path.join(fixturePath, filePath)
|
||||
|
||||
if (await Filesystem.exists(fullPath)) {
|
||||
if (!fullPath.endsWith("index.json")) {
|
||||
downloadCount++
|
||||
}
|
||||
return new Response(Bun.file(fullPath))
|
||||
}
|
||||
}
|
||||
|
||||
return new Response("Not Found", { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
CLOUDFLARE_SKILLS_URL = `http://localhost:${server.port}/.well-known/skills/`
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
await rm(Discovery.dir(), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("Discovery.pull", () => {
|
||||
test("downloads skills from cloudflare url", async () => {
|
||||
@@ -11,26 +50,27 @@ describe("Discovery.pull", () => {
|
||||
for (const dir of dirs) {
|
||||
expect(dir).toStartWith(Discovery.dir())
|
||||
const md = path.join(dir, "SKILL.md")
|
||||
expect(await Bun.file(md).exists()).toBe(true)
|
||||
expect(await Filesystem.exists(md)).toBe(true)
|
||||
}
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
test("url without trailing slash works", async () => {
|
||||
const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, ""))
|
||||
expect(dirs.length).toBeGreaterThan(0)
|
||||
for (const dir of dirs) {
|
||||
const md = path.join(dir, "SKILL.md")
|
||||
expect(await Bun.file(md).exists()).toBe(true)
|
||||
expect(await Filesystem.exists(md)).toBe(true)
|
||||
}
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
test("returns empty array for invalid url", async () => {
|
||||
const dirs = await Discovery.pull("https://example.invalid/.well-known/skills/")
|
||||
const dirs = await Discovery.pull(`http://localhost:${server.port}/invalid-url/`)
|
||||
expect(dirs).toEqual([])
|
||||
})
|
||||
|
||||
test("returns empty array for non-json response", async () => {
|
||||
const dirs = await Discovery.pull("https://example.com/")
|
||||
// any url not explicitly handled in server returns 404 text "Not Found"
|
||||
const dirs = await Discovery.pull(`http://localhost:${server.port}/some-other-path/`)
|
||||
expect(dirs).toEqual([])
|
||||
})
|
||||
|
||||
@@ -38,23 +78,33 @@ describe("Discovery.pull", () => {
|
||||
const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
|
||||
// find a skill dir that should have reference files (e.g. agents-sdk)
|
||||
const agentsSdk = dirs.find((d) => d.endsWith("/agents-sdk"))
|
||||
expect(agentsSdk).toBeDefined()
|
||||
if (agentsSdk) {
|
||||
const refs = path.join(agentsSdk, "references")
|
||||
expect(await Bun.file(path.join(agentsSdk, "SKILL.md")).exists()).toBe(true)
|
||||
expect(await Filesystem.exists(path.join(agentsSdk, "SKILL.md"))).toBe(true)
|
||||
// agents-sdk has reference files per the index
|
||||
const refDir = await Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: refs, onlyFiles: true }))
|
||||
expect(refDir.length).toBeGreaterThan(0)
|
||||
}
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
test("caches downloaded files on second pull", async () => {
|
||||
// clear dir and downloadCount
|
||||
await rm(Discovery.dir(), { recursive: true, force: true })
|
||||
downloadCount = 0
|
||||
|
||||
// first pull to populate cache
|
||||
const first = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
|
||||
expect(first.length).toBeGreaterThan(0)
|
||||
const firstCount = downloadCount
|
||||
expect(firstCount).toBeGreaterThan(0)
|
||||
|
||||
// second pull should return same results from cache
|
||||
const second = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
|
||||
expect(second.length).toBe(first.length)
|
||||
expect(second.sort()).toEqual(first.sort())
|
||||
}, 60_000)
|
||||
|
||||
// second pull should NOT increment download count
|
||||
expect(downloadCount).toBe(firstCount)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
async function bootstrap() {
|
||||
@@ -11,8 +13,8 @@ async function bootstrap() {
|
||||
const unique = Math.random().toString(36).slice(2)
|
||||
const aContent = `A${unique}`
|
||||
const bContent = `B${unique}`
|
||||
await Bun.write(`${dir}/a.txt`, aContent)
|
||||
await Bun.write(`${dir}/b.txt`, bContent)
|
||||
await Filesystem.write(`${dir}/a.txt`, aContent)
|
||||
await Filesystem.write(`${dir}/b.txt`, bContent)
|
||||
await $`git add .`.cwd(dir).quiet()
|
||||
await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet()
|
||||
return {
|
||||
@@ -46,11 +48,16 @@ test("revert should remove new files", async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Bun.write(`${tmp.path}/new.txt`, "NEW")
|
||||
await Filesystem.write(`${tmp.path}/new.txt`, "NEW")
|
||||
|
||||
await Snapshot.revert([await Snapshot.patch(before!)])
|
||||
|
||||
expect(await Bun.file(`${tmp.path}/new.txt`).exists()).toBe(false)
|
||||
expect(
|
||||
await fs
|
||||
.access(`${tmp.path}/new.txt`)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -64,11 +71,16 @@ test("revert in subdirectory", async () => {
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await $`mkdir -p ${tmp.path}/sub`.quiet()
|
||||
await Bun.write(`${tmp.path}/sub/file.txt`, "SUB")
|
||||
await Filesystem.write(`${tmp.path}/sub/file.txt`, "SUB")
|
||||
|
||||
await Snapshot.revert([await Snapshot.patch(before!)])
|
||||
|
||||
expect(await Bun.file(`${tmp.path}/sub/file.txt`).exists()).toBe(false)
|
||||
expect(
|
||||
await fs
|
||||
.access(`${tmp.path}/sub/file.txt`)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(false)
|
||||
// Note: revert currently only removes files, not directories
|
||||
// The empty subdirectory will remain
|
||||
},
|
||||
@@ -84,18 +96,23 @@ test("multiple file operations", async () => {
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await $`rm ${tmp.path}/a.txt`.quiet()
|
||||
await Bun.write(`${tmp.path}/c.txt`, "C")
|
||||
await Filesystem.write(`${tmp.path}/c.txt`, "C")
|
||||
await $`mkdir -p ${tmp.path}/dir`.quiet()
|
||||
await Bun.write(`${tmp.path}/dir/d.txt`, "D")
|
||||
await Bun.write(`${tmp.path}/b.txt`, "MODIFIED")
|
||||
await Filesystem.write(`${tmp.path}/dir/d.txt`, "D")
|
||||
await Filesystem.write(`${tmp.path}/b.txt`, "MODIFIED")
|
||||
|
||||
await Snapshot.revert([await Snapshot.patch(before!)])
|
||||
|
||||
expect(await Bun.file(`${tmp.path}/a.txt`).text()).toBe(tmp.extra.aContent)
|
||||
expect(await Bun.file(`${tmp.path}/c.txt`).exists()).toBe(false)
|
||||
expect(await fs.readFile(`${tmp.path}/a.txt`, "utf-8")).toBe(tmp.extra.aContent)
|
||||
expect(
|
||||
await fs
|
||||
.access(`${tmp.path}/c.txt`)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(false)
|
||||
// Note: revert currently only removes files, not directories
|
||||
// The empty directory will remain
|
||||
expect(await Bun.file(`${tmp.path}/b.txt`).text()).toBe(tmp.extra.bContent)
|
||||
expect(await fs.readFile(`${tmp.path}/b.txt`, "utf-8")).toBe(tmp.extra.bContent)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -123,13 +140,18 @@ test("binary file handling", async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Bun.write(`${tmp.path}/image.png`, new Uint8Array([0x89, 0x50, 0x4e, 0x47]))
|
||||
await Filesystem.write(`${tmp.path}/image.png`, new Uint8Array([0x89, 0x50, 0x4e, 0x47]))
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
expect(patch.files).toContain(`${tmp.path}/image.png`)
|
||||
|
||||
await Snapshot.revert([patch])
|
||||
expect(await Bun.file(`${tmp.path}/image.png`).exists()).toBe(false)
|
||||
expect(
|
||||
await fs
|
||||
.access(`${tmp.path}/image.png`)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -157,7 +179,7 @@ test("large file handling", async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Bun.write(`${tmp.path}/large.txt`, "x".repeat(1024 * 1024))
|
||||
await Filesystem.write(`${tmp.path}/large.txt`, "x".repeat(1024 * 1024))
|
||||
|
||||
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.path}/large.txt`)
|
||||
},
|
||||
@@ -173,11 +195,16 @@ test("nested directory revert", async () => {
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await $`mkdir -p ${tmp.path}/level1/level2/level3`.quiet()
|
||||
await Bun.write(`${tmp.path}/level1/level2/level3/deep.txt`, "DEEP")
|
||||
await Filesystem.write(`${tmp.path}/level1/level2/level3/deep.txt`, "DEEP")
|
||||
|
||||
await Snapshot.revert([await Snapshot.patch(before!)])
|
||||
|
||||
expect(await Bun.file(`${tmp.path}/level1/level2/level3/deep.txt`).exists()).toBe(false)
|
||||
expect(
|
||||
await fs
|
||||
.access(`${tmp.path}/level1/level2/level3/deep.txt`)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -190,9 +217,9 @@ test("special characters in filenames", async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Bun.write(`${tmp.path}/file with spaces.txt`, "SPACES")
|
||||
await Bun.write(`${tmp.path}/file-with-dashes.txt`, "DASHES")
|
||||
await Bun.write(`${tmp.path}/file_with_underscores.txt`, "UNDERSCORES")
|
||||
await Filesystem.write(`${tmp.path}/file with spaces.txt`, "SPACES")
|
||||
await Filesystem.write(`${tmp.path}/file-with-dashes.txt`, "DASHES")
|
||||
await Filesystem.write(`${tmp.path}/file_with_underscores.txt`, "UNDERSCORES")
|
||||
|
||||
const files = (await Snapshot.patch(before!)).files
|
||||
expect(files).toContain(`${tmp.path}/file with spaces.txt`)
|
||||
@@ -225,7 +252,7 @@ test("patch with invalid hash", async () => {
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
// Create a change
|
||||
await Bun.write(`${tmp.path}/test.txt`, "TEST")
|
||||
await Filesystem.write(`${tmp.path}/test.txt`, "TEST")
|
||||
|
||||
// Try to patch with invalid hash - should handle gracefully
|
||||
const patch = await Snapshot.patch("invalid-hash-12345")
|
||||
@@ -273,7 +300,7 @@ test("unicode filenames", async () => {
|
||||
]
|
||||
|
||||
for (const file of unicodeFiles) {
|
||||
await Bun.write(file.path, file.content)
|
||||
await Filesystem.write(file.path, file.content)
|
||||
}
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
@@ -286,7 +313,12 @@ test("unicode filenames", async () => {
|
||||
await Snapshot.revert([patch])
|
||||
|
||||
for (const file of unicodeFiles) {
|
||||
expect(await Bun.file(file.path).exists()).toBe(false)
|
||||
expect(
|
||||
await fs
|
||||
.access(file.path)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -300,14 +332,14 @@ test.skip("unicode filenames modification and restore", async () => {
|
||||
const chineseFile = `${tmp.path}/文件.txt`
|
||||
const cyrillicFile = `${tmp.path}/файл.txt`
|
||||
|
||||
await Bun.write(chineseFile, "original chinese")
|
||||
await Bun.write(cyrillicFile, "original cyrillic")
|
||||
await Filesystem.write(chineseFile, "original chinese")
|
||||
await Filesystem.write(cyrillicFile, "original cyrillic")
|
||||
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Bun.write(chineseFile, "modified chinese")
|
||||
await Bun.write(cyrillicFile, "modified cyrillic")
|
||||
await Filesystem.write(chineseFile, "modified chinese")
|
||||
await Filesystem.write(cyrillicFile, "modified cyrillic")
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
expect(patch.files).toContain(chineseFile)
|
||||
@@ -315,8 +347,8 @@ test.skip("unicode filenames modification and restore", async () => {
|
||||
|
||||
await Snapshot.revert([patch])
|
||||
|
||||
expect(await Bun.file(chineseFile).text()).toBe("original chinese")
|
||||
expect(await Bun.file(cyrillicFile).text()).toBe("original cyrillic")
|
||||
expect(await fs.readFile(chineseFile, "utf-8")).toBe("original chinese")
|
||||
expect(await fs.readFile(cyrillicFile, "utf-8")).toBe("original cyrillic")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -331,13 +363,18 @@ test("unicode filenames in subdirectories", async () => {
|
||||
|
||||
await $`mkdir -p "${tmp.path}/目录/подкаталог"`.quiet()
|
||||
const deepFile = `${tmp.path}/目录/подкаталог/文件.txt`
|
||||
await Bun.write(deepFile, "deep unicode content")
|
||||
await Filesystem.write(deepFile, "deep unicode content")
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
expect(patch.files).toContain(deepFile)
|
||||
|
||||
await Snapshot.revert([patch])
|
||||
expect(await Bun.file(deepFile).exists()).toBe(false)
|
||||
expect(
|
||||
await fs
|
||||
.access(deepFile)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -353,13 +390,18 @@ test("very long filenames", async () => {
|
||||
const longName = "a".repeat(200) + ".txt"
|
||||
const longFile = `${tmp.path}/${longName}`
|
||||
|
||||
await Bun.write(longFile, "long filename content")
|
||||
await Filesystem.write(longFile, "long filename content")
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
expect(patch.files).toContain(longFile)
|
||||
|
||||
await Snapshot.revert([patch])
|
||||
expect(await Bun.file(longFile).exists()).toBe(false)
|
||||
expect(
|
||||
await fs
|
||||
.access(longFile)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -372,9 +414,9 @@ test("hidden files", async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Bun.write(`${tmp.path}/.hidden`, "hidden content")
|
||||
await Bun.write(`${tmp.path}/.gitignore`, "*.log")
|
||||
await Bun.write(`${tmp.path}/.config`, "config content")
|
||||
await Filesystem.write(`${tmp.path}/.hidden`, "hidden content")
|
||||
await Filesystem.write(`${tmp.path}/.gitignore`, "*.log")
|
||||
await Filesystem.write(`${tmp.path}/.config`, "config content")
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
expect(patch.files).toContain(`${tmp.path}/.hidden`)
|
||||
@@ -393,7 +435,7 @@ test("nested symlinks", async () => {
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await $`mkdir -p ${tmp.path}/sub/dir`.quiet()
|
||||
await Bun.write(`${tmp.path}/sub/dir/target.txt`, "target content")
|
||||
await Filesystem.write(`${tmp.path}/sub/dir/target.txt`, "target content")
|
||||
await $`ln -s ${tmp.path}/sub/dir/target.txt ${tmp.path}/sub/dir/link.txt`.quiet()
|
||||
await $`ln -s ${tmp.path}/sub ${tmp.path}/sub-link`.quiet()
|
||||
|
||||
@@ -450,9 +492,9 @@ test("gitignore changes", async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Bun.write(`${tmp.path}/.gitignore`, "*.ignored")
|
||||
await Bun.write(`${tmp.path}/test.ignored`, "ignored content")
|
||||
await Bun.write(`${tmp.path}/normal.txt`, "normal content")
|
||||
await Filesystem.write(`${tmp.path}/.gitignore`, "*.ignored")
|
||||
await Filesystem.write(`${tmp.path}/test.ignored`, "ignored content")
|
||||
await Filesystem.write(`${tmp.path}/normal.txt`, "normal content")
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
|
||||
@@ -477,7 +519,7 @@ test("concurrent file operations during patch", async () => {
|
||||
// Start creating files
|
||||
const createPromise = (async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await Bun.write(`${tmp.path}/concurrent${i}.txt`, `concurrent${i}`)
|
||||
await Filesystem.write(`${tmp.path}/concurrent${i}.txt`, `concurrent${i}`)
|
||||
// Small delay to simulate concurrent operations
|
||||
await new Promise((resolve) => setTimeout(resolve, 1))
|
||||
}
|
||||
@@ -504,7 +546,7 @@ test("snapshot state isolation between projects", async () => {
|
||||
directory: tmp1.path,
|
||||
fn: async () => {
|
||||
const before1 = await Snapshot.track()
|
||||
await Bun.write(`${tmp1.path}/project1.txt`, "project1 content")
|
||||
await Filesystem.write(`${tmp1.path}/project1.txt`, "project1 content")
|
||||
const patch1 = await Snapshot.patch(before1!)
|
||||
expect(patch1.files).toContain(`${tmp1.path}/project1.txt`)
|
||||
},
|
||||
@@ -514,7 +556,7 @@ test("snapshot state isolation between projects", async () => {
|
||||
directory: tmp2.path,
|
||||
fn: async () => {
|
||||
const before2 = await Snapshot.track()
|
||||
await Bun.write(`${tmp2.path}/project2.txt`, "project2 content")
|
||||
await Filesystem.write(`${tmp2.path}/project2.txt`, "project2 content")
|
||||
const patch2 = await Snapshot.patch(before2!)
|
||||
expect(patch2.files).toContain(`${tmp2.path}/project2.txt`)
|
||||
|
||||
@@ -544,7 +586,7 @@ test("patch detects changes in secondary worktree", async () => {
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
const worktreeFile = `${worktreePath}/worktree.txt`
|
||||
await Bun.write(worktreeFile, "worktree content")
|
||||
await Filesystem.write(worktreeFile, "worktree content")
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
expect(patch.files).toContain(worktreeFile)
|
||||
@@ -569,7 +611,7 @@ test("revert only removes files in invoking worktree", async () => {
|
||||
},
|
||||
})
|
||||
const primaryFile = `${tmp.path}/worktree.txt`
|
||||
await Bun.write(primaryFile, "primary content")
|
||||
await Filesystem.write(primaryFile, "primary content")
|
||||
|
||||
await Instance.provide({
|
||||
directory: worktreePath,
|
||||
@@ -578,16 +620,21 @@ test("revert only removes files in invoking worktree", async () => {
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
const worktreeFile = `${worktreePath}/worktree.txt`
|
||||
await Bun.write(worktreeFile, "worktree content")
|
||||
await Filesystem.write(worktreeFile, "worktree content")
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
await Snapshot.revert([patch])
|
||||
|
||||
expect(await Bun.file(worktreeFile).exists()).toBe(false)
|
||||
expect(
|
||||
await fs
|
||||
.access(worktreeFile)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(false)
|
||||
},
|
||||
})
|
||||
|
||||
expect(await Bun.file(primaryFile).text()).toBe("primary content")
|
||||
expect(await fs.readFile(primaryFile, "utf-8")).toBe("primary content")
|
||||
} finally {
|
||||
await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow()
|
||||
await $`rm -rf ${worktreePath}`.quiet()
|
||||
@@ -614,10 +661,10 @@ test("diff reports worktree-only/shared edits and ignores primary-only", async (
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Bun.write(`${worktreePath}/worktree-only.txt`, "worktree diff content")
|
||||
await Bun.write(`${worktreePath}/shared.txt`, "worktree edit")
|
||||
await Bun.write(`${tmp.path}/shared.txt`, "primary edit")
|
||||
await Bun.write(`${tmp.path}/primary-only.txt`, "primary change")
|
||||
await Filesystem.write(`${worktreePath}/worktree-only.txt`, "worktree diff content")
|
||||
await Filesystem.write(`${worktreePath}/shared.txt`, "worktree edit")
|
||||
await Filesystem.write(`${tmp.path}/shared.txt`, "primary edit")
|
||||
await Filesystem.write(`${tmp.path}/primary-only.txt`, "primary change")
|
||||
|
||||
const diff = await Snapshot.diff(before!)
|
||||
expect(diff).toContain("worktree-only.txt")
|
||||
@@ -662,8 +709,8 @@ test("diff function with various changes", async () => {
|
||||
|
||||
// Make various changes
|
||||
await $`rm ${tmp.path}/a.txt`.quiet()
|
||||
await Bun.write(`${tmp.path}/new.txt`, "new content")
|
||||
await Bun.write(`${tmp.path}/b.txt`, "modified content")
|
||||
await Filesystem.write(`${tmp.path}/new.txt`, "new content")
|
||||
await Filesystem.write(`${tmp.path}/b.txt`, "modified content")
|
||||
|
||||
const diff = await Snapshot.diff(before!)
|
||||
expect(diff).toContain("a.txt")
|
||||
@@ -683,16 +730,26 @@ test("restore function", async () => {
|
||||
|
||||
// Make changes
|
||||
await $`rm ${tmp.path}/a.txt`.quiet()
|
||||
await Bun.write(`${tmp.path}/new.txt`, "new content")
|
||||
await Bun.write(`${tmp.path}/b.txt`, "modified")
|
||||
await Filesystem.write(`${tmp.path}/new.txt`, "new content")
|
||||
await Filesystem.write(`${tmp.path}/b.txt`, "modified")
|
||||
|
||||
// Restore to original state
|
||||
await Snapshot.restore(before!)
|
||||
|
||||
expect(await Bun.file(`${tmp.path}/a.txt`).exists()).toBe(true)
|
||||
expect(await Bun.file(`${tmp.path}/a.txt`).text()).toBe(tmp.extra.aContent)
|
||||
expect(await Bun.file(`${tmp.path}/new.txt`).exists()).toBe(true) // New files should remain
|
||||
expect(await Bun.file(`${tmp.path}/b.txt`).text()).toBe(tmp.extra.bContent)
|
||||
expect(
|
||||
await fs
|
||||
.access(`${tmp.path}/a.txt`)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(true)
|
||||
expect(await fs.readFile(`${tmp.path}/a.txt`, "utf-8")).toBe(tmp.extra.aContent)
|
||||
expect(
|
||||
await fs
|
||||
.access(`${tmp.path}/new.txt`)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(true) // New files should remain
|
||||
expect(await fs.readFile(`${tmp.path}/b.txt`, "utf-8")).toBe(tmp.extra.bContent)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -710,14 +767,19 @@ test("revert should not delete files that existed but were deleted in snapshot",
|
||||
const snapshot2 = await Snapshot.track()
|
||||
expect(snapshot2).toBeTruthy()
|
||||
|
||||
await Bun.write(`${tmp.path}/a.txt`, "recreated content")
|
||||
await Filesystem.write(`${tmp.path}/a.txt`, "recreated content")
|
||||
|
||||
const patch = await Snapshot.patch(snapshot2!)
|
||||
expect(patch.files).toContain(`${tmp.path}/a.txt`)
|
||||
|
||||
await Snapshot.revert([patch])
|
||||
|
||||
expect(await Bun.file(`${tmp.path}/a.txt`).exists()).toBe(false)
|
||||
expect(
|
||||
await fs
|
||||
.access(`${tmp.path}/a.txt`)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -727,14 +789,14 @@ test("revert preserves file that existed in snapshot when deleted then recreated
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Bun.write(`${tmp.path}/existing.txt`, "original content")
|
||||
await Filesystem.write(`${tmp.path}/existing.txt`, "original content")
|
||||
|
||||
const snapshot = await Snapshot.track()
|
||||
expect(snapshot).toBeTruthy()
|
||||
|
||||
await $`rm ${tmp.path}/existing.txt`.quiet()
|
||||
await Bun.write(`${tmp.path}/existing.txt`, "recreated")
|
||||
await Bun.write(`${tmp.path}/newfile.txt`, "new")
|
||||
await Filesystem.write(`${tmp.path}/existing.txt`, "recreated")
|
||||
await Filesystem.write(`${tmp.path}/newfile.txt`, "new")
|
||||
|
||||
const patch = await Snapshot.patch(snapshot!)
|
||||
expect(patch.files).toContain(`${tmp.path}/existing.txt`)
|
||||
@@ -742,9 +804,19 @@ test("revert preserves file that existed in snapshot when deleted then recreated
|
||||
|
||||
await Snapshot.revert([patch])
|
||||
|
||||
expect(await Bun.file(`${tmp.path}/newfile.txt`).exists()).toBe(false)
|
||||
expect(await Bun.file(`${tmp.path}/existing.txt`).exists()).toBe(true)
|
||||
expect(await Bun.file(`${tmp.path}/existing.txt`).text()).toBe("original content")
|
||||
expect(
|
||||
await fs
|
||||
.access(`${tmp.path}/newfile.txt`)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(false)
|
||||
expect(
|
||||
await fs
|
||||
.access(`${tmp.path}/existing.txt`)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(true)
|
||||
expect(await fs.readFile(`${tmp.path}/existing.txt`, "utf-8")).toBe("original content")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -754,17 +826,17 @@ test("diffFull sets status based on git change type", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Bun.write(`${tmp.path}/grow.txt`, "one\n")
|
||||
await Bun.write(`${tmp.path}/trim.txt`, "line1\nline2\n")
|
||||
await Bun.write(`${tmp.path}/delete.txt`, "gone")
|
||||
await Filesystem.write(`${tmp.path}/grow.txt`, "one\n")
|
||||
await Filesystem.write(`${tmp.path}/trim.txt`, "line1\nline2\n")
|
||||
await Filesystem.write(`${tmp.path}/delete.txt`, "gone")
|
||||
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Bun.write(`${tmp.path}/grow.txt`, "one\ntwo\n")
|
||||
await Bun.write(`${tmp.path}/trim.txt`, "line1\n")
|
||||
await Filesystem.write(`${tmp.path}/grow.txt`, "one\ntwo\n")
|
||||
await Filesystem.write(`${tmp.path}/trim.txt`, "line1\n")
|
||||
await $`rm ${tmp.path}/delete.txt`.quiet()
|
||||
await Bun.write(`${tmp.path}/added.txt`, "new")
|
||||
await Filesystem.write(`${tmp.path}/added.txt`, "new")
|
||||
|
||||
const after = await Snapshot.track()
|
||||
expect(after).toBeTruthy()
|
||||
@@ -803,7 +875,7 @@ test("diffFull with new file additions", async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Bun.write(`${tmp.path}/new.txt`, "new content")
|
||||
await Filesystem.write(`${tmp.path}/new.txt`, "new content")
|
||||
|
||||
const after = await Snapshot.track()
|
||||
expect(after).toBeTruthy()
|
||||
@@ -829,7 +901,7 @@ test("diffFull with file modifications", async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Bun.write(`${tmp.path}/b.txt`, "modified content")
|
||||
await Filesystem.write(`${tmp.path}/b.txt`, "modified content")
|
||||
|
||||
const after = await Snapshot.track()
|
||||
expect(after).toBeTruthy()
|
||||
@@ -881,7 +953,7 @@ test("diffFull with multiple line additions", async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Bun.write(`${tmp.path}/multi.txt`, "line1\nline2\nline3")
|
||||
await Filesystem.write(`${tmp.path}/multi.txt`, "line1\nline2\nline3")
|
||||
|
||||
const after = await Snapshot.track()
|
||||
expect(after).toBeTruthy()
|
||||
@@ -907,7 +979,7 @@ test("diffFull with addition and deletion", async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Bun.write(`${tmp.path}/added.txt`, "added content")
|
||||
await Filesystem.write(`${tmp.path}/added.txt`, "added content")
|
||||
await $`rm ${tmp.path}/a.txt`.quiet()
|
||||
|
||||
const after = await Snapshot.track()
|
||||
@@ -941,8 +1013,8 @@ test("diffFull with multiple additions and deletions", async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Bun.write(`${tmp.path}/multi1.txt`, "line1\nline2\nline3")
|
||||
await Bun.write(`${tmp.path}/multi2.txt`, "single line")
|
||||
await Filesystem.write(`${tmp.path}/multi1.txt`, "line1\nline2\nline3")
|
||||
await Filesystem.write(`${tmp.path}/multi2.txt`, "single line")
|
||||
await $`rm ${tmp.path}/a.txt`.quiet()
|
||||
await $`rm ${tmp.path}/b.txt`.quiet()
|
||||
|
||||
@@ -1000,7 +1072,7 @@ test("diffFull with binary file changes", async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Bun.write(`${tmp.path}/binary.bin`, new Uint8Array([0x00, 0x01, 0x02, 0x03]))
|
||||
await Filesystem.write(`${tmp.path}/binary.bin`, new Uint8Array([0x00, 0x01, 0x02, 0x03]))
|
||||
|
||||
const after = await Snapshot.track()
|
||||
expect(after).toBeTruthy()
|
||||
@@ -1020,11 +1092,11 @@ test("diffFull with whitespace changes", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Bun.write(`${tmp.path}/whitespace.txt`, "line1\nline2")
|
||||
await Filesystem.write(`${tmp.path}/whitespace.txt`, "line1\nline2")
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Bun.write(`${tmp.path}/whitespace.txt`, "line1\n\nline2\n")
|
||||
await Filesystem.write(`${tmp.path}/whitespace.txt`, "line1\n\nline2\n")
|
||||
|
||||
const after = await Snapshot.track()
|
||||
expect(after).toBeTruthy()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { BashTool } from "../../src/tool/bash"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import type { PermissionNext } from "../../src/permission/next"
|
||||
import { Truncate } from "../../src/tool/truncation"
|
||||
@@ -388,7 +389,7 @@ describe("tool.bash truncation", () => {
|
||||
const filepath = (result.metadata as any).outputPath
|
||||
expect(filepath).toBeTruthy()
|
||||
|
||||
const saved = await Bun.file(filepath).text()
|
||||
const saved = await Filesystem.readText(filepath)
|
||||
const lines = saved.trim().split("\n")
|
||||
expect(lines.length).toBe(lineCount)
|
||||
expect(lines[0]).toBe("1")
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { ReadTool } from "../../src/tool/read"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { PermissionNext } from "../../src/permission/next"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
@@ -199,10 +200,10 @@ describe("tool.read truncation", () => {
|
||||
test("truncates large file by bytes and sets truncated metadata", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const base = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
|
||||
const base = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json"))
|
||||
const target = 60 * 1024
|
||||
const content = base.length >= target ? base : base.repeat(Math.ceil(target / base.length))
|
||||
await Bun.write(path.join(dir, "large.json"), content)
|
||||
await Filesystem.write(path.join(dir, "large.json"), content)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, test, expect, afterAll } from "bun:test"
|
||||
import { Truncate } from "../../src/tool/truncation"
|
||||
import { Identifier } from "../../src/id/id"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
@@ -9,7 +10,7 @@ const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
||||
describe("Truncate", () => {
|
||||
describe("output", () => {
|
||||
test("truncates large json file by bytes", async () => {
|
||||
const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
|
||||
const content = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json"))
|
||||
const result = await Truncate.output(content)
|
||||
|
||||
expect(result.truncated).toBe(true)
|
||||
@@ -69,7 +70,7 @@ describe("Truncate", () => {
|
||||
})
|
||||
|
||||
test("large single-line file truncates with byte message", async () => {
|
||||
const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
|
||||
const content = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json"))
|
||||
const result = await Truncate.output(content)
|
||||
|
||||
expect(result.truncated).toBe(true)
|
||||
@@ -88,7 +89,7 @@ describe("Truncate", () => {
|
||||
expect(result.outputPath).toBeDefined()
|
||||
expect(result.outputPath).toContain("tool_")
|
||||
|
||||
const written = await Bun.file(result.outputPath).text()
|
||||
const written = await Filesystem.readText(result.outputPath!)
|
||||
expect(written).toBe(lines)
|
||||
})
|
||||
|
||||
@@ -139,21 +140,21 @@ describe("Truncate", () => {
|
||||
const oldTimestamp = Date.now() - 10 * DAY_MS
|
||||
const oldId = Identifier.create("tool", false, oldTimestamp)
|
||||
oldFile = path.join(Truncate.DIR, oldId)
|
||||
await Bun.write(Bun.file(oldFile), "old content")
|
||||
await Filesystem.write(oldFile, "old content")
|
||||
|
||||
// Create a recent file (3 days ago)
|
||||
const recentTimestamp = Date.now() - 3 * DAY_MS
|
||||
const recentId = Identifier.create("tool", false, recentTimestamp)
|
||||
recentFile = path.join(Truncate.DIR, recentId)
|
||||
await Bun.write(Bun.file(recentFile), "recent content")
|
||||
await Filesystem.write(recentFile, "recent content")
|
||||
|
||||
await Truncate.cleanup()
|
||||
|
||||
// Old file should be deleted
|
||||
expect(await Bun.file(oldFile).exists()).toBe(false)
|
||||
expect(await Filesystem.exists(oldFile)).toBe(false)
|
||||
|
||||
// Recent file should still exist
|
||||
expect(await Bun.file(recentFile).exists()).toBe(true)
|
||||
expect(await Filesystem.exists(recentFile)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Glob } from "../../src/util/glob"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("Glob", () => {
|
||||
describe("scan()", () => {
|
||||
test("finds files matching pattern", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.writeFile(path.join(tmp.path, "a.txt"), "", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "b.txt"), "", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "c.md"), "", "utf-8")
|
||||
|
||||
const results = await Glob.scan("*.txt", { cwd: tmp.path })
|
||||
|
||||
expect(results.sort()).toEqual(["a.txt", "b.txt"])
|
||||
})
|
||||
|
||||
test("returns absolute paths when absolute option is true", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "", "utf-8")
|
||||
|
||||
const results = await Glob.scan("*.txt", { cwd: tmp.path, absolute: true })
|
||||
|
||||
expect(results[0]).toBe(path.join(tmp.path, "file.txt"))
|
||||
})
|
||||
|
||||
test("excludes directories by default", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.mkdir(path.join(tmp.path, "subdir"))
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "", "utf-8")
|
||||
|
||||
const results = await Glob.scan("*", { cwd: tmp.path })
|
||||
|
||||
expect(results).toEqual(["file.txt"])
|
||||
})
|
||||
|
||||
test("excludes directories when include is 'file'", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.mkdir(path.join(tmp.path, "subdir"))
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "", "utf-8")
|
||||
|
||||
const results = await Glob.scan("*", { cwd: tmp.path, include: "file" })
|
||||
|
||||
expect(results).toEqual(["file.txt"])
|
||||
})
|
||||
|
||||
test("includes directories when include is 'all'", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.mkdir(path.join(tmp.path, "subdir"))
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "", "utf-8")
|
||||
|
||||
const results = await Glob.scan("*", { cwd: tmp.path, include: "all" })
|
||||
|
||||
expect(results.sort()).toEqual(["file.txt", "subdir"])
|
||||
})
|
||||
|
||||
test("handles nested patterns", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.mkdir(path.join(tmp.path, "nested"), { recursive: true })
|
||||
await fs.writeFile(path.join(tmp.path, "nested", "deep.txt"), "", "utf-8")
|
||||
|
||||
const results = await Glob.scan("**/*.txt", { cwd: tmp.path })
|
||||
|
||||
expect(results).toEqual(["nested/deep.txt"])
|
||||
})
|
||||
|
||||
test("returns empty array for no matches", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
const results = await Glob.scan("*.nonexistent", { cwd: tmp.path })
|
||||
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
test("does not follow symlinks by default", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.mkdir(path.join(tmp.path, "realdir"))
|
||||
await fs.writeFile(path.join(tmp.path, "realdir", "file.txt"), "", "utf-8")
|
||||
await fs.symlink(path.join(tmp.path, "realdir"), path.join(tmp.path, "linkdir"))
|
||||
|
||||
const results = await Glob.scan("**/*.txt", { cwd: tmp.path })
|
||||
|
||||
expect(results).toEqual(["realdir/file.txt"])
|
||||
})
|
||||
|
||||
test("follows symlinks when symlink option is true", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.mkdir(path.join(tmp.path, "realdir"))
|
||||
await fs.writeFile(path.join(tmp.path, "realdir", "file.txt"), "", "utf-8")
|
||||
await fs.symlink(path.join(tmp.path, "realdir"), path.join(tmp.path, "linkdir"))
|
||||
|
||||
const results = await Glob.scan("**/*.txt", { cwd: tmp.path, symlink: true })
|
||||
|
||||
expect(results.sort()).toEqual(["linkdir/file.txt", "realdir/file.txt"])
|
||||
})
|
||||
|
||||
test("includes dotfiles when dot option is true", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.writeFile(path.join(tmp.path, ".hidden"), "", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "visible"), "", "utf-8")
|
||||
|
||||
const results = await Glob.scan("*", { cwd: tmp.path, dot: true })
|
||||
|
||||
expect(results.sort()).toEqual([".hidden", "visible"])
|
||||
})
|
||||
|
||||
test("excludes dotfiles when dot option is false", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.writeFile(path.join(tmp.path, ".hidden"), "", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "visible"), "", "utf-8")
|
||||
|
||||
const results = await Glob.scan("*", { cwd: tmp.path, dot: false })
|
||||
|
||||
expect(results).toEqual(["visible"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("scanSync()", () => {
|
||||
test("finds files matching pattern synchronously", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.writeFile(path.join(tmp.path, "a.txt"), "", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "b.txt"), "", "utf-8")
|
||||
|
||||
const results = Glob.scanSync("*.txt", { cwd: tmp.path })
|
||||
|
||||
expect(results.sort()).toEqual(["a.txt", "b.txt"])
|
||||
})
|
||||
|
||||
test("respects options", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.mkdir(path.join(tmp.path, "subdir"))
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "", "utf-8")
|
||||
|
||||
const results = Glob.scanSync("*", { cwd: tmp.path, include: "all" })
|
||||
|
||||
expect(results.sort()).toEqual(["file.txt", "subdir"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("match()", () => {
|
||||
test("matches simple patterns", () => {
|
||||
expect(Glob.match("*.txt", "file.txt")).toBe(true)
|
||||
expect(Glob.match("*.txt", "file.js")).toBe(false)
|
||||
})
|
||||
|
||||
test("matches directory patterns", () => {
|
||||
expect(Glob.match("**/*.js", "src/index.js")).toBe(true)
|
||||
expect(Glob.match("**/*.js", "src/index.ts")).toBe(false)
|
||||
})
|
||||
|
||||
test("matches dot files", () => {
|
||||
expect(Glob.match(".*", ".gitignore")).toBe(true)
|
||||
expect(Glob.match("**/*.md", ".github/README.md")).toBe(true)
|
||||
})
|
||||
|
||||
test("matches brace expansion", () => {
|
||||
expect(Glob.match("*.{js,ts}", "file.js")).toBe(true)
|
||||
expect(Glob.match("*.{js,ts}", "file.ts")).toBe(true)
|
||||
expect(Glob.match("*.{js,ts}", "file.py")).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.2.6",
|
||||
"version": "1.2.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user