Compare commits

..
Author SHA1 Message Date
Kit Langton ba44d37ac0 chore(lockfile): update bun lock for platform-node 2026-03-16 19:51:11 -04:00
Kit Langton e2d4e60eab refactor(skill): effectify discovery loading 2026-03-16 19:38:13 -04:00
Kit Langton e21d892cff refactor(skill): effectify discovery service 2026-03-16 18:57:00 -04:00
Kit Langton 527956e3ec refactor(skill): effectify SkillService as scoped service
Convert Skill namespace to SkillService class with Effect.fn methods.
Scanning runs eagerly in layer constructor — needs lazy init to avoid
breaking unrelated tests (same pattern as FileService).
2026-03-16 16:18:44 -04:00
96 changed files with 2974 additions and 2944 deletions
-1
View File
@@ -21,4 +21,3 @@ r44vc0rp
rekram1-node rekram1-node
-spider-yamet clawdbot/llm psychosis, spam pinging the team -spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr thdxr
-OpenCode2026
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-VF3rXpIz9XbTTfM8YB98DJJOs4Sotaq5cSwIBUfbNDA=", "x86_64-linux": "sha256-WJgo6UclmtQOEubnKMZybdIEhZ1uRTucF61yojjd+l0=",
"aarch64-linux": "sha256-cIE10+0xhb5u0TQedaDbEu6e40ypHnSBmh8unnhCDZE=", "aarch64-linux": "sha256-QfZ/g7EZFpe6ndR3dG8WvVfMj5Kyd/R/4kkTJfGJxL4=",
"aarch64-darwin": "sha256-d/l7g/4angRw/oxoSGpcYL0i9pNphgRChJwhva5Kypo=", "aarch64-darwin": "sha256-ezr/R70XJr9eN5l3mgb7HzLF6QsofNEKUOtuxbfli80=",
"x86_64-darwin": "sha256-WQyuUKMfHpO1rpWsjhCXuG99iX2jEdSe3AVltxvt+1Y=" "x86_64-darwin": "sha256-MbsBGS415uEU/n1RQ/5H5pqh+udLY3+oimJ+eS5uJVI="
} }
} }
-10
View File
@@ -174,8 +174,6 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
- In terminal tests, type through the browser. Do not write to the PTY through the SDK. - In terminal tests, type through the browser. Do not write to the PTY through the SDK.
- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`. - Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles. - These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
- After opening the terminal, use `waitTerminalFocusIdle(...)` before the next keyboard action when prompt focus or keyboard routing matters.
- This avoids racing terminal mount, focus handoff, and prompt readiness when the next step types or sends shortcuts.
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks. - Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
### Wait on state ### Wait on state
@@ -184,9 +182,6 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
- Avoid race-prone flows that assume work is finished after an action - Avoid race-prone flows that assume work is finished after an action
- Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers - Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers
- Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state - Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state
- Prefer semantic app state over transient DOM visibility when behavior depends on active selection, focus ownership, or async retry loops
- Do not treat a visible element as proof that the app will route the next action to it
- When fixing a flake, validate with `--repeat-each` and multiple workers when practical
### Add hooks ### Add hooks
@@ -194,16 +189,11 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
- Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts` - Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts`
- Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony - Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony
- When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI - When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI
- Add minimal test-only probes for semantic state like the active list item or selected command when DOM intermediates are unstable
- Prefer probing committed app state over asserting on transient highlight, visibility, or animation states
### Prefer helpers ### Prefer helpers
- Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise - Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise
- Use direct locators when the interaction is simple and a helper would not add clarity - Use direct locators when the interaction is simple and a helper would not add clarity
- Prefer helpers that both perform an action and verify the app consumed it
- Avoid composing helpers redundantly when one already includes the other or already waits for the resulting state
- If a helper already covers the required wait or verification, use it directly instead of layering extra clicks, keypresses, or assertions
## Writing New Tests ## Writing New Tests
-70
View File
@@ -16,7 +16,6 @@ import {
listItemSelector, listItemSelector,
listItemKeySelector, listItemKeySelector,
listItemKeyStartsWithSelector, listItemKeyStartsWithSelector,
promptSelector,
terminalSelector, terminalSelector,
workspaceItemSelector, workspaceItemSelector,
workspaceMenuTriggerSelector, workspaceMenuTriggerSelector,
@@ -62,15 +61,6 @@ async function terminalReady(page: Page, term?: Locator) {
}, id) }, id)
} }
async function terminalFocusIdle(page: Page, term?: Locator) {
const next = term ?? page.locator(terminalSelector).first()
const id = await terminalID(next)
return page.evaluate((id) => {
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
return (state?.focusing ?? 0) === 0
}, id)
}
async function terminalHas(page: Page, input: { term?: Locator; token: string }) { async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
const next = input.term ?? page.locator(terminalSelector).first() const next = input.term ?? page.locator(terminalSelector).first()
const id = await terminalID(next) const id = await terminalID(next)
@@ -83,29 +73,6 @@ async function terminalHas(page: Page, input: { term?: Locator; token: string })
) )
} }
async function promptSlashActive(page: Page, id: string) {
return page.evaluate((id) => {
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
if (state?.popover !== "slash") return false
if (!state.slash.ids.includes(id)) return false
return state.slash.active === id
}, id)
}
async function promptSlashSelects(page: Page) {
return page.evaluate(() => {
return (window as E2EWindow).__opencode_e2e?.prompt?.current?.selects ?? 0
})
}
async function promptSlashSelected(page: Page, input: { id: string; count: number }) {
return page.evaluate((input) => {
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
if (!state) return false
return state.selected === input.id && state.selects >= input.count
}, input)
}
export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) { export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
const term = input?.term ?? page.locator(terminalSelector).first() const term = input?.term ?? page.locator(terminalSelector).first()
const timeout = input?.timeout ?? 10_000 const timeout = input?.timeout ?? 10_000
@@ -114,43 +81,6 @@ export async function waitTerminalReady(page: Page, input?: { term?: Locator; ti
await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true) await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
} }
export async function waitTerminalFocusIdle(page: Page, input?: { term?: Locator; timeout?: number }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const timeout = input?.timeout ?? 10_000
await waitTerminalReady(page, { term, timeout })
await expect.poll(() => terminalFocusIdle(page, term), { timeout }).toBe(true)
}
export async function showPromptSlash(
page: Page,
input: { id: string; text: string; prompt?: Locator; timeout?: number },
) {
const prompt = input.prompt ?? page.locator(promptSelector)
const timeout = input.timeout ?? 10_000
await expect
.poll(
async () => {
await prompt.click().catch(() => false)
await prompt.fill(input.text).catch(() => false)
return promptSlashActive(page, input.id).catch(() => false)
},
{ timeout },
)
.toBe(true)
}
export async function runPromptSlash(
page: Page,
input: { id: string; text: string; prompt?: Locator; timeout?: number },
) {
const prompt = input.prompt ?? page.locator(promptSelector)
const timeout = input.timeout ?? 10_000
const count = await promptSlashSelects(page)
await showPromptSlash(page, input)
await prompt.press("Enter")
await expect.poll(() => promptSlashSelected(page, { id: input.id, count: count + 1 }), { timeout }).toBe(true)
}
export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) { export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
const term = input.term ?? page.locator(terminalSelector).first() const term = input.term ?? page.locator(terminalSelector).first()
const timeout = input.timeout ?? 10_000 const timeout = input.timeout ?? 10_000
-3
View File
@@ -98,9 +98,6 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin
model: { model: {
enabled: true, enabled: true,
}, },
prompt: {
enabled: true,
},
terminal: { terminal: {
enabled: true, enabled: true,
terminals: {}, terminals: {},
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { runPromptSlash, waitTerminalFocusIdle } from "../actions" import { waitTerminalReady } from "../actions"
import { promptSelector, terminalSelector } from "../selectors" import { promptSelector, terminalSelector } from "../selectors"
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => { test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
@@ -7,12 +7,29 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
const terminal = page.locator(terminalSelector) const terminal = page.locator(terminalSelector)
const slash = page.locator('[data-slash-id="terminal.toggle"]').first()
await expect(terminal).not.toBeVisible() await expect(terminal).not.toBeVisible()
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" }) await prompt.fill("/terminal")
await waitTerminalFocusIdle(page, { term: terminal }) await expect(slash).toBeVisible()
await page.keyboard.press("Enter")
await waitTerminalReady(page, { term: terminal })
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" }) // Terminal panel retries focus (immediate, RAF, 120ms, 240ms) after opening,
// which can steal focus from the prompt and prevent fill() from triggering
// the slash popover. Re-attempt click+fill until all retries are exhausted
// and the popover appears.
await expect
.poll(
async () => {
await prompt.click().catch(() => false)
await prompt.fill("/terminal").catch(() => false)
return slash.isVisible().catch(() => false)
},
{ timeout: 10_000 },
)
.toBe(true)
await page.keyboard.press("Enter")
await expect(terminal).not.toBeVisible() await expect(terminal).not.toBeVisible()
}) })
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { openSettings, closeDialog, waitTerminalFocusIdle, withSession } from "../actions" import { openSettings, closeDialog, waitTerminalReady, withSession } from "../actions"
import { keybindButtonSelector, terminalSelector } from "../selectors" import { keybindButtonSelector, terminalSelector } from "../selectors"
import { modKey } from "../utils" import { modKey } from "../utils"
@@ -302,7 +302,7 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
await expect(terminal).not.toBeVisible() await expect(terminal).not.toBeVisible()
await page.keyboard.press(`${modKey}+Y`) await page.keyboard.press(`${modKey}+Y`)
await waitTerminalFocusIdle(page, { term: terminal }) await waitTerminalReady(page, { term: terminal })
await page.keyboard.press(`${modKey}+Y`) await page.keyboard.press(`${modKey}+Y`)
await expect(terminal).not.toBeVisible() await expect(terminal).not.toBeVisible()
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { waitTerminalFocusIdle, waitTerminalReady } from "../actions" import { waitTerminalReady } from "../actions"
import { promptSelector, terminalSelector } from "../selectors" import { promptSelector, terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils" import { terminalToggleKey } from "../utils"
@@ -14,7 +14,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
await page.keyboard.press(terminalToggleKey) await page.keyboard.press(terminalToggleKey)
} }
await waitTerminalFocusIdle(page, { term: terminals.first() }) await waitTerminalReady(page, { term: terminals.first() })
await expect(terminals).toHaveCount(1) await expect(terminals).toHaveCount(1)
// Ghostty captures a lot of keybinds when focused; move focus back // Ghostty captures a lot of keybinds when focused; move focus back
+21 -35
View File
@@ -1,6 +1,6 @@
import { useFilteredList } from "@opencode-ai/ui/hooks" import { useFilteredList } from "@opencode-ai/ui/hooks"
import { useSpring } from "@opencode-ai/ui/motion-spring" import { useSpring } from "@opencode-ai/ui/motion-spring"
import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal } from "solid-js" import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
@@ -36,7 +36,6 @@ import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
import { promptEnabled, promptProbe } from "@/testing/prompt"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments } from "./prompt-input/attachments" import { createPromptAttachments } from "./prompt-input/attachments"
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files" import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
@@ -244,23 +243,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}, },
) )
const working = createMemo(() => status()?.type !== "idle") const working = createMemo(() => status()?.type !== "idle")
const tip = () => {
if (working()) {
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
)
}
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
)
}
const imageAttachments = createMemo(() => const imageAttachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
) )
@@ -622,7 +604,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const handleSlashSelect = (cmd: SlashCommand | undefined) => { const handleSlashSelect = (cmd: SlashCommand | undefined) => {
if (!cmd) return if (!cmd) return
promptProbe.select(cmd.id)
closePopover() closePopover()
if (cmd.type === "custom") { if (cmd.type === "custom") {
@@ -711,20 +692,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}) })
}) })
if (promptEnabled()) {
createEffect(() => {
promptProbe.set({
popover: store.popover,
slash: {
active: slashActive() ?? null,
ids: slashFlat().map((cmd) => cmd.id),
},
})
})
onCleanup(() => promptProbe.clear())
}
const selectPopoverActive = () => { const selectPopoverActive = () => {
if (store.popover === "at") { if (store.popover === "at") {
const items = atFlat() const items = atFlat()
@@ -1379,7 +1346,26 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/> />
<div class="flex items-center gap-1 pointer-events-auto"> <div class="flex items-center gap-1 pointer-events-auto">
<Tooltip placement="top" inactive={!prompt.dirty() && !working()} value={tip()}> <Tooltip
placement="top"
inactive={!prompt.dirty() && !working()}
value={
<Switch>
<Match when={working()}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
</Match>
<Match when={true}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
</Match>
</Switch>
}
>
<IconButton <IconButton
data-action="prompt-submit" data-action="prompt-submit"
type="submit" type="submit"
+6 -10
View File
@@ -1,10 +1,10 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { checksum } from "@opencode-ai/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store" import { createStore, type SetStoreFunction } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
import type { FileSelection } from "@/context/file" import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { checksum } from "@opencode-ai/util/encode"
interface PartBase { interface PartBase {
content: string content: string
@@ -250,7 +250,6 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
} }
} }
const owner = getOwner()
const load = (dir: string, id: string | undefined) => { const load = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}` const key = `${dir}:${id ?? WORKSPACE_KEY}`
const existing = cache.get(key) const existing = cache.get(key)
@@ -260,13 +259,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
return existing.value return existing.value
} }
const entry = createRoot( const entry = createRoot((dispose) => ({
(dispose) => ({
value: createPromptSession(dir, id), value: createPromptSession(dir, id),
dispose, dispose,
}), }))
owner,
)
cache.set(key, entry) cache.set(key, entry)
prune() prune()
@@ -18,10 +18,8 @@ import { terminalTabLabel } from "@/pages/session/terminal-label"
import { createSizing, focusTerminalById } from "@/pages/session/helpers" import { createSizing, focusTerminalById } from "@/pages/session/helpers"
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { terminalProbe } from "@/testing/terminal"
export function TerminalPanel() { export function TerminalPanel() {
const delays = [120, 240]
const layout = useLayout() const layout = useLayout()
const terminal = useTerminal() const terminal = useTerminal()
const language = useLanguage() const language = useLanguage()
@@ -81,20 +79,16 @@ export function TerminalPanel() {
) )
const focus = (id: string) => { const focus = (id: string) => {
const probe = terminalProbe(id)
probe.focus(delays.length + 1)
focusTerminalById(id) focusTerminalById(id)
const frame = requestAnimationFrame(() => { const frame = requestAnimationFrame(() => {
probe.step()
if (!opened()) return if (!opened()) return
if (terminal.active() !== id) return if (terminal.active() !== id) return
focusTerminalById(id) focusTerminalById(id)
}) })
const timers = delays.map((ms) => const timers = [120, 240].map((ms) =>
window.setTimeout(() => { window.setTimeout(() => {
probe.step()
if (!opened()) return if (!opened()) return
if (terminal.active() !== id) return if (terminal.active() !== id) return
focusTerminalById(id) focusTerminalById(id)
@@ -102,7 +96,6 @@ export function TerminalPanel() {
) )
return () => { return () => {
probe.focus(0)
cancelAnimationFrame(frame) cancelAnimationFrame(frame)
for (const timer of timers) clearTimeout(timer) for (const timer of timers) clearTimeout(timer)
} }
-56
View File
@@ -1,56 +0,0 @@
import type { E2EWindow } from "./terminal"
export type PromptProbeState = {
popover: "at" | "slash" | null
slash: {
active: string | null
ids: string[]
}
selected: string | null
selects: number
}
export const promptEnabled = () => {
if (typeof window === "undefined") return false
return (window as E2EWindow).__opencode_e2e?.prompt?.enabled === true
}
const root = () => {
if (!promptEnabled()) return
return (window as E2EWindow).__opencode_e2e?.prompt
}
export const promptProbe = {
set(input: Omit<PromptProbeState, "selected" | "selects">) {
const state = root()
if (!state) return
state.current = {
popover: input.popover,
slash: {
active: input.slash.active,
ids: [...input.slash.ids],
},
selected: state.current?.selected ?? null,
selects: state.current?.selects ?? 0,
}
},
select(id: string) {
const state = root()
if (!state) return
const prev = state.current
state.current = {
popover: prev?.popover ?? null,
slash: {
active: prev?.slash.active ?? null,
ids: [...(prev?.slash.ids ?? [])],
},
selected: id,
selects: (prev?.selects ?? 0) + 1,
}
},
clear() {
const state = root()
if (!state) return
state.current = undefined
},
}
-15
View File
@@ -7,7 +7,6 @@ export type TerminalProbeState = {
connects: number connects: number
rendered: string rendered: string
settled: number settled: number
focusing: number
} }
type TerminalProbeControl = { type TerminalProbeControl = {
@@ -20,10 +19,6 @@ export type E2EWindow = Window & {
enabled?: boolean enabled?: boolean
current?: ModelProbeState current?: ModelProbeState
} }
prompt?: {
enabled?: boolean
current?: import("./prompt").PromptProbeState
}
terminal?: { terminal?: {
enabled?: boolean enabled?: boolean
terminals?: Record<string, TerminalProbeState> terminals?: Record<string, TerminalProbeState>
@@ -37,7 +32,6 @@ const seed = (): TerminalProbeState => ({
connects: 0, connects: 0,
rendered: "", rendered: "",
settled: 0, settled: 0,
focusing: 0,
}) })
const root = () => { const root = () => {
@@ -94,15 +88,6 @@ export const terminalProbe = (id: string) => {
const prev = state[id] ?? seed() const prev = state[id] ?? seed()
state[id] = { ...prev, settled: prev.settled + 1 } state[id] = { ...prev, settled: prev.settled + 1 }
}, },
focus(count: number) {
set({ focusing: Math.max(0, count) })
},
step() {
const state = terms()
if (!state) return
const prev = state[id] ?? seed()
state[id] = { ...prev, focusing: Math.max(0, prev.focusing - 1) }
},
control(next: Partial<TerminalProbeControl>) { control(next: Partial<TerminalProbeControl>) {
const state = controls() const state = controls()
if (!state) return if (!state) return
-6
View File
@@ -11,14 +11,12 @@ const seed = async () => {
const { Instance } = await import("../src/project/instance") const { Instance } = await import("../src/project/instance")
const { InstanceBootstrap } = await import("../src/project/bootstrap") const { InstanceBootstrap } = await import("../src/project/bootstrap")
const { Config } = await import("../src/config/config") const { Config } = await import("../src/config/config")
const { disposeRuntime } = await import("../src/effect/runtime")
const { Session } = await import("../src/session") const { Session } = await import("../src/session")
const { MessageID, PartID } = await import("../src/session/schema") const { MessageID, PartID } = await import("../src/session/schema")
const { Project } = await import("../src/project/project") const { Project } = await import("../src/project/project")
const { ModelID, ProviderID } = await import("../src/provider/schema") const { ModelID, ProviderID } = await import("../src/provider/schema")
const { ToolRegistry } = await import("../src/tool/registry") const { ToolRegistry } = await import("../src/tool/registry")
try {
await Instance.provide({ await Instance.provide({
directory: dir, directory: dir,
init: InstanceBootstrap, init: InstanceBootstrap,
@@ -53,10 +51,6 @@ const seed = async () => {
await Project.update({ projectID: Instance.project.id, name: "E2E Project" }) await Project.update({ projectID: Instance.project.id, name: "E2E Project" })
}, },
}) })
} finally {
await Instance.disposeAll().catch(() => {})
await disposeRuntime().catch(() => {})
}
} }
await seed() await seed()
-355
View File
@@ -1,355 +0,0 @@
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo"
import {
type AccountError as SchemaError,
AccessToken as SchemaAccessToken,
Account as SchemaAccount,
AccountID as SchemaAccountID,
DeviceCode as SchemaDeviceCode,
RefreshToken as SchemaRefreshToken,
AccountServiceError as SchemaServiceError,
Login as SchemaLogin,
Org as SchemaOrg,
OrgID as SchemaOrgID,
PollDenied as SchemaPollDenied,
PollError as SchemaPollError,
PollExpired as SchemaPollExpired,
PollPending as SchemaPollPending,
type PollResult as SchemaPollResult,
PollSlow as SchemaPollSlow,
PollSuccess as SchemaPollSuccess,
UserCode as SchemaUserCode,
} from "./schema"
export namespace AccountEffect {
export type Error = SchemaError
const AccessToken = SchemaAccessToken
type AccessToken = SchemaAccessToken
const Account = SchemaAccount
type Account = SchemaAccount
const AccountID = SchemaAccountID
type AccountID = SchemaAccountID
const DeviceCode = SchemaDeviceCode
type DeviceCode = SchemaDeviceCode
const RefreshToken = SchemaRefreshToken
type RefreshToken = SchemaRefreshToken
const Login = SchemaLogin
type Login = SchemaLogin
const Org = SchemaOrg
type Org = SchemaOrg
const OrgID = SchemaOrgID
type OrgID = SchemaOrgID
const PollDenied = SchemaPollDenied
const PollError = SchemaPollError
const PollExpired = SchemaPollExpired
const PollPending = SchemaPollPending
const PollSlow = SchemaPollSlow
const PollSuccess = SchemaPollSuccess
const UserCode = SchemaUserCode
type PollResult = SchemaPollResult
export type AccountOrgs = {
account: Account
orgs: readonly Org[]
}
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
config: Schema.Record(Schema.String, Schema.Json),
}) {}
const DurationFromSeconds = Schema.Number.pipe(
Schema.decodeTo(Schema.Duration, {
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
}),
)
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
access_token: AccessToken,
refresh_token: RefreshToken,
expires_in: DurationFromSeconds,
}) {}
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
device_code: DeviceCode,
user_code: UserCode,
verification_uri_complete: Schema.String,
expires_in: DurationFromSeconds,
interval: DurationFromSeconds,
}) {}
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
access_token: AccessToken,
refresh_token: RefreshToken,
token_type: Schema.Literal("Bearer"),
expires_in: DurationFromSeconds,
}) {}
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
error: Schema.String,
error_description: Schema.String,
}) {
toPollResult(): PollResult {
if (this.error === "authorization_pending") return new PollPending()
if (this.error === "slow_down") return new PollSlow()
if (this.error === "expired_token") return new PollExpired()
if (this.error === "access_denied") return new PollDenied()
return new PollError({ cause: this.error })
}
}
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
class User extends Schema.Class<User>("User")({
id: AccountID,
email: Schema.String,
}) {}
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
grant_type: Schema.String,
device_code: DeviceCode,
client_id: Schema.String,
}) {}
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
grant_type: Schema.String,
refresh_token: RefreshToken,
client_id: Schema.String,
}) {}
const client_id = "opencode-cli"
const map =
(message = "Account service operation failed") =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, SchemaServiceError, R> =>
effect.pipe(
Effect.mapError((cause) =>
cause instanceof SchemaServiceError ? cause : new SchemaServiceError({ message, cause }),
),
)
export interface Interface {
readonly active: () => Effect.Effect<Option.Option<Account>, Error>
readonly list: () => Effect.Effect<Account[], Error>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], Error>
readonly remove: (accountID: AccountID) => Effect.Effect<void, Error>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, Error>
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], Error>
readonly config: (accountID: AccountID, orgID: OrgID) => Effect.Effect<Option.Option<Record<string, unknown>>, Error>
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, Error>
readonly login: (url: string) => Effect.Effect<Login, Error>
readonly poll: (input: Login) => Effect.Effect<PollResult, Error>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
Service,
Effect.gen(function* () {
const repo = yield* AccountRepo
const http = yield* HttpClient.HttpClient
const httpRead = withTransientReadRetry(http)
const httpOk = HttpClient.filterStatusOk(http)
const httpReadOk = HttpClient.filterStatusOk(httpRead)
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
httpRead.execute(request).pipe(map("HTTP request failed"))
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
httpReadOk.execute(request).pipe(map("HTTP request failed"))
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => httpOk.execute(req)),
map("HTTP request failed"),
)
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (row.token_expiry && row.token_expiry > now) return row.access_token
const response = yield* executeEffectOk(
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
new TokenRefreshRequest({
grant_type: "refresh_token",
refresh_token: row.refresh_token,
client_id,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
map("Failed to decode response"),
)
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
yield* repo.persistToken({
accountID: row.id,
accessToken: parsed.access_token,
refreshToken: parsed.refresh_token,
expiry,
})
return parsed.access_token
})
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
const maybe = yield* repo.getRow(accountID)
if (Option.isNone(maybe)) return Option.none()
const account = maybe.value
const accessToken = yield* resolveToken(account)
return Option.some({ account, accessToken })
})
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
map("Failed to decode response"),
)
})
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/user`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(map("Failed to decode response"))
})
const token = Effect.fn("Account.token")((accountID: AccountID) =>
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
)
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return []
return yield* fetchOrgs(resolved.value.account.url, resolved.value.accessToken)
})
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
const accounts = yield* repo.list()
const [errors, results] = yield* Effect.partition(
accounts,
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
{ concurrency: 3 },
)
for (const err of errors) {
yield* Effect.logWarning("failed to fetch orgs for account").pipe(Effect.annotateLogs({ error: String(err) }))
}
return results
})
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none()
const response = yield* executeRead(
HttpClientRequest.get(`${resolved.value.account.url}/api/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(resolved.value.accessToken),
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
),
)
if (response.status === 404) return Option.none()
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(map())
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(map("Failed to decode response"))
return Option.some(parsed.config)
})
const login = Effect.fn("Account.login")(function* (server: string) {
const response = yield* executeEffectOk(
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id })),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(map("Failed to decode response"))
return new Login({
code: parsed.device_code,
user: parsed.user_code,
url: `${server}${parsed.verification_uri_complete}`,
server,
expiry: parsed.expires_in,
interval: parsed.interval,
})
})
const poll = Effect.fn("Account.poll")(function* (input: Login) {
const response = yield* executeEffectOk(
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
new DeviceTokenRequest({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: input.code,
client_id,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(map("Failed to decode response"))
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
const [account, remoteOrgs] = yield* Effect.all(
[fetchUser(input.server, parsed.access_token), fetchOrgs(input.server, parsed.access_token)],
{ concurrency: 2 },
)
const first = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
const expiry = (yield* Clock.currentTimeMillis) + Duration.toMillis(parsed.expires_in)
yield* repo.persistAccount({
id: account.id,
email: account.email,
url: input.server,
accessToken: parsed.access_token,
refreshToken: parsed.refresh_token,
expiry,
orgID: first,
})
return new PollSuccess({ email: account.email })
})
return Service.of({
active: repo.active,
list: repo.list,
orgsByAccount,
remove: repo.remove,
use: repo.use,
orgs,
config,
token,
login,
poll,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
}
+11 -17
View File
@@ -1,33 +1,27 @@
import { Effect, Option } from "effect" import { Effect, Option } from "effect"
import { AccountEffect } from "./effect"
import { import {
AccessToken as Token,
Account as AccountSchema, Account as AccountSchema,
type AccountError, type AccountError,
AccountID as ID, type AccessToken,
OrgID as Org, AccountID,
} from "./schema" AccountService,
OrgID,
} from "./service"
export { AccessToken, AccountID, OrgID } from "./service"
import { runtime } from "@/effect/runtime" import { runtime } from "@/effect/runtime"
export { AccessToken, AccountID, OrgID } from "./schema" function runSync<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) {
return runtime.runSync(AccountService.use(f))
function runSync<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountEffect.Error>) {
return runtime.runSync(AccountEffect.Service.use(f))
} }
function runPromise<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) { function runPromise<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) {
return runtime.runPromise(AccountEffect.Service.use(f)) return runtime.runPromise(AccountService.use(f))
} }
export namespace Account { export namespace Account {
export const AccessToken = Token
export type AccessToken = Token
export const AccountID = ID
export type AccountID = ID
export const OrgID = Org
export type OrgID = Org
export const Account = AccountSchema export const Account = AccountSchema
export type Account = AccountSchema export type Account = AccountSchema
+359
View File
@@ -0,0 +1,359 @@
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo"
import {
type AccountError,
AccessToken,
Account,
AccountID,
DeviceCode,
RefreshToken,
AccountServiceError,
Login,
Org,
OrgID,
PollDenied,
PollError,
PollExpired,
PollPending,
type PollResult,
PollSlow,
PollSuccess,
UserCode,
} from "./schema"
export * from "./schema"
export type AccountOrgs = {
account: Account
orgs: readonly Org[]
}
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
config: Schema.Record(Schema.String, Schema.Json),
}) {}
const DurationFromSeconds = Schema.Number.pipe(
Schema.decodeTo(Schema.Duration, {
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
}),
)
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
access_token: AccessToken,
refresh_token: RefreshToken,
expires_in: DurationFromSeconds,
}) {}
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
device_code: DeviceCode,
user_code: UserCode,
verification_uri_complete: Schema.String,
expires_in: DurationFromSeconds,
interval: DurationFromSeconds,
}) {}
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
access_token: AccessToken,
refresh_token: RefreshToken,
token_type: Schema.Literal("Bearer"),
expires_in: DurationFromSeconds,
}) {}
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
error: Schema.String,
error_description: Schema.String,
}) {
toPollResult(): PollResult {
if (this.error === "authorization_pending") return new PollPending()
if (this.error === "slow_down") return new PollSlow()
if (this.error === "expired_token") return new PollExpired()
if (this.error === "access_denied") return new PollDenied()
return new PollError({ cause: this.error })
}
}
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
class User extends Schema.Class<User>("User")({
id: AccountID,
email: Schema.String,
}) {}
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
grant_type: Schema.String,
device_code: DeviceCode,
client_id: Schema.String,
}) {}
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
grant_type: Schema.String,
refresh_token: RefreshToken,
client_id: Schema.String,
}) {}
const clientId = "opencode-cli"
const mapAccountServiceError =
(message = "Account service operation failed") =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
effect.pipe(
Effect.mapError((cause) =>
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
),
)
export namespace AccountService {
export interface Service {
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
readonly list: () => Effect.Effect<Account[], AccountError>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
readonly config: (
accountID: AccountID,
orgID: OrgID,
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
readonly login: (url: string) => Effect.Effect<Login, AccountError>
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
}
}
export class AccountService extends ServiceMap.Service<AccountService, AccountService.Service>()("@opencode/Account") {
static readonly layer: Layer.Layer<AccountService, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
AccountService,
Effect.gen(function* () {
const repo = yield* AccountRepo
const http = yield* HttpClient.HttpClient
const httpRead = withTransientReadRetry(http)
const httpOk = HttpClient.filterStatusOk(http)
const httpReadOk = HttpClient.filterStatusOk(httpRead)
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => httpOk.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
// Returns a usable access token for a stored account row, refreshing and
// persisting it when the cached token has expired.
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (row.token_expiry && row.token_expiry > now) return row.access_token
const response = yield* executeEffectOk(
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
new TokenRefreshRequest({
grant_type: "refresh_token",
refresh_token: row.refresh_token,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
yield* repo.persistToken({
accountID: row.id,
accessToken: parsed.access_token,
refreshToken: parsed.refresh_token,
expiry,
})
return parsed.access_token
})
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) return Option.none()
const account = maybeAccount.value
const accessToken = yield* resolveToken(account)
return Option.some({ account, accessToken })
})
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/user`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const token = Effect.fn("AccountService.token")((accountID: AccountID) =>
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
)
const orgsByAccount = Effect.fn("AccountService.orgsByAccount")(function* () {
const accounts = yield* repo.list()
const [errors, results] = yield* Effect.partition(
accounts,
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
{ concurrency: 3 },
)
for (const error of errors) {
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
Effect.annotateLogs({ error: String(error) }),
)
}
return results
})
const orgs = Effect.fn("AccountService.orgs")(function* (accountID: AccountID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return []
const { account, accessToken } = resolved.value
return yield* fetchOrgs(account.url, accessToken)
})
const config = Effect.fn("AccountService.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none()
const { account, accessToken } = resolved.value
const response = yield* executeRead(
HttpClientRequest.get(`${account.url}/api/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
),
)
if (response.status === 404) return Option.none()
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
mapAccountServiceError("Failed to decode response"),
)
return Option.some(parsed.config)
})
const login = Effect.fn("AccountService.login")(function* (server: string) {
const response = yield* executeEffectOk(
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
return new Login({
code: parsed.device_code,
user: parsed.user_code,
url: `${server}${parsed.verification_uri_complete}`,
server,
expiry: parsed.expires_in,
interval: parsed.interval,
})
})
const poll = Effect.fn("AccountService.poll")(function* (input: Login) {
const response = yield* executeEffectOk(
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
new DeviceTokenRequest({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: input.code,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
const accessToken = parsed.access_token
const user = fetchUser(input.server, accessToken)
const orgs = fetchOrgs(input.server, accessToken)
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
// TODO: When there are multiple orgs, let the user choose
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
const now = yield* Clock.currentTimeMillis
const expiry = now + Duration.toMillis(parsed.expires_in)
const refreshToken = parsed.refresh_token
yield* repo.persistAccount({
id: account.id,
email: account.email,
url: input.server,
accessToken,
refreshToken,
expiry,
orgID: firstOrgID,
})
return new PollSuccess({ email: account.email })
})
return AccountService.of({
active: repo.active,
list: repo.list,
orgsByAccount,
remove: repo.remove,
use: repo.use,
orgs,
config,
token,
login,
poll,
})
}),
)
static readonly defaultLayer = AccountService.layer.pipe(
Layer.provide(AccountRepo.layer),
Layer.provide(FetchHttpClient.layer),
)
}
+2 -2
View File
@@ -5,7 +5,7 @@ import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai" import { generateObject, streamObject, type ModelMessage } from "ai"
import { SystemPrompt } from "../session/system" import { SystemPrompt } from "../session/system"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Truncate } from "../tool/truncate" import { Truncate } from "../tool/truncation"
import { Auth } from "../auth" import { Auth } from "../auth"
import { ProviderTransform } from "../provider/transform" import { ProviderTransform } from "../provider/transform"
@@ -14,7 +14,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt"
import PROMPT_EXPLORE from "./prompt/explore.txt" import PROMPT_EXPLORE from "./prompt/explore.txt"
import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_SUMMARY from "./prompt/summary.txt"
import PROMPT_TITLE from "./prompt/title.txt" import PROMPT_TITLE from "./prompt/title.txt"
import { PermissionNext } from "@/permission" import { PermissionNext } from "@/permission/next"
import { mergeDeep, pipe, sortBy, values } from "remeda" import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@/global" import { Global } from "@/global"
import path from "path" import path from "path"
-98
View File
@@ -1,98 +0,0 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
export namespace AuthEffect {
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: Schema.Number,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
}) {}
export class ApiAuth extends Schema.Class<ApiAuth>("ApiAuth")({
type: Schema.Literal("api"),
key: Schema.String,
}) {}
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
type: Schema.Literal("wellknown"),
key: Schema.String,
token: Schema.String,
}) {}
export const Info = Schema.Union([Oauth, ApiAuth, WellKnown])
export type Info = Schema.Schema.Type<typeof Info>
export class AuthServiceError extends Schema.TaggedErrorClass<AuthServiceError>()("AuthServiceError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export type Error = AuthServiceError
const file = path.join(Global.Path.data, "auth.json")
const fail = (message: string) => (cause: unknown) => new AuthServiceError({ message, cause })
export interface Interface {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthServiceError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthServiceError>
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthServiceError>
readonly remove: (key: string) => Effect.Effect<void, AuthServiceError>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("Auth.all")(() =>
Effect.tryPromise({
try: async () => {
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
},
catch: fail("Failed to read auth data"),
}),
)
const get = Effect.fn("Auth.get")(function* (providerID: string) {
return (yield* all())[providerID]
})
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
if (norm !== key) delete data[key]
delete data[norm + "/"]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
catch: fail("Failed to write auth data"),
})
})
const remove = Effect.fn("Auth.remove")(function* (key: string) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
delete data[key]
delete data[norm]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, data, 0o600),
catch: fail("Failed to write auth data"),
})
})
return Service.of({ get, all, set, remove })
}),
)
export const defaultLayer = layer
}
+4 -4
View File
@@ -1,12 +1,12 @@
import { Effect } from "effect" import { Effect } from "effect"
import z from "zod" import z from "zod"
import { runtime } from "@/effect/runtime" import { runtime } from "@/effect/runtime"
import * as S from "./effect" import * as S from "./service"
export const OAUTH_DUMMY_KEY = S.AuthEffect.OAUTH_DUMMY_KEY export { OAUTH_DUMMY_KEY } from "./service"
function runPromise<A>(f: (service: S.AuthEffect.Interface) => Effect.Effect<A, S.AuthEffect.Error>) { function runPromise<A>(f: (service: S.AuthService.Service) => Effect.Effect<A, S.AuthServiceError>) {
return runtime.runPromise(S.AuthEffect.Service.use(f)) return runtime.runPromise(S.AuthService.use(f))
} }
export namespace Auth { export namespace Auth {
+101
View File
@@ -0,0 +1,101 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: Schema.Number,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
}) {}
export class Api extends Schema.Class<Api>("ApiAuth")({
type: Schema.Literal("api"),
key: Schema.String,
}) {}
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
type: Schema.Literal("wellknown"),
key: Schema.String,
token: Schema.String,
}) {}
export const Info = Schema.Union([Oauth, Api, WellKnown])
export type Info = Schema.Schema.Type<typeof Info>
export class AuthServiceError extends Schema.TaggedErrorClass<AuthServiceError>()("AuthServiceError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
const file = path.join(Global.Path.data, "auth.json")
const fail = (message: string) => (cause: unknown) => new AuthServiceError({ message, cause })
export namespace AuthService {
export interface Service {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthServiceError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthServiceError>
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthServiceError>
readonly remove: (key: string) => Effect.Effect<void, AuthServiceError>
}
}
export class AuthService extends ServiceMap.Service<AuthService, AuthService.Service>()("@opencode/Auth") {
static readonly layer = Layer.effect(
AuthService,
Effect.gen(function* () {
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("AuthService.all")(() =>
Effect.tryPromise({
try: async () => {
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
},
catch: fail("Failed to read auth data"),
}),
)
const get = Effect.fn("AuthService.get")(function* (providerID: string) {
return (yield* all())[providerID]
})
const set = Effect.fn("AuthService.set")(function* (key: string, info: Info) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
if (norm !== key) delete data[key]
delete data[norm + "/"]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
catch: fail("Failed to write auth data"),
})
})
const remove = Effect.fn("AuthService.remove")(function* (key: string) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
delete data[key]
delete data[norm]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, data, 0o600),
catch: fail("Failed to write auth data"),
})
})
return AuthService.of({
get,
all,
set,
remove,
})
}),
)
static readonly defaultLayer = AuthService.layer
}
+6 -6
View File
@@ -2,8 +2,8 @@ import { cmd } from "./cmd"
import { Duration, Effect, Match, Option } from "effect" import { Duration, Effect, Match, Option } from "effect"
import { UI } from "../ui" import { UI } from "../ui"
import { runtime } from "@/effect/runtime" import { runtime } from "@/effect/runtime"
import { AccountEffect } from "@/account/effect" import { AccountID, AccountService, OrgID, PollExpired, type PollResult } from "@/account/service"
import { type AccountError, AccountID, OrgID, PollExpired, type PollResult } from "@/account/schema" import { type AccountError } from "@/account/schema"
import * as Prompt from "../effect/prompt" import * as Prompt from "../effect/prompt"
import open from "open" import open from "open"
@@ -17,7 +17,7 @@ const isActiveOrgChoice = (
) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID ) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
const loginEffect = Effect.fn("login")(function* (url: string) { const loginEffect = Effect.fn("login")(function* (url: string) {
const service = yield* AccountEffect.Service const service = yield* AccountService
yield* Prompt.intro("Log in") yield* Prompt.intro("Log in")
const login = yield* service.login(url) const login = yield* service.login(url)
@@ -58,7 +58,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) {
}) })
const logoutEffect = Effect.fn("logout")(function* (email?: string) { const logoutEffect = Effect.fn("logout")(function* (email?: string) {
const service = yield* AccountEffect.Service const service = yield* AccountService
const accounts = yield* service.list() const accounts = yield* service.list()
if (accounts.length === 0) return yield* println("Not logged in") if (accounts.length === 0) return yield* println("Not logged in")
@@ -98,7 +98,7 @@ interface OrgChoice {
} }
const switchEffect = Effect.fn("switch")(function* () { const switchEffect = Effect.fn("switch")(function* () {
const service = yield* AccountEffect.Service const service = yield* AccountService
const groups = yield* service.orgsByAccount() const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("Not logged in") if (groups.length === 0) return yield* println("Not logged in")
@@ -129,7 +129,7 @@ const switchEffect = Effect.fn("switch")(function* () {
}) })
const orgsEffect = Effect.fn("orgs")(function* () { const orgsEffect = Effect.fn("orgs")(function* () {
const service = yield* AccountEffect.Service const service = yield* AccountService
const groups = yield* service.orgsByAccount() const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("No accounts found") if (groups.length === 0) return yield* println("No accounts found")
+1 -1
View File
@@ -7,7 +7,7 @@ import type { MessageV2 } from "../../../session/message-v2"
import { MessageID, PartID } from "../../../session/schema" import { MessageID, PartID } from "../../../session/schema"
import { ToolRegistry } from "../../../tool/registry" import { ToolRegistry } from "../../../tool/registry"
import { Instance } from "../../../project/instance" import { Instance } from "../../../project/instance"
import { PermissionNext } from "../../../permission" import { PermissionNext } from "../../../permission/next"
import { iife } from "../../../util/iife" import { iife } from "../../../util/iife"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd" import { cmd } from "../cmd"
+1 -1
View File
@@ -11,7 +11,7 @@ import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart
import { Server } from "../../server/server" import { Server } from "../../server/server"
import { Provider } from "../../provider/provider" import { Provider } from "../../provider/provider"
import { Agent } from "../../agent/agent" import { Agent } from "../../agent/agent"
import { PermissionNext } from "../../permission" import { PermissionNext } from "../../permission/next"
import { Tool } from "../../tool/tool" import { Tool } from "../../tool/tool"
import { GlobTool } from "../../tool/glob" import { GlobTool } from "../../tool/glob"
import { GrepTool } from "../../tool/grep" import { GrepTool } from "../../tool/grep"
@@ -70,7 +70,7 @@ function init() {
useKeyboard((evt) => { useKeyboard((evt) => {
if (store.stack.length === 0) return if (store.stack.length === 0) return
if (evt.defaultPrevented) return if (evt.defaultPrevented) return
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()) return
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) { if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
const current = store.stack.at(-1)! const current = store.stack.at(-1)!
current.onClose?.() current.onClose?.()
@@ -17,21 +17,17 @@ export namespace Editor {
await Filesystem.write(filepath, opts.value) await Filesystem.write(filepath, opts.value)
opts.renderer.suspend() opts.renderer.suspend()
opts.renderer.currentRenderBuffer.clear() opts.renderer.currentRenderBuffer.clear()
try {
const parts = editor.split(" ") const parts = editor.split(" ")
const proc = Process.spawn([...parts, filepath], { const proc = Process.spawn([...parts, filepath], {
stdin: "inherit", stdin: "inherit",
stdout: "inherit", stdout: "inherit",
stderr: "inherit", stderr: "inherit",
shell: process.platform === "win32",
}) })
await proc.exited await proc.exited
const content = await Filesystem.readText(filepath) const content = await Filesystem.readText(filepath)
return content || undefined
} finally {
opts.renderer.currentRenderBuffer.clear() opts.renderer.currentRenderBuffer.clear()
opts.renderer.resume() opts.renderer.resume()
opts.renderer.requestRender() opts.renderer.requestRender()
} return content || undefined
} }
} }
+1 -6
View File
@@ -1052,12 +1052,7 @@ export namespace Config {
}) })
.optional(), .optional(),
plugin: z.string().array().optional(), plugin: z.string().array().optional(),
snapshot: z snapshot: z.boolean().optional(),
.boolean()
.optional()
.describe(
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
),
share: z share: z
.enum(["manual", "auto", "disabled"]) .enum(["manual", "auto", "disabled"])
.optional() .optional()
@@ -1,15 +1,13 @@
import { ServiceMap } from "effect"; import { ServiceMap } from "effect"
import type { Project } from "@/project/project"; import type { Project } from "@/project/project"
export declare namespace InstanceContext { export declare namespace InstanceContext {
export interface Shape { export interface Shape {
readonly directory: string; readonly directory: string
readonly worktree: string; readonly project: Project.Info
readonly project: Project.Info;
} }
} }
export class InstanceContext extends ServiceMap.Service< export class InstanceContext extends ServiceMap.Service<InstanceContext, InstanceContext.Shape>()(
InstanceContext, "opencode/InstanceContext",
InstanceContext.Shape ) {}
>()("opencode/InstanceContext") {}
+37 -43
View File
@@ -1,51 +1,43 @@
import { Effect, Layer, LayerMap, ServiceMap } from "effect" import { Effect, Layer, LayerMap, ServiceMap } from "effect"
import { File } from "@/file"
import { FileTime } from "@/file/time"
import { FileWatcher } from "@/file/watcher"
import { Format } from "@/format"
import { PermissionEffect } from "@/permission/effect"
import { Instance } from "@/project/instance"
import { Vcs } from "@/project/vcs"
import { ProviderAuthEffect } from "@/provider/auth-effect"
import { QuestionEffect } from "@/question/effect"
import { Skill } from "@/skill/skill"
import { Snapshot } from "@/snapshot"
import { InstanceContext } from "./instance-context"
import { registerDisposer } from "./instance-registry" import { registerDisposer } from "./instance-registry"
import { InstanceContext } from "./instance-context"
import { ProviderAuthService } from "@/provider/auth-service"
import { QuestionService } from "@/question/service"
import { PermissionService } from "@/permission/service"
import { FileWatcherService } from "@/file/watcher"
import { VcsService } from "@/project/vcs"
import { FileTimeService } from "@/file/time"
import { FormatService } from "@/format"
import { FileService } from "@/file"
import { SkillService } from "@/skill/skill"
import { Instance } from "@/project/instance"
export { InstanceContext } from "./instance-context" export { InstanceContext } from "./instance-context"
export type InstanceServices = export type InstanceServices =
| QuestionEffect.Service | QuestionService
| PermissionEffect.Service | PermissionService
| ProviderAuthEffect.Service | ProviderAuthService
| FileWatcher.Service | FileWatcherService
| Vcs.Service | VcsService
| FileTime.Service | FileTimeService
| Format.Service | FormatService
| File.Service | FileService
| Skill.Service | SkillService
| Snapshot.Service
// NOTE: LayerMap only passes the key (directory string) to lookup, but we need function lookup(directory: string) {
// the full instance context (directory, worktree, project). We read from the const project = Instance.project
// legacy Instance ALS here, which is safe because lookup is only triggered via const ctx = Layer.sync(InstanceContext, () => InstanceContext.of({ directory, project }))
// runPromiseInstance -> Instances.get, which always runs inside Instance.provide.
// This should go away once the old Instance type is removed and lookup can load
// the full context directly.
function lookup(_key: string) {
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
return Layer.mergeAll( return Layer.mergeAll(
Layer.fresh(QuestionEffect.layer), Layer.fresh(QuestionService.layer),
Layer.fresh(PermissionEffect.layer), Layer.fresh(PermissionService.layer),
Layer.fresh(ProviderAuthEffect.defaultLayer), Layer.fresh(ProviderAuthService.layer),
Layer.fresh(FileWatcher.layer).pipe(Layer.orDie), Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie),
Layer.fresh(Vcs.layer), Layer.fresh(VcsService.layer),
Layer.fresh(FileTime.layer).pipe(Layer.orDie), Layer.fresh(FileTimeService.layer).pipe(Layer.orDie),
Layer.fresh(Format.layer), Layer.fresh(FormatService.layer),
Layer.fresh(File.layer), Layer.fresh(FileService.layer),
Layer.fresh(Skill.defaultLayer), Layer.fresh(SkillService.layer),
Layer.fresh(Snapshot.defaultLayer),
).pipe(Layer.provide(ctx)) ).pipe(Layer.provide(ctx))
} }
@@ -55,9 +47,7 @@ export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<s
static readonly layer = Layer.effect( static readonly layer = Layer.effect(
Instances, Instances,
Effect.gen(function* () { Effect.gen(function* () {
const layerMap = yield* LayerMap.make(lookup, { const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
idleTimeToLive: Infinity,
})
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory))) const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
yield* Effect.addFinalizer(() => Effect.sync(unregister)) yield* Effect.addFinalizer(() => Effect.sync(unregister))
return Instances.of(layerMap) return Instances.of(layerMap)
@@ -67,4 +57,8 @@ export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<s
static get(directory: string): Layer.Layer<InstanceServices, never, Instances> { static get(directory: string): Layer.Layer<InstanceServices, never, Instances> {
return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory)))) return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory))))
} }
static invalidate(directory: string): Effect.Effect<void, never, Instances> {
return Instances.use((map) => map.invalidate(directory))
}
} }
+3 -12
View File
@@ -1,23 +1,14 @@
import { Effect, Layer, ManagedRuntime } from "effect" import { Effect, Layer, ManagedRuntime } from "effect"
import { AccountEffect } from "@/account/effect" import { AccountService } from "@/account/service"
import { AuthEffect } from "@/auth/effect" import { AuthService } from "@/auth/service"
import { Instances } from "@/effect/instances" import { Instances } from "@/effect/instances"
import type { InstanceServices } from "@/effect/instances" import type { InstanceServices } from "@/effect/instances"
import { TruncateEffect } from "@/tool/truncate-effect"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
export const runtime = ManagedRuntime.make( export const runtime = ManagedRuntime.make(
Layer.mergeAll( Layer.mergeAll(AccountService.defaultLayer, Instances.layer).pipe(Layer.provideMerge(AuthService.defaultLayer)),
AccountEffect.defaultLayer, //
TruncateEffect.defaultLayer,
Instances.layer,
).pipe(Layer.provideMerge(AuthEffect.defaultLayer)),
) )
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) { export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory)))) return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
} }
export function disposeRuntime() {
return runtime.dispose()
}
+245 -213
View File
@@ -1,111 +1,24 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceContext } from "@/effect/instance-context"
import { runPromiseInstance } from "@/effect/runtime"
import { git } from "@/util/git"
import { Effect, Layer, ServiceMap } from "effect"
import { formatPatch, structuredPatch } from "diff"
import fs from "fs"
import fuzzysort from "fuzzysort"
import ignore from "ignore"
import path from "path"
import z from "zod" import z from "zod"
import { Global } from "../global" import { formatPatch, structuredPatch } from "diff"
import { Instance } from "../project/instance" import path from "path"
import { Filesystem } from "../util/filesystem" import fs from "fs"
import ignore from "ignore"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Protected } from "./protected" import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Ripgrep } from "./ripgrep" import { Ripgrep } from "./ripgrep"
import fuzzysort from "fuzzysort"
import { Global } from "../global"
import { git } from "@/util/git"
import { Protected } from "./protected"
import { InstanceContext } from "@/effect/instance-context"
import { Effect, Layer, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
export namespace File { const log = Log.create({ service: "file" })
export const Info = z
.object({
path: z.string(),
added: z.number().int(),
removed: z.number().int(),
status: z.enum(["added", "deleted", "modified"]),
})
.meta({
ref: "File",
})
export type Info = z.infer<typeof Info> const binaryExtensions = new Set([
export const Node = z
.object({
name: z.string(),
path: z.string(),
absolute: z.string(),
type: z.enum(["file", "directory"]),
ignored: z.boolean(),
})
.meta({
ref: "FileNode",
})
export type Node = z.infer<typeof Node>
export const Content = z
.object({
type: z.enum(["text", "binary"]),
content: z.string(),
diff: z.string().optional(),
patch: z
.object({
oldFileName: z.string(),
newFileName: z.string(),
oldHeader: z.string().optional(),
newHeader: z.string().optional(),
hunks: z.array(
z.object({
oldStart: z.number(),
oldLines: z.number(),
newStart: z.number(),
newLines: z.number(),
lines: z.array(z.string()),
}),
),
index: z.string().optional(),
})
.optional(),
encoding: z.literal("base64").optional(),
mimeType: z.string().optional(),
})
.meta({
ref: "FileContent",
})
export type Content = z.infer<typeof Content>
export const Event = {
Edited: BusEvent.define(
"file.edited",
z.object({
file: z.string(),
}),
),
}
export function init() {
return runPromiseInstance(Service.use((svc) => svc.init()))
}
export async function status() {
return runPromiseInstance(Service.use((svc) => svc.status()))
}
export async function read(file: string): Promise<Content> {
return runPromiseInstance(Service.use((svc) => svc.read(file)))
}
export async function list(dir?: string) {
return runPromiseInstance(Service.use((svc) => svc.list(dir)))
}
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
return runPromiseInstance(Service.use((svc) => svc.search(input)))
}
const log = Log.create({ service: "file" })
const binary = new Set([
"exe", "exe",
"dll", "dll",
"pdb", "pdb",
@@ -205,9 +118,9 @@ export namespace File {
"bash", "bash",
"zsh", "zsh",
"fish", "fish",
]) ])
const image = new Set([ const imageExtensions = new Set([
"png", "png",
"jpg", "jpg",
"jpeg", "jpeg",
@@ -233,9 +146,9 @@ export namespace File {
"raf", "raf",
"pef", "pef",
"x3f", "x3f",
]) ])
const text = new Set([ const textExtensions = new Set([
"ts", "ts",
"tsx", "tsx",
"mts", "mts",
@@ -277,9 +190,9 @@ export namespace File {
"cfg", "cfg",
"conf", "conf",
"env", "env",
]) ])
const textName = new Set([ const textNames = new Set([
"dockerfile", "dockerfile",
"makefile", "makefile",
".gitignore", ".gitignore",
@@ -289,9 +202,26 @@ export namespace File {
".nvmrc", ".nvmrc",
".prettierrc", ".prettierrc",
".eslintrc", ".eslintrc",
]) ])
const mime: Record<string, string> = { 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> = {
png: "image/png", png: "image/png",
jpg: "image/jpeg", jpg: "image/jpeg",
jpeg: "image/jpeg", jpeg: "image/jpeg",
@@ -309,45 +239,125 @@ export namespace File {
heic: "image/heic", heic: "image/heic",
heif: "image/heif", heif: "image/heif",
} }
return mimeTypes[ext] || "image/" + ext
}
type Entry = { files: string[]; dirs: string[] } function isBinaryByExtension(filepath: string): boolean {
const ext = path.extname(filepath).toLowerCase().slice(1)
return binaryExtensions.has(ext)
}
const ext = (file: string) => path.extname(file).toLowerCase().slice(1) function isImage(mimeType: string): boolean {
const name = (file: string) => path.basename(file).toLowerCase() return mimeType.startsWith("image/")
const isImageByExtension = (file: string) => image.has(ext(file)) }
const isTextByExtension = (file: string) => text.has(ext(file))
const isTextByName = (file: string) => textName.has(name(file))
const isBinaryByExtension = (file: string) => binary.has(ext(file))
const isImage = (mimeType: string) => mimeType.startsWith("image/")
const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
function shouldEncode(mimeType: string) { function shouldEncode(mimeType: string): boolean {
const type = mimeType.toLowerCase() const type = mimeType.toLowerCase()
log.info("shouldEncode", { type }) log.info("shouldEncode", { type })
if (!type) return false if (!type) return false
if (type.startsWith("text/")) return false if (type.startsWith("text/")) return false
if (type.includes("charset=")) return false if (type.includes("charset=")) return false
const top = type.split("/", 2)[0]
return ["image", "audio", "video", "font", "model", "multipart"].includes(top) const parts = type.split("/", 2)
const top = parts[0]
const tops = ["image", "audio", "video", "font", "model", "multipart"]
if (tops.includes(top)) return true
return false
}
export namespace File {
export const Info = z
.object({
path: z.string(),
added: z.number().int(),
removed: z.number().int(),
status: z.enum(["added", "deleted", "modified"]),
})
.meta({
ref: "File",
})
export type Info = z.infer<typeof Info>
export const Node = z
.object({
name: z.string(),
path: z.string(),
absolute: z.string(),
type: z.enum(["file", "directory"]),
ignored: z.boolean(),
})
.meta({
ref: "FileNode",
})
export type Node = z.infer<typeof Node>
export const Content = z
.object({
type: z.enum(["text", "binary"]),
content: z.string(),
diff: z.string().optional(),
patch: z
.object({
oldFileName: z.string(),
newFileName: z.string(),
oldHeader: z.string().optional(),
newHeader: z.string().optional(),
hunks: z.array(
z.object({
oldStart: z.number(),
oldLines: z.number(),
newStart: z.number(),
newLines: z.number(),
lines: z.array(z.string()),
}),
),
index: z.string().optional(),
})
.optional(),
encoding: z.literal("base64").optional(),
mimeType: z.string().optional(),
})
.meta({
ref: "FileContent",
})
export type Content = z.infer<typeof Content>
export const Event = {
Edited: BusEvent.define(
"file.edited",
z.object({
file: z.string(),
}),
),
} }
const hidden = (item: string) => { export function init() {
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "") return runPromiseInstance(FileService.use((s) => s.init()))
return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
} }
const sortHiddenLast = (items: string[], prefer: boolean) => { export async function status() {
if (prefer) return items return runPromiseInstance(FileService.use((s) => s.status()))
const visible: string[] = []
const hiddenItems: string[] = []
for (const item of items) {
if (hidden(item)) hiddenItems.push(item)
else visible.push(item)
}
return [...visible, ...hiddenItems]
} }
export interface Interface { export async function read(file: string): Promise<Content> {
return runPromiseInstance(FileService.use((s) => s.read(file)))
}
export async function list(dir?: string) {
return runPromiseInstance(FileService.use((s) => s.list(dir)))
}
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
return runPromiseInstance(FileService.use((s) => s.search(input)))
}
}
export namespace FileService {
export interface Service {
readonly init: () => Effect.Effect<void> readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<File.Info[]> readonly status: () => Effect.Effect<File.Info[]>
readonly read: (file: string) => Effect.Effect<File.Content> readonly read: (file: string) => Effect.Effect<File.Content>
@@ -359,29 +369,36 @@ export namespace File {
type?: "file" | "directory" type?: "file" | "directory"
}) => Effect.Effect<string[]> }) => Effect.Effect<string[]>
} }
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {} export class FileService extends ServiceMap.Service<FileService, FileService.Service>()("@opencode/File") {
static readonly layer = Layer.effect(
export const layer = Layer.effect( FileService,
Service,
Effect.gen(function* () { Effect.gen(function* () {
const instance = yield* InstanceContext const instance = yield* InstanceContext
// File cache state
type Entry = { files: string[]; dirs: string[] }
let cache: Entry = { files: [], dirs: [] } let cache: Entry = { files: [], dirs: [] }
let task: Promise<void> | undefined let task: Promise<void> | undefined
const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global" const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
function kick() { function kick() {
if (task) return task if (task) return task
task = (async () => { task = (async () => {
// Disable scanning if in root of file system
if (instance.directory === path.parse(instance.directory).root) return if (instance.directory === path.parse(instance.directory).root) return
const next: Entry = { files: [], dirs: [] } const next: Entry = { files: [], dirs: [] }
try { try {
if (isGlobalHome) { if (isGlobalHome) {
const dirs = new Set<string>() const dirs = new Set<string>()
const protectedNames = Protected.names() const protectedNames = Protected.names()
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name) const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name) const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
const top = await fs.promises const top = await fs.promises
.readdir(instance.directory, { withFileTypes: true }) .readdir(instance.directory, { withFileTypes: true })
.catch(() => [] as fs.Dirent[]) .catch(() => [] as fs.Dirent[])
@@ -392,7 +409,9 @@ export namespace File {
dirs.add(entry.name + "/") dirs.add(entry.name + "/")
const base = path.join(instance.directory, entry.name) const base = path.join(instance.directory, entry.name)
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[]) const children = await fs.promises
.readdir(base, { withFileTypes: true })
.catch(() => [] as fs.Dirent[])
for (const child of children) { for (const child of children) {
if (!child.isDirectory()) continue if (!child.isDirectory()) continue
if (shouldIgnoreNested(child.name)) continue if (shouldIgnoreNested(child.name)) continue
@@ -402,7 +421,7 @@ export namespace File {
next.dirs = Array.from(dirs).toSorted() next.dirs = Array.from(dirs).toSorted()
} else { } else {
const seen = new Set<string>() const set = new Set<string>()
for await (const file of Ripgrep.files({ cwd: instance.directory })) { for await (const file of Ripgrep.files({ cwd: instance.directory })) {
next.files.push(file) next.files.push(file)
let current = file let current = file
@@ -411,8 +430,8 @@ export namespace File {
if (dir === ".") break if (dir === ".") break
if (dir === current) break if (dir === current) break
current = dir current = dir
if (seen.has(dir)) continue if (set.has(dir)) continue
seen.add(dir) set.add(dir)
next.dirs.push(dir + "/") next.dirs.push(dir + "/")
} }
} }
@@ -430,11 +449,11 @@ export namespace File {
return cache return cache
} }
const init = Effect.fn("File.init")(function* () { const init = Effect.fn("FileService.init")(function* () {
yield* Effect.promise(() => kick()) void kick()
}) })
const status = Effect.fn("File.status")(function* () { const status = Effect.fn("FileService.status")(function* () {
if (instance.project.vcs !== "git") return [] if (instance.project.vcs !== "git") return []
return yield* Effect.promise(async () => { return yield* Effect.promise(async () => {
@@ -444,13 +463,14 @@ export namespace File {
}) })
).text() ).text()
const changed: File.Info[] = [] const changedFiles: File.Info[] = []
if (diffOutput.trim()) { if (diffOutput.trim()) {
for (const line of diffOutput.trim().split("\n")) { const lines = diffOutput.trim().split("\n")
const [added, removed, file] = line.split("\t") for (const line of lines) {
changed.push({ const [added, removed, filepath] = line.split("\t")
path: file, changedFiles.push({
path: filepath,
added: added === "-" ? 0 : parseInt(added, 10), added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10), removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified", status: "modified",
@@ -476,12 +496,14 @@ export namespace File {
).text() ).text()
if (untrackedOutput.trim()) { if (untrackedOutput.trim()) {
for (const file of untrackedOutput.trim().split("\n")) { const untrackedFiles = untrackedOutput.trim().split("\n")
for (const filepath of untrackedFiles) {
try { try {
const content = await Filesystem.readText(path.join(instance.directory, file)) const content = await Filesystem.readText(path.join(instance.directory, filepath))
changed.push({ const lines = content.split("\n").length
path: file, changedFiles.push({
added: content.split("\n").length, path: filepath,
added: lines,
removed: 0, removed: 0,
status: "added", status: "added",
}) })
@@ -491,6 +513,7 @@ export namespace File {
} }
} }
// Get deleted files
const deletedOutput = ( const deletedOutput = (
await git( await git(
[ [
@@ -510,51 +533,50 @@ export namespace File {
).text() ).text()
if (deletedOutput.trim()) { if (deletedOutput.trim()) {
for (const file of deletedOutput.trim().split("\n")) { const deletedFiles = deletedOutput.trim().split("\n")
changed.push({ for (const filepath of deletedFiles) {
path: file, changedFiles.push({
path: filepath,
added: 0, added: 0,
removed: 0, removed: 0, // Could get original line count but would require another git command
status: "deleted", status: "deleted",
}) })
} }
} }
return changed.map((item) => { return changedFiles.map((x) => {
const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path) const full = path.isAbsolute(x.path) ? x.path : path.join(instance.directory, x.path)
return { return {
...item, ...x,
path: path.relative(instance.directory, full), path: path.relative(instance.directory, full),
} }
}) })
}) })
}) })
const read = Effect.fn("File.read")(function* (file: string) { const read = Effect.fn("FileService.read")(function* (file: string) {
return yield* Effect.promise(async (): Promise<File.Content> => { return yield* Effect.promise(async (): Promise<File.Content> => {
using _ = log.time("read", { file }) using _ = log.time("read", { file })
const full = path.join(instance.directory, file) const full = path.join(instance.directory, file)
if (!Instance.containsPath(full)) { if (!Instance.containsPath(full)) {
throw new Error("Access denied: path escapes project directory") throw new Error(`Access denied: path escapes project directory`)
} }
// Fast path: check extension before any filesystem operations
if (isImageByExtension(file)) { if (isImageByExtension(file)) {
if (await Filesystem.exists(full)) { if (await Filesystem.exists(full)) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([])) const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
return { const content = buffer.toString("base64")
type: "text", const mimeType = getImageMimeType(file)
content: buffer.toString("base64"), return { type: "text", content, mimeType, encoding: "base64" }
mimeType: getImageMimeType(file),
encoding: "base64",
}
} }
return { type: "text", content: "" } return { type: "text", content: "" }
} }
const knownText = isTextByExtension(file) || isTextByName(file) const text = isTextByExtension(file) || isTextByName(file)
if (isBinaryByExtension(file) && !knownText) { if (isBinaryByExtension(file) && !text) {
return { type: "binary", content: "" } return { type: "binary", content: "" }
} }
@@ -563,7 +585,7 @@ export namespace File {
} }
const mimeType = Filesystem.mimeType(full) const mimeType = Filesystem.mimeType(full)
const encode = knownText ? false : shouldEncode(mimeType) const encode = text ? false : shouldEncode(mimeType)
if (encode && !isImage(mimeType)) { if (encode && !isImage(mimeType)) {
return { type: "binary", content: "", mimeType } return { type: "binary", content: "", mimeType }
@@ -571,12 +593,8 @@ export namespace File {
if (encode) { if (encode) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([])) const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
return { const content = buffer.toString("base64")
type: "text", return { type: "text", content, mimeType, encoding: "base64" }
content: buffer.toString("base64"),
mimeType,
encoding: "base64",
}
} }
const content = (await Filesystem.readText(full).catch(() => "")).trim() const content = (await Filesystem.readText(full).catch(() => "")).trim()
@@ -587,9 +605,7 @@ export namespace File {
).text() ).text()
if (!diff.trim()) { if (!diff.trim()) {
diff = ( diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: instance.directory })
cwd: instance.directory,
})
).text() ).text()
} }
if (diff.trim()) { if (diff.trim()) {
@@ -598,64 +614,64 @@ export namespace File {
context: Infinity, context: Infinity,
ignoreWhitespace: true, ignoreWhitespace: true,
}) })
return { const diff = formatPatch(patch)
type: "text", return { type: "text", content, patch, diff }
content,
patch,
diff: formatPatch(patch),
} }
} }
}
return { type: "text", content } return { type: "text", content }
}) })
}) })
const list = Effect.fn("File.list")(function* (dir?: string) { const list = Effect.fn("FileService.list")(function* (dir?: string) {
return yield* Effect.promise(async () => { return yield* Effect.promise(async () => {
const exclude = [".git", ".DS_Store"] const exclude = [".git", ".DS_Store"]
let ignored = (_: string) => false let ignored = (_: string) => false
if (instance.project.vcs === "git") { if (instance.project.vcs === "git") {
const ig = ignore() const ig = ignore()
const gitignore = path.join(instance.project.worktree, ".gitignore") const gitignorePath = path.join(instance.project.worktree, ".gitignore")
if (await Filesystem.exists(gitignore)) { if (await Filesystem.exists(gitignorePath)) {
ig.add(await Filesystem.readText(gitignore)) ig.add(await Filesystem.readText(gitignorePath))
} }
const ignoreFile = path.join(instance.project.worktree, ".ignore") const ignorePath = path.join(instance.project.worktree, ".ignore")
if (await Filesystem.exists(ignoreFile)) { if (await Filesystem.exists(ignorePath)) {
ig.add(await Filesystem.readText(ignoreFile)) ig.add(await Filesystem.readText(ignorePath))
} }
ignored = ig.ignores.bind(ig) ignored = ig.ignores.bind(ig)
} }
const resolved = dir ? path.join(instance.directory, dir) : instance.directory const resolved = dir ? path.join(instance.directory, dir) : instance.directory
if (!Instance.containsPath(resolved)) { if (!Instance.containsPath(resolved)) {
throw new Error("Access denied: path escapes project directory") throw new Error(`Access denied: path escapes project directory`)
} }
const nodes: File.Node[] = [] const nodes: File.Node[] = []
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) { for (const entry of await fs.promises
.readdir(resolved, {
withFileTypes: true,
})
.catch(() => [])) {
if (exclude.includes(entry.name)) continue if (exclude.includes(entry.name)) continue
const absolute = path.join(resolved, entry.name) const fullPath = path.join(resolved, entry.name)
const file = path.relative(instance.directory, absolute) const relativePath = path.relative(instance.directory, fullPath)
const type = entry.isDirectory() ? "directory" : "file" const type = entry.isDirectory() ? "directory" : "file"
nodes.push({ nodes.push({
name: entry.name, name: entry.name,
path: file, path: relativePath,
absolute, absolute: fullPath,
type, type,
ignored: ignored(type === "directory" ? file + "/" : file), ignored: ignored(type === "directory" ? relativePath + "/" : relativePath),
}) })
} }
return nodes.sort((a, b) => { return nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === "directory" ? -1 : 1 if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1
}
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name)
}) })
}) })
}) })
const search = Effect.fn("File.search")(function* (input: { const search = Effect.fn("FileService.search")(function* (input: {
query: string query: string
limit?: number limit?: number
dirs?: boolean dirs?: boolean
@@ -668,19 +684,34 @@ export namespace File {
log.info("search", { query, kind }) log.info("search", { query, kind })
const result = await getFiles() const result = await getFiles()
const preferHidden = query.startsWith(".") || query.includes("/.")
const hidden = (item: string) => {
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1)
}
const preferHidden = query.startsWith(".") || query.includes("/.")
const sortHiddenLast = (items: string[]) => {
if (preferHidden) return items
const visible: string[] = []
const hiddenItems: string[] = []
for (const item of items) {
const isHidden = hidden(item)
if (isHidden) hiddenItems.push(item)
if (!isHidden) visible.push(item)
}
return [...visible, ...hiddenItems]
}
if (!query) { if (!query) {
if (kind === "file") return result.files.slice(0, limit) if (kind === "file") return result.files.slice(0, limit)
return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit) return sortHiddenLast(result.dirs.toSorted()).slice(0, limit)
} }
const items = const items =
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs] kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target) const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target)
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted
log.info("search", { query, kind, results: output.length }) log.info("search", { query, kind, results: output.length })
return output return output
@@ -688,7 +719,8 @@ export namespace File {
}) })
log.info("init") log.info("init")
return Service.of({ init, status, read, list, search })
return FileService.of({ init, status, read, list, search })
}), }),
) )
} }
+68 -63
View File
@@ -1,110 +1,115 @@
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Flag } from "@/flag/flag"
import { Filesystem } from "../util/filesystem"
import { Effect, Layer, ServiceMap, Semaphore } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
import type { SessionID } from "@/session/schema"
export namespace FileTime { const log = Log.create({ service: "file.time" })
const log = Log.create({ service: "file.time" })
export type Stamp = { export namespace FileTimeService {
readonly read: Date export interface Service {
readonly mtime: number | undefined
readonly ctime: number | undefined
readonly size: number | undefined
}
const stamp = Effect.fnUntraced(function* (file: string) {
const stat = Filesystem.stat(file)
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
return {
read: yield* DateTime.nowAsDate,
mtime: stat?.mtime?.getTime(),
ctime: stat?.ctime?.getTime(),
size,
}
})
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
const value = reads.get(sessionID)
if (value) return value
const next = new Map<string, Stamp>()
reads.set(sessionID, next)
return next
}
export interface Interface {
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void> readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined> readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void> readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T> readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
} }
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {} type Stamp = {
readonly read: Date
readonly mtime: number | undefined
readonly ctime: number | undefined
readonly size: number | undefined
}
export const layer = Layer.effect( function stamp(file: string): Stamp {
Service, const stat = Filesystem.stat(file)
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
return {
read: new Date(),
mtime: stat?.mtime?.getTime(),
ctime: stat?.ctime?.getTime(),
size,
}
}
function session(reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) {
let value = reads.get(sessionID)
if (!value) {
value = new Map<string, Stamp>()
reads.set(sessionID, value)
}
return value
}
export class FileTimeService extends ServiceMap.Service<FileTimeService, FileTimeService.Service>()(
"@opencode/FileTime",
) {
static readonly layer = Layer.effect(
FileTimeService,
Effect.gen(function* () { Effect.gen(function* () {
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
const reads = new Map<SessionID, Map<string, Stamp>>() const reads = new Map<SessionID, Map<string, Stamp>>()
const locks = new Map<string, Semaphore.Semaphore>() const locks = new Map<string, Semaphore.Semaphore>()
const getLock = (filepath: string) => { function getLock(filepath: string) {
const lock = locks.get(filepath) let lock = locks.get(filepath)
if (lock) return lock if (!lock) {
lock = Semaphore.makeUnsafe(1)
const next = Semaphore.makeUnsafe(1) locks.set(filepath, lock)
locks.set(filepath, next) }
return next return lock
} }
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) { return FileTimeService.of({
read: Effect.fn("FileTimeService.read")(function* (sessionID: SessionID, file: string) {
log.info("read", { sessionID, file }) log.info("read", { sessionID, file })
session(reads, sessionID).set(file, yield* stamp(file)) session(reads, sessionID).set(file, stamp(file))
}) }),
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) { get: Effect.fn("FileTimeService.get")(function* (sessionID: SessionID, file: string) {
return reads.get(sessionID)?.get(file)?.read return reads.get(sessionID)?.get(file)?.read
}) }),
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) { assert: Effect.fn("FileTimeService.assert")(function* (sessionID: SessionID, filepath: string) {
if (disableCheck) return if (disableCheck) return
const time = reads.get(sessionID)?.get(filepath) const time = reads.get(sessionID)?.get(filepath)
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`) if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
const next = stamp(filepath)
const next = yield* stamp(filepath)
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
if (!changed) return
if (changed) {
throw new Error( throw new Error(
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`, `File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
) )
}) }
}),
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) { withLock: Effect.fn("FileTimeService.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1)) const lock = getLock(filepath)
return yield* Effect.promise(fn).pipe(lock.withPermits(1))
}),
}) })
return Service.of({ read, get, assert, withLock })
}), }),
) )
}
export namespace FileTime {
export function read(sessionID: SessionID, file: string) { export function read(sessionID: SessionID, file: string) {
return runPromiseInstance(Service.use((s) => s.read(sessionID, file))) return runPromiseInstance(FileTimeService.use((s) => s.read(sessionID, file)))
} }
export function get(sessionID: SessionID, file: string) { export function get(sessionID: SessionID, file: string) {
return runPromiseInstance(Service.use((s) => s.get(sessionID, file))) return runPromiseInstance(FileTimeService.use((s) => s.get(sessionID, file)))
} }
export async function assert(sessionID: SessionID, filepath: string) { export async function assert(sessionID: SessionID, filepath: string) {
return runPromiseInstance(Service.use((s) => s.assert(sessionID, filepath))) return runPromiseInstance(FileTimeService.use((s) => s.assert(sessionID, filepath)))
} }
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> { export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
return runPromiseInstance(Service.use((s) => s.withLock(filepath, fn))) return runPromiseInstance(FileTimeService.use((s) => s.withLock(filepath, fn)))
} }
} }
+46 -32
View File
@@ -1,29 +1,29 @@
import { Cause, Effect, Layer, ServiceMap } from "effect" import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { InstanceContext } from "@/effect/instance-context"
import { Instance } from "@/project/instance"
import z from "zod"
import { Log } from "../util/log"
import { FileIgnore } from "./ignore"
import { Config } from "../config/config"
import path from "path"
// @ts-ignore // @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper" import { createWrapper } from "@parcel/watcher/wrapper"
import { lazy } from "@/util/lazy"
import type ParcelWatcher from "@parcel/watcher" import type ParcelWatcher from "@parcel/watcher"
import { readdir } from "fs/promises" import { readdir } from "fs/promises"
import path from "path"
import z from "zod"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceContext } from "@/effect/instance-context"
import { Flag } from "@/flag/flag"
import { Instance } from "@/project/instance"
import { git } from "@/util/git" import { git } from "@/util/git"
import { lazy } from "@/util/lazy"
import { Config } from "../config/config"
import { FileIgnore } from "./ignore"
import { Protected } from "./protected" import { Protected } from "./protected"
import { Log } from "../util/log" import { Flag } from "@/flag/flag"
import { Cause, Effect, Layer, ServiceMap } from "effect"
const SUBSCRIBE_TIMEOUT_MS = 10_000
declare const OPENCODE_LIBC: string | undefined declare const OPENCODE_LIBC: string | undefined
export namespace FileWatcher { const log = Log.create({ service: "file.watcher" })
const log = Log.create({ service: "file.watcher" })
const SUBSCRIBE_TIMEOUT_MS = 10_000
export const Event = { const event = {
Updated: BusEvent.define( Updated: BusEvent.define(
"file.watcher.updated", "file.watcher.updated",
z.object({ z.object({
@@ -31,9 +31,9 @@ export namespace FileWatcher {
event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]), event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
}), }),
), ),
} }
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => { const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
try { try {
const binding = require( const binding = require(
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`, `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
@@ -43,34 +43,47 @@ export namespace FileWatcher {
log.error("failed to load watcher binding", { error }) log.error("failed to load watcher binding", { error })
return return
} }
}) })
function getBackend() { function getBackend() {
if (process.platform === "win32") return "windows" if (process.platform === "win32") return "windows"
if (process.platform === "darwin") return "fs-events" if (process.platform === "darwin") return "fs-events"
if (process.platform === "linux") return "inotify" if (process.platform === "linux") return "inotify"
} }
export namespace FileWatcher {
export const Event = event
/** Whether the native @parcel/watcher binding is available on this platform. */
export const hasNativeBinding = () => !!watcher() export const hasNativeBinding = () => !!watcher()
}
export class Service extends ServiceMap.Service<Service, {}>()("@opencode/FileWatcher") {} const init = Effect.fn("FileWatcherService.init")(function* () {})
export const layer = Layer.effect( export namespace FileWatcherService {
Service, export interface Service {
readonly init: () => Effect.Effect<void>
}
}
export class FileWatcherService extends ServiceMap.Service<FileWatcherService, FileWatcherService.Service>()(
"@opencode/FileWatcher",
) {
static readonly layer = Layer.effect(
FileWatcherService,
Effect.gen(function* () { Effect.gen(function* () {
const instance = yield* InstanceContext const instance = yield* InstanceContext
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({}) if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return FileWatcherService.of({ init })
log.info("init", { directory: instance.directory }) log.info("init", { directory: instance.directory })
const backend = getBackend() const backend = getBackend()
if (!backend) { if (!backend) {
log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform }) log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform })
return Service.of({}) return FileWatcherService.of({ init })
} }
const w = watcher() const w = watcher()
if (!w) return Service.of({}) if (!w) return FileWatcherService.of({ init })
log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend }) log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend })
@@ -80,9 +93,9 @@ export namespace FileWatcher {
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => { const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
if (err) return if (err) return
for (const evt of evts) { for (const evt of evts) {
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" }) if (evt.type === "create") Bus.publish(event.Updated, { file: evt.path, event: "add" })
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" }) if (evt.type === "update") Bus.publish(event.Updated, { file: evt.path, event: "change" })
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" }) if (evt.type === "delete") Bus.publish(event.Updated, { file: evt.path, event: "unlink" })
} }
}) })
@@ -95,6 +108,7 @@ export namespace FileWatcher {
Effect.timeout(SUBSCRIBE_TIMEOUT_MS), Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => { Effect.catchCause((cause) => {
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) }) log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
// Clean up a subscription that resolves after timeout
pending.then((s) => s.unsubscribe()).catch(() => {}) pending.then((s) => s.unsubscribe()).catch(() => {})
return Effect.void return Effect.void
}), }),
@@ -123,11 +137,11 @@ export namespace FileWatcher {
} }
} }
return Service.of({}) return FileWatcherService.of({ init })
}).pipe( }).pipe(
Effect.catchCause((cause) => { Effect.catchCause((cause) => {
log.error("failed to init watcher service", { cause: Cause.pretty(cause) }) log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
return Effect.succeed(Service.of({})) return Effect.succeed(FileWatcherService.of({ init }))
}), }),
), ),
) )
+40 -33
View File
@@ -1,20 +1,21 @@
import { Effect, Layer, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
import { InstanceContext } from "@/effect/instance-context"
import path from "path"
import { mergeDeep } from "remeda"
import z from "zod"
import { Bus } from "../bus" import { Bus } from "../bus"
import { Config } from "../config/config"
import { File } from "../file" import { File } from "../file"
import { Log } from "../util/log"
import path from "path"
import z from "zod"
import * as Formatter from "./formatter"
import { Config } from "../config/config"
import { mergeDeep } from "remeda"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Process } from "../util/process" import { Process } from "../util/process"
import { Log } from "../util/log" import { InstanceContext } from "@/effect/instance-context"
import * as Formatter from "./formatter" import { Effect, Layer, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
const log = Log.create({ service: "format" })
export namespace Format { export namespace Format {
const log = Log.create({ service: "format" })
export const Status = z export const Status = z
.object({ .object({
name: z.string(), name: z.string(),
@@ -26,14 +27,25 @@ export namespace Format {
}) })
export type Status = z.infer<typeof Status> export type Status = z.infer<typeof Status>
export interface Interface { export async function init() {
readonly status: () => Effect.Effect<Status[]> return runPromiseInstance(FormatService.use((s) => s.init()))
} }
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {} export async function status() {
return runPromiseInstance(FormatService.use((s) => s.status()))
}
}
export const layer = Layer.effect( export namespace FormatService {
Service, export interface Service {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<Format.Status[]>
}
}
export class FormatService extends ServiceMap.Service<FormatService, FormatService.Service>()("@opencode/Format") {
static readonly layer = Layer.effect(
FormatService,
Effect.gen(function* () { Effect.gen(function* () {
const instance = yield* InstanceContext const instance = yield* InstanceContext
@@ -51,19 +63,17 @@ export namespace Format {
delete formatters[name] delete formatters[name]
continue continue
} }
const info = mergeDeep(formatters[name] ?? {}, { const result = mergeDeep(formatters[name] ?? {}, {
command: [], command: [],
extensions: [], extensions: [],
...item, ...item,
}) }) as Formatter.Info
if (info.command.length === 0) continue if (result.command.length === 0) continue
formatters[name] = { result.enabled = async () => true
...info, result.name = name
name, formatters[name] = result
enabled: async () => true,
}
} }
} else { } else {
log.info("all formatters are disabled") log.info("all formatters are disabled")
@@ -110,12 +120,11 @@ export namespace Format {
}, },
) )
const exit = await proc.exited const exit = await proc.exited
if (exit !== 0) { if (exit !== 0)
log.error("failed", { log.error("failed", {
command: item.command, command: item.command,
...item.environment, ...item.environment,
}) })
}
} catch (error) { } catch (error) {
log.error("failed to format file", { log.error("failed to format file", {
error, error,
@@ -131,8 +140,10 @@ export namespace Format {
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)) yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
log.info("init") log.info("init")
const status = Effect.fn("Format.status")(function* () { const init = Effect.fn("FormatService.init")(function* () {})
const result: Status[] = []
const status = Effect.fn("FormatService.status")(function* () {
const result: Format.Status[] = []
for (const formatter of Object.values(formatters)) { for (const formatter of Object.values(formatters)) {
const isOn = yield* Effect.promise(() => isEnabled(formatter)) const isOn = yield* Effect.promise(() => isEnabled(formatter))
result.push({ result.push({
@@ -144,11 +155,7 @@ export namespace Format {
return result return result
}) })
return Service.of({ status }) return FormatService.of({ init, status })
}), }),
) )
export async function status() {
return runPromiseInstance(Service.use((s) => s.status()))
}
} }
@@ -3,7 +3,7 @@ import { Config } from "@/config/config"
import { fn } from "@/util/fn" import { fn } from "@/util/fn"
import { Wildcard } from "@/util/wildcard" import { Wildcard } from "@/util/wildcard"
import os from "os" import os from "os"
import { PermissionEffect as S } from "./effect" import * as S from "./service"
export namespace PermissionNext { export namespace PermissionNext {
function expand(pattern: string): string { function expand(pattern: string): string {
@@ -26,7 +26,7 @@ export namespace PermissionNext {
export type Reply = S.Reply export type Reply = S.Reply
export const Approval = S.Approval export const Approval = S.Approval
export const Event = S.Event export const Event = S.Event
export const Service = S.Service export const Service = S.PermissionService
export const RejectedError = S.RejectedError export const RejectedError = S.RejectedError
export const CorrectedError = S.CorrectedError export const CorrectedError = S.CorrectedError
export const DeniedError = S.DeniedError export const DeniedError = S.DeniedError
@@ -53,14 +53,16 @@ export namespace PermissionNext {
return rulesets.flat() return rulesets.flat()
} }
export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((service) => service.ask(input)))) export const ask = fn(S.AskInput, async (input) =>
runPromiseInstance(S.PermissionService.use((service) => service.ask(input))),
)
export const reply = fn(S.ReplyInput, async (input) => export const reply = fn(S.ReplyInput, async (input) =>
runPromiseInstance(S.Service.use((service) => service.reply(input))), runPromiseInstance(S.PermissionService.use((service) => service.reply(input))),
) )
export async function list() { export async function list() {
return runPromiseInstance(S.Service.use((service) => service.list())) return runPromiseInstance(S.PermissionService.use((service) => service.list()))
} }
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
@@ -11,15 +11,14 @@ import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import z from "zod" import z from "zod"
import { PermissionID } from "./schema" import { PermissionID } from "./schema"
export namespace PermissionEffect { const log = Log.create({ service: "permission" })
const log = Log.create({ service: "permission" })
export const Action = z.enum(["allow", "deny", "ask"]).meta({ export const Action = z.enum(["allow", "deny", "ask"]).meta({
ref: "PermissionAction", ref: "PermissionAction",
}) })
export type Action = z.infer<typeof Action> export type Action = z.infer<typeof Action>
export const Rule = z export const Rule = z
.object({ .object({
permission: z.string(), permission: z.string(),
pattern: z.string(), pattern: z.string(),
@@ -28,14 +27,14 @@ export namespace PermissionEffect {
.meta({ .meta({
ref: "PermissionRule", ref: "PermissionRule",
}) })
export type Rule = z.infer<typeof Rule> export type Rule = z.infer<typeof Rule>
export const Ruleset = Rule.array().meta({ export const Ruleset = Rule.array().meta({
ref: "PermissionRuleset", ref: "PermissionRuleset",
}) })
export type Ruleset = z.infer<typeof Ruleset> export type Ruleset = z.infer<typeof Ruleset>
export const Request = z export const Request = z
.object({ .object({
id: PermissionID.zod, id: PermissionID.zod,
sessionID: SessionID.zod, sessionID: SessionID.zod,
@@ -53,17 +52,17 @@ export namespace PermissionEffect {
.meta({ .meta({
ref: "PermissionRequest", ref: "PermissionRequest",
}) })
export type Request = z.infer<typeof Request> export type Request = z.infer<typeof Request>
export const Reply = z.enum(["once", "always", "reject"]) export const Reply = z.enum(["once", "always", "reject"])
export type Reply = z.infer<typeof Reply> export type Reply = z.infer<typeof Reply>
export const Approval = z.object({ export const Approval = z.object({
projectID: ProjectID.zod, projectID: ProjectID.zod,
patterns: z.string().array(), patterns: z.string().array(),
}) })
export const Event = { export const Event = {
Asked: BusEvent.define("permission.asked", Request), Asked: BusEvent.define("permission.asked", Request),
Replied: BusEvent.define( Replied: BusEvent.define(
"permission.replied", "permission.replied",
@@ -73,66 +72,60 @@ export namespace PermissionEffect {
reply: Reply, reply: Reply,
}), }),
), ),
} }
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) { export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
override get message() { override get message() {
return "The user rejected permission to use this specific tool call." return "The user rejected permission to use this specific tool call."
} }
} }
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", { export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
feedback: Schema.String, feedback: Schema.String,
}) { }) {
override get message() { override get message() {
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}` return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
} }
} }
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", { export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
ruleset: Schema.Any, ruleset: Schema.Any,
}) { }) {
override get message() { override get message() {
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}` return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
} }
} }
export type Error = DeniedError | RejectedError | CorrectedError export type PermissionError = DeniedError | RejectedError | CorrectedError
export const AskInput = Request.partial({ id: true }).extend({ interface PendingEntry {
info: Request
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
}
export const AskInput = Request.partial({ id: true }).extend({
ruleset: Ruleset, ruleset: Ruleset,
}) })
export const ReplyInput = z.object({ export const ReplyInput = z.object({
requestID: PermissionID.zod, requestID: PermissionID.zod,
reply: Reply, reply: Reply,
message: z.string().optional(), message: z.string().optional(),
}) })
export interface Interface { export declare namespace PermissionService {
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error> export interface Api {
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, PermissionError>
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void> readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]> readonly list: () => Effect.Effect<Request[]>
} }
}
interface PendingEntry { export class PermissionService extends ServiceMap.Service<PermissionService, PermissionService.Api>()(
info: Request "@opencode/PermissionNext",
deferred: Deferred.Deferred<void, RejectedError | CorrectedError> ) {
} static readonly layer = Layer.effect(
PermissionService,
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
const rules = rulesets.flat()
log.info("evaluate", { permission, pattern, ruleset: rules })
const match = rules.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () { Effect.gen(function* () {
const { project } = yield* InstanceContext const { project } = yield* InstanceContext
const row = Database.use((db) => const row = Database.use((db) =>
@@ -141,7 +134,7 @@ export namespace PermissionEffect {
const pending = new Map<PermissionID, PendingEntry>() const pending = new Map<PermissionID, PendingEntry>()
const approved: Ruleset = row?.data ?? [] const approved: Ruleset = row?.data ?? []
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) { const ask = Effect.fn("PermissionService.ask")(function* (input: z.infer<typeof AskInput>) {
const { ruleset, ...request } = input const { ruleset, ...request } = input
let needsAsk = false let needsAsk = false
@@ -177,7 +170,7 @@ export namespace PermissionEffect {
) )
}) })
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) { const reply = Effect.fn("PermissionService.reply")(function* (input: z.infer<typeof ReplyInput>) {
const existing = pending.get(input.requestID) const existing = pending.get(input.requestID)
if (!existing) return if (!existing) return
@@ -232,13 +225,27 @@ export namespace PermissionEffect {
}) })
yield* Deferred.succeed(item.deferred, undefined) yield* Deferred.succeed(item.deferred, undefined)
} }
// TODO: we don't save the permission ruleset to disk yet until there's
// UI to manage it
// db().insert(PermissionTable).values({ projectID: Instance.project.id, data: s.approved })
// .onConflictDoUpdate({ target: PermissionTable.projectID, set: { data: s.approved } }).run()
}) })
const list = Effect.fn("Permission.list")(function* () { const list = Effect.fn("PermissionService.list")(function* () {
return Array.from(pending.values(), (item) => item.info) return Array.from(pending.values(), (item) => item.info)
}) })
return Service.of({ ask, reply, list }) return PermissionService.of({ ask, reply, list })
}), }),
) )
} }
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
const merged = rulesets.flat()
log.info("evaluate", { permission, pattern, ruleset: merged })
const match = merged.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
}
+4 -1
View File
@@ -185,10 +185,12 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
const deploymentType = inputs.deploymentType || "github.com" const deploymentType = inputs.deploymentType || "github.com"
let domain = "github.com" let domain = "github.com"
let actualProvider = "github-copilot"
if (deploymentType === "enterprise") { if (deploymentType === "enterprise") {
const enterpriseUrl = inputs.enterpriseUrl const enterpriseUrl = inputs.enterpriseUrl
domain = normalizeDomain(enterpriseUrl!) domain = normalizeDomain(enterpriseUrl!)
actualProvider = "github-copilot-enterprise"
} }
const urls = getUrls(domain) const urls = getUrls(domain)
@@ -260,7 +262,8 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
expires: 0, expires: 0,
} }
if (deploymentType === "enterprise") { if (actualProvider === "github-copilot-enterprise") {
result.provider = "github-copilot-enterprise"
result.enterpriseUrl = domain result.enterpriseUrl = domain
} }
+12 -1
View File
@@ -1,23 +1,34 @@
import { Plugin } from "../plugin" import { Plugin } from "../plugin"
import { Format } from "../format"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { FileWatcherService } from "../file/watcher"
import { File } from "../file" import { File } from "../file"
import { Project } from "./project" import { Project } from "./project"
import { Bus } from "../bus" import { Bus } from "../bus"
import { Command } from "../command" import { Command } from "../command"
import { Instance } from "./instance" import { Instance } from "./instance"
import { VcsService } from "./vcs"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { ShareNext } from "@/share/share-next" import { ShareNext } from "@/share/share-next"
import { Snapshot } from "../snapshot"
import { Truncate } from "../tool/truncation"
import { runPromiseInstance } from "@/effect/runtime"
export async function InstanceBootstrap() { export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory }) Log.Default.info("bootstrapping", { directory: Instance.directory })
await Plugin.init() await Plugin.init()
ShareNext.init() ShareNext.init()
await Format.init()
await LSP.init() await LSP.init()
await runPromiseInstance(FileWatcherService.use((service) => service.init()))
File.init() File.init()
await runPromiseInstance(VcsService.use((s) => s.init()))
Snapshot.init()
Truncate.init()
Bus.subscribe(Command.Event.Executed, async (payload) => { Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) { if (payload.properties.name === Command.Default.INIT) {
Project.setInitialized(Instance.project.id) await Project.setInitialized(Instance.project.id)
} }
}) })
} }
+74 -96
View File
@@ -1,23 +1,23 @@
import { GlobalBus } from "@/bus/global"; import { Log } from "@/util/log"
import { disposeInstance } from "@/effect/instance-registry"; import { Context } from "../util/context"
import { Filesystem } from "@/util/filesystem"; import { Project } from "./project"
import { iife } from "@/util/iife"; import { State } from "./state"
import { Log } from "@/util/log"; import { iife } from "@/util/iife"
import { Context } from "../util/context"; import { GlobalBus } from "@/bus/global"
import { Project } from "./project"; import { Filesystem } from "@/util/filesystem"
import { State } from "./state"; import { disposeInstance } from "@/effect/instance-registry"
interface Context { interface Context {
directory: string; directory: string
worktree: string; worktree: string
project: Project.Info; project: Project.Info
} }
const context = Context.create<Context>("instance"); const context = Context.create<Context>("instance")
const cache = new Map<string, Promise<Context>>(); const cache = new Map<string, Promise<Context>>()
const disposal = { const disposal = {
all: undefined as Promise<void> | undefined, all: undefined as Promise<void> | undefined,
}; }
function emit(directory: string) { function emit(directory: string) {
GlobalBus.emit("event", { GlobalBus.emit("event", {
@@ -28,15 +28,10 @@ function emit(directory: string) {
directory, directory,
}, },
}, },
}); })
} }
function boot(input: { function boot(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
directory: string;
init?: () => Promise<any>;
project?: Project.Info;
worktree?: string;
}) {
return iife(async () => { return iife(async () => {
const ctx = const ctx =
input.project && input.worktree input.project && input.worktree
@@ -45,63 +40,54 @@ function boot(input: {
worktree: input.worktree, worktree: input.worktree,
project: input.project, project: input.project,
} }
: await Project.fromDirectory(input.directory).then( : await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({
({ project, sandbox }) => ({
directory: input.directory, directory: input.directory,
worktree: sandbox, worktree: sandbox,
project, project,
}), }))
);
await context.provide(ctx, async () => { await context.provide(ctx, async () => {
await input.init?.(); await input.init?.()
}); })
return ctx; return ctx
}); })
} }
function track(directory: string, next: Promise<Context>) { function track(directory: string, next: Promise<Context>) {
const task = next.catch((error) => { const task = next.catch((error) => {
if (cache.get(directory) === task) cache.delete(directory); if (cache.get(directory) === task) cache.delete(directory)
throw error; throw error
}); })
cache.set(directory, task); cache.set(directory, task)
return task; return task
} }
export const Instance = { export const Instance = {
async provide<R>(input: { async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
directory: string; const directory = Filesystem.resolve(input.directory)
init?: () => Promise<any>; let existing = cache.get(directory)
fn: () => R;
}): Promise<R> {
const directory = Filesystem.resolve(input.directory);
let existing = cache.get(directory);
if (!existing) { if (!existing) {
Log.Default.info("creating instance", { directory }); Log.Default.info("creating instance", { directory })
existing = track( existing = track(
directory, directory,
boot({ boot({
directory, directory,
init: input.init, init: input.init,
}), }),
); )
} }
const ctx = await existing; const ctx = await existing
return context.provide(ctx, async () => { return context.provide(ctx, async () => {
return input.fn(); return input.fn()
}); })
},
get current() {
return context.use();
}, },
get directory() { get directory() {
return context.use().directory; return context.use().directory
}, },
get worktree() { get worktree() {
return context.use().worktree; return context.use().worktree
}, },
get project() { get project() {
return context.use().project; return context.use().project
}, },
/** /**
* Check if a path is within the project boundary. * Check if a path is within the project boundary.
@@ -109,11 +95,11 @@ export const Instance = {
* Paths within the worktree but outside the working directory should not trigger external_directory permission. * Paths within the worktree but outside the working directory should not trigger external_directory permission.
*/ */
containsPath(filepath: string) { containsPath(filepath: string) {
if (Filesystem.contains(Instance.directory, filepath)) return true; if (Filesystem.contains(Instance.directory, filepath)) return true
// Non-git projects set worktree to "/" which would match ANY absolute path. // Non-git projects set worktree to "/" which would match ANY absolute path.
// Skip worktree check in this case to preserve external_directory permissions. // Skip worktree check in this case to preserve external_directory permissions.
if (Instance.worktree === "/") return false; if (Instance.worktree === "/") return false
return Filesystem.contains(Instance.worktree, filepath); return Filesystem.contains(Instance.worktree, filepath)
}, },
/** /**
* Captures the current instance ALS context and returns a wrapper that * Captures the current instance ALS context and returns a wrapper that
@@ -121,65 +107,57 @@ export const Instance = {
* instance async context (native addons, event emitters, timers, etc.). * instance async context (native addons, event emitters, timers, etc.).
*/ */
bind<F extends (...args: any[]) => any>(fn: F): F { bind<F extends (...args: any[]) => any>(fn: F): F {
const ctx = context.use(); const ctx = context.use()
return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F; return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F
}, },
state<S>( state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
init: () => S, return State.create(() => Instance.directory, init, dispose)
dispose?: (state: Awaited<S>) => Promise<void>,
): () => S {
return State.create(() => Instance.directory, init, dispose);
}, },
async reload(input: { async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
directory: string; const directory = Filesystem.resolve(input.directory)
init?: () => Promise<any>; Log.Default.info("reloading instance", { directory })
project?: Project.Info; await Promise.all([State.dispose(directory), disposeInstance(directory)])
worktree?: string; cache.delete(directory)
}) { const next = track(directory, boot({ ...input, directory }))
const directory = Filesystem.resolve(input.directory); emit(directory)
Log.Default.info("reloading instance", { directory }); return await next
await Promise.all([State.dispose(directory), disposeInstance(directory)]);
cache.delete(directory);
const next = track(directory, boot({ ...input, directory }));
emit(directory);
return await next;
}, },
async dispose() { async dispose() {
const directory = Instance.directory; const directory = Instance.directory
Log.Default.info("disposing instance", { directory }); Log.Default.info("disposing instance", { directory })
await Promise.all([State.dispose(directory), disposeInstance(directory)]); await Promise.all([State.dispose(directory), disposeInstance(directory)])
cache.delete(directory); cache.delete(directory)
emit(directory); emit(directory)
}, },
async disposeAll() { async disposeAll() {
if (disposal.all) return disposal.all; if (disposal.all) return disposal.all
disposal.all = iife(async () => { disposal.all = iife(async () => {
Log.Default.info("disposing all instances"); Log.Default.info("disposing all instances")
const entries = [...cache.entries()]; const entries = [...cache.entries()]
for (const [key, value] of entries) { for (const [key, value] of entries) {
if (cache.get(key) !== value) continue; if (cache.get(key) !== value) continue
const ctx = await value.catch((error) => { const ctx = await value.catch((error) => {
Log.Default.warn("instance dispose failed", { key, error }); Log.Default.warn("instance dispose failed", { key, error })
return undefined; return undefined
}); })
if (!ctx) { if (!ctx) {
if (cache.get(key) === value) cache.delete(key); if (cache.get(key) === value) cache.delete(key)
continue; continue
} }
if (cache.get(key) !== value) continue; if (cache.get(key) !== value) continue
await context.provide(ctx, async () => { await context.provide(ctx, async () => {
await Instance.dispose(); await Instance.dispose()
}); })
} }
}).finally(() => { }).finally(() => {
disposal.all = undefined; disposal.all = undefined
}); })
return disposal.all; return disposal.all
}, },
}; }
+19 -15
View File
@@ -1,16 +1,16 @@
import { Effect, Layer, ServiceMap } from "effect"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import z from "zod"
import { Log } from "@/util/log"
import { Instance } from "./instance"
import { InstanceContext } from "@/effect/instance-context" import { InstanceContext } from "@/effect/instance-context"
import { FileWatcher } from "@/file/watcher" import { FileWatcher } from "@/file/watcher"
import { Log } from "@/util/log"
import { git } from "@/util/git" import { git } from "@/util/git"
import { Instance } from "./instance" import { Effect, Layer, ServiceMap } from "effect"
import z from "zod"
const log = Log.create({ service: "vcs" })
export namespace Vcs { export namespace Vcs {
const log = Log.create({ service: "vcs" })
export const Event = { export const Event = {
BranchUpdated: BusEvent.define( BranchUpdated: BusEvent.define(
"vcs.branch.updated", "vcs.branch.updated",
@@ -28,15 +28,18 @@ export namespace Vcs {
ref: "VcsInfo", ref: "VcsInfo",
}) })
export type Info = z.infer<typeof Info> export type Info = z.infer<typeof Info>
}
export interface Interface { export namespace VcsService {
export interface Service {
readonly init: () => Effect.Effect<void>
readonly branch: () => Effect.Effect<string | undefined> readonly branch: () => Effect.Effect<string | undefined>
} }
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {} export class VcsService extends ServiceMap.Service<VcsService, VcsService.Service>()("@opencode/Vcs") {
static readonly layer = Layer.effect(
export const layer = Layer.effect( VcsService,
Service,
Effect.gen(function* () { Effect.gen(function* () {
const instance = yield* InstanceContext const instance = yield* InstanceContext
let current: string | undefined let current: string | undefined
@@ -62,7 +65,7 @@ export namespace Vcs {
if (next !== current) { if (next !== current) {
log.info("branch changed", { from: current, to: next }) log.info("branch changed", { from: current, to: next })
current = next current = next
Bus.publish(Event.BranchUpdated, { branch: next }) Bus.publish(Vcs.Event.BranchUpdated, { branch: next })
} }
}), }),
) )
@@ -70,8 +73,9 @@ export namespace Vcs {
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)) yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
} }
return Service.of({ return VcsService.of({
branch: Effect.fn("Vcs.branch")(function* () { init: Effect.fn("VcsService.init")(function* () {}),
branch: Effect.fn("VcsService.branch")(function* () {
return current return current
}), }),
}) })
@@ -1,13 +1,12 @@
import type { AuthOuathResult } from "@opencode-ai/plugin" import type { AuthOuathResult } from "@opencode-ai/plugin"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import * as Auth from "@/auth/service"
import { ProviderID } from "./schema"
import { Effect, Layer, Record, ServiceMap, Struct } from "effect" import { Effect, Layer, Record, ServiceMap, Struct } from "effect"
import { filter, fromEntries, map, pipe } from "remeda" import { filter, fromEntries, map, pipe } from "remeda"
import z from "zod" import z from "zod"
import * as Auth from "@/auth/effect"
import { ProviderID } from "./schema"
export namespace ProviderAuthEffect { export const Method = z
export const Method = z
.object({ .object({
type: z.union([z.literal("oauth"), z.literal("api")]), type: z.union([z.literal("oauth"), z.literal("api")]),
label: z.string(), label: z.string(),
@@ -15,9 +14,9 @@ export namespace ProviderAuthEffect {
.meta({ .meta({
ref: "ProviderAuthMethod", ref: "ProviderAuthMethod",
}) })
export type Method = z.infer<typeof Method> export type Method = z.infer<typeof Method>
export const Authorization = z export const Authorization = z
.object({ .object({
url: z.string(), url: z.string(),
method: z.union([z.literal("auto"), z.literal("code")]), method: z.union([z.literal("auto"), z.literal("code")]),
@@ -26,42 +25,49 @@ export namespace ProviderAuthEffect {
.meta({ .meta({
ref: "ProviderAuthAuthorization", ref: "ProviderAuthAuthorization",
}) })
export type Authorization = z.infer<typeof Authorization> export type Authorization = z.infer<typeof Authorization>
export const OauthMissing = NamedError.create( export const OauthMissing = NamedError.create(
"ProviderAuthOauthMissing", "ProviderAuthOauthMissing",
z.object({ z.object({
providerID: ProviderID.zod, providerID: ProviderID.zod,
}), }),
) )
export const OauthCodeMissing = NamedError.create( export const OauthCodeMissing = NamedError.create(
"ProviderAuthOauthCodeMissing", "ProviderAuthOauthCodeMissing",
z.object({ z.object({
providerID: ProviderID.zod, providerID: ProviderID.zod,
}), }),
) )
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({})) export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
export type Error = export type ProviderAuthError =
| Auth.AuthEffect.AuthServiceError | Auth.AuthServiceError
| InstanceType<typeof OauthMissing> | InstanceType<typeof OauthMissing>
| InstanceType<typeof OauthCodeMissing> | InstanceType<typeof OauthCodeMissing>
| InstanceType<typeof OauthCallbackFailed> | InstanceType<typeof OauthCallbackFailed>
export interface Interface { export namespace ProviderAuthService {
export interface Service {
readonly methods: () => Effect.Effect<Record<string, Method[]>> readonly methods: () => Effect.Effect<Record<string, Method[]>>
readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect<Authorization | undefined> readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect<Authorization | undefined>
readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error> readonly callback: (input: {
providerID: ProviderID
method: number
code?: string
}) => Effect.Effect<void, ProviderAuthError>
} }
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {} export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService, ProviderAuthService.Service>()(
"@opencode/ProviderAuth",
export const layer = Layer.effect( ) {
Service, static readonly layer = Layer.effect(
ProviderAuthService,
Effect.gen(function* () { Effect.gen(function* () {
const auth = yield* Auth.AuthEffect.Service const auth = yield* Auth.AuthService
const hooks = yield* Effect.promise(async () => { const hooks = yield* Effect.promise(async () => {
const mod = await import("../plugin") const mod = await import("../plugin")
return pipe( return pipe(
@@ -73,11 +79,11 @@ export namespace ProviderAuthEffect {
}) })
const pending = new Map<ProviderID, AuthOuathResult>() const pending = new Map<ProviderID, AuthOuathResult>()
const methods = Effect.fn("ProviderAuth.methods")(function* () { const methods = Effect.fn("ProviderAuthService.methods")(function* () {
return Record.map(hooks, (item) => item.methods.map((method): Method => Struct.pick(method, ["type", "label"]))) return Record.map(hooks, (item) => item.methods.map((method): Method => Struct.pick(method, ["type", "label"])))
}) })
const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: { const authorize = Effect.fn("ProviderAuthService.authorize")(function* (input: {
providerID: ProviderID providerID: ProviderID
method: number method: number
}) { }) {
@@ -92,16 +98,15 @@ export namespace ProviderAuthEffect {
} }
}) })
const callback = Effect.fn("ProviderAuth.callback")(function* (input: { const callback = Effect.fn("ProviderAuthService.callback")(function* (input: {
providerID: ProviderID providerID: ProviderID
method: number method: number
code?: string code?: string
}) { }) {
const match = pending.get(input.providerID) const match = pending.get(input.providerID)
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID })) if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
if (match.method === "code" && !input.code) { if (match.method === "code" && !input.code)
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID })) return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
}
const result = yield* Effect.promise(() => const result = yield* Effect.promise(() =>
match.method === "code" ? match.callback(input.code!) : match.callback(), match.method === "code" ? match.callback(input.code!) : match.callback(),
@@ -126,9 +131,13 @@ export namespace ProviderAuthEffect {
} }
}) })
return Service.of({ methods, authorize, callback }) return ProviderAuthService.of({
methods,
authorize,
callback,
})
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(Auth.AuthEffect.defaultLayer)) static readonly defaultLayer = ProviderAuthService.layer.pipe(Layer.provide(Auth.AuthService.defaultLayer))
} }
+4 -4
View File
@@ -2,7 +2,7 @@ import z from "zod"
import { runPromiseInstance } from "@/effect/runtime" import { runPromiseInstance } from "@/effect/runtime"
import { fn } from "@/util/fn" import { fn } from "@/util/fn"
import { ProviderAuthEffect as S } from "./auth-effect" import * as S from "./auth-service"
import { ProviderID } from "./schema" import { ProviderID } from "./schema"
export namespace ProviderAuth { export namespace ProviderAuth {
@@ -10,7 +10,7 @@ export namespace ProviderAuth {
export type Method = S.Method export type Method = S.Method
export async function methods() { export async function methods() {
return runPromiseInstance(S.Service.use((service) => service.methods())) return runPromiseInstance(S.ProviderAuthService.use((service) => service.methods()))
} }
export const Authorization = S.Authorization export const Authorization = S.Authorization
@@ -22,7 +22,7 @@ export namespace ProviderAuth {
method: z.number(), method: z.number(),
}), }),
async (input): Promise<Authorization | undefined> => async (input): Promise<Authorization | undefined> =>
runPromiseInstance(S.Service.use((service) => service.authorize(input))), runPromiseInstance(S.ProviderAuthService.use((service) => service.authorize(input))),
) )
export const callback = fn( export const callback = fn(
@@ -31,7 +31,7 @@ export namespace ProviderAuth {
method: z.number(), method: z.number(),
code: z.string().optional(), code: z.string().optional(),
}), }),
async (input) => runPromiseInstance(S.Service.use((service) => service.callback(input))), async (input) => runPromiseInstance(S.ProviderAuthService.use((service) => service.callback(input))),
) )
export import OauthMissing = S.OauthMissing export import OauthMissing = S.OauthMissing
+55 -1
View File
@@ -197,6 +197,16 @@ export namespace Provider {
options: {}, options: {},
} }
}, },
"github-copilot-enterprise": async () => {
return {
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
},
options: {},
}
},
azure: async (provider) => { azure: async (provider) => {
const resource = iife(() => { const resource = iife(() => {
const name = provider.options?.resourceName const name = provider.options?.resourceName
@@ -853,6 +863,20 @@ export namespace Provider {
const configProviders = Object.entries(config.provider ?? {}) const configProviders = Object.entries(config.provider ?? {})
// Add GitHub Copilot Enterprise provider that inherits from GitHub Copilot
if (database["github-copilot"]) {
const githubCopilot = database["github-copilot"]
database["github-copilot-enterprise"] = {
...githubCopilot,
id: ProviderID.githubCopilotEnterprise,
name: "GitHub Copilot Enterprise",
models: mapValues(githubCopilot.models, (model) => ({
...model,
providerID: ProviderID.githubCopilotEnterprise,
})),
}
}
function mergeProvider(providerID: ProviderID, provider: Partial<Info>) { function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
const existing = providers[providerID] const existing = providers[providerID]
if (existing) { if (existing) {
@@ -979,16 +1003,46 @@ export namespace Provider {
const providerID = ProviderID.make(plugin.auth.provider) const providerID = ProviderID.make(plugin.auth.provider)
if (disabled.has(providerID)) continue if (disabled.has(providerID)) continue
// For github-copilot plugin, check if auth exists for either github-copilot or github-copilot-enterprise
let hasAuth = false
const auth = await Auth.get(providerID) const auth = await Auth.get(providerID)
if (!auth) continue if (auth) hasAuth = true
// Special handling for github-copilot: also check for enterprise auth
if (providerID === ProviderID.githubCopilot && !hasAuth) {
const enterpriseAuth = await Auth.get("github-copilot-enterprise")
if (enterpriseAuth) hasAuth = true
}
if (!hasAuth) continue
if (!plugin.auth.loader) continue if (!plugin.auth.loader) continue
// Load for the main provider if auth exists
if (auth) { if (auth) {
const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider]) const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider])
const opts = options ?? {} const opts = options ?? {}
const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts } const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
mergeProvider(providerID, patch) mergeProvider(providerID, patch)
} }
// If this is github-copilot plugin, also register for github-copilot-enterprise if auth exists
if (providerID === ProviderID.githubCopilot) {
const enterpriseProviderID = ProviderID.githubCopilotEnterprise
if (!disabled.has(enterpriseProviderID)) {
const enterpriseAuth = await Auth.get(enterpriseProviderID)
if (enterpriseAuth) {
const enterpriseOptions = await plugin.auth.loader(
() => Auth.get(enterpriseProviderID) as any,
database[enterpriseProviderID],
)
const opts = enterpriseOptions ?? {}
const patch: Partial<Info> = providers[enterpriseProviderID]
? { options: opts }
: { source: "custom", options: opts }
mergeProvider(enterpriseProviderID, patch)
}
}
}
} }
for (const [id, fn] of Object.entries(CUSTOM_LOADERS)) { for (const [id, fn] of Object.entries(CUSTOM_LOADERS)) {
+1
View File
@@ -18,6 +18,7 @@ export const ProviderID = providerIdSchema.pipe(
google: schema.makeUnsafe("google"), google: schema.makeUnsafe("google"),
googleVertex: schema.makeUnsafe("google-vertex"), googleVertex: schema.makeUnsafe("google-vertex"),
githubCopilot: schema.makeUnsafe("github-copilot"), githubCopilot: schema.makeUnsafe("github-copilot"),
githubCopilotEnterprise: schema.makeUnsafe("github-copilot-enterprise"),
amazonBedrock: schema.makeUnsafe("amazon-bedrock"), amazonBedrock: schema.makeUnsafe("amazon-bedrock"),
azure: schema.makeUnsafe("azure"), azure: schema.makeUnsafe("azure"),
openrouter: schema.makeUnsafe("openrouter"), openrouter: schema.makeUnsafe("openrouter"),
-168
View File
@@ -1,168 +0,0 @@
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { SessionID, MessageID } from "@/session/schema"
import { Log } from "@/util/log"
import z from "zod"
import { QuestionID } from "./schema"
export namespace QuestionEffect {
const log = Log.create({ service: "question" })
export const Option = z
.object({
label: z.string().describe("Display text (1-5 words, concise)"),
description: z.string().describe("Explanation of choice"),
})
.meta({ ref: "QuestionOption" })
export type Option = z.infer<typeof Option>
export const Info = z
.object({
question: z.string().describe("Complete question"),
header: z.string().describe("Very short label (max 30 chars)"),
options: z.array(Option).describe("Available choices"),
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
})
.meta({ ref: "QuestionInfo" })
export type Info = z.infer<typeof Info>
export const Request = z
.object({
id: QuestionID.zod,
sessionID: SessionID.zod,
questions: z.array(Info).describe("Questions to ask"),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({ ref: "QuestionRequest" })
export type Request = z.infer<typeof Request>
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
export type Answer = z.infer<typeof Answer>
export const Reply = z.object({
answers: z.array(Answer).describe("User answers in order of questions (each answer is an array of selected labels)"),
})
export type Reply = z.infer<typeof Reply>
export const Event = {
Asked: BusEvent.define("question.asked", Request),
Replied: BusEvent.define(
"question.replied",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
answers: z.array(Answer),
}),
),
Rejected: BusEvent.define(
"question.rejected",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
}),
),
}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
override get message() {
return "The user dismissed this question"
}
}
export type Error = RejectedError
interface Pending {
info: Request
deferred: Deferred.Deferred<Answer[], RejectedError>
}
export interface Interface {
readonly ask: (input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}) => Effect.Effect<Answer[], RejectedError>
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Question") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const pending = new Map<QuestionID, Pending>()
const ask = Effect.fn("Question.ask")(function* (input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}) {
const id = QuestionID.ascending()
log.info("asking", { id, questions: input.questions.length })
const deferred = yield* Deferred.make<Answer[], RejectedError>()
const info: Request = {
id,
sessionID: input.sessionID,
questions: input.questions,
tool: input.tool,
}
pending.set(id, { info, deferred })
Bus.publish(Event.Asked, info)
return yield* Effect.ensuring(
Deferred.await(deferred),
Effect.sync(() => {
pending.delete(id)
}),
)
})
const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
const existing = pending.get(input.requestID)
if (!existing) {
log.warn("reply for unknown request", { requestID: input.requestID })
return
}
pending.delete(input.requestID)
log.info("replied", { requestID: input.requestID, answers: input.answers })
Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: input.answers,
})
yield* Deferred.succeed(existing.deferred, input.answers)
})
const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
const existing = pending.get(requestID)
if (!existing) {
log.warn("reject for unknown request", { requestID })
return
}
pending.delete(requestID)
log.info("rejected", { requestID })
Bus.publish(Event.Rejected, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
})
yield* Deferred.fail(existing.deferred, new RejectedError())
})
const list = Effect.fn("Question.list")(function* () {
return Array.from(pending.values(), (x) => x.info)
})
return Service.of({ ask, reply, reject, list })
}),
)
}
+17 -17
View File
@@ -1,39 +1,39 @@
import { runPromiseInstance } from "@/effect/runtime" import { runPromiseInstance } from "@/effect/runtime"
import * as S from "./effect" import * as S from "./service"
import type { QuestionID } from "./schema" import type { QuestionID } from "./schema"
import type { SessionID, MessageID } from "@/session/schema" import type { SessionID, MessageID } from "@/session/schema"
export namespace Question { export namespace Question {
export const Option = S.QuestionEffect.Option export const Option = S.Option
export type Option = S.QuestionEffect.Option export type Option = S.Option
export const Info = S.QuestionEffect.Info export const Info = S.Info
export type Info = S.QuestionEffect.Info export type Info = S.Info
export const Request = S.QuestionEffect.Request export const Request = S.Request
export type Request = S.QuestionEffect.Request export type Request = S.Request
export const Answer = S.QuestionEffect.Answer export const Answer = S.Answer
export type Answer = S.QuestionEffect.Answer export type Answer = S.Answer
export const Reply = S.QuestionEffect.Reply export const Reply = S.Reply
export type Reply = S.QuestionEffect.Reply export type Reply = S.Reply
export const Event = S.QuestionEffect.Event export const Event = S.Event
export const RejectedError = S.QuestionEffect.RejectedError export const RejectedError = S.RejectedError
export async function ask(input: { export async function ask(input: {
sessionID: SessionID sessionID: SessionID
questions: Info[] questions: Info[]
tool?: { messageID: MessageID; callID: string } tool?: { messageID: MessageID; callID: string }
}): Promise<Answer[]> { }): Promise<Answer[]> {
return runPromiseInstance(S.QuestionEffect.Service.use((service) => service.ask(input))) return runPromiseInstance(S.QuestionService.use((service) => service.ask(input)))
} }
export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> { export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> {
return runPromiseInstance(S.QuestionEffect.Service.use((service) => service.reply(input))) return runPromiseInstance(S.QuestionService.use((service) => service.reply(input)))
} }
export async function reject(requestID: QuestionID): Promise<void> { export async function reject(requestID: QuestionID): Promise<void> {
return runPromiseInstance(S.QuestionEffect.Service.use((service) => service.reject(requestID))) return runPromiseInstance(S.QuestionService.use((service) => service.reject(requestID)))
} }
export async function list(): Promise<Request[]> { export async function list(): Promise<Request[]> {
return runPromiseInstance(S.QuestionEffect.Service.use((service) => service.list())) return runPromiseInstance(S.QuestionService.use((service) => service.list()))
} }
} }
+172
View File
@@ -0,0 +1,172 @@
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { SessionID, MessageID } from "@/session/schema"
import { Log } from "@/util/log"
import z from "zod"
import { QuestionID } from "./schema"
const log = Log.create({ service: "question" })
// --- Zod schemas (re-exported by facade) ---
export const Option = z
.object({
label: z.string().describe("Display text (1-5 words, concise)"),
description: z.string().describe("Explanation of choice"),
})
.meta({ ref: "QuestionOption" })
export type Option = z.infer<typeof Option>
export const Info = z
.object({
question: z.string().describe("Complete question"),
header: z.string().describe("Very short label (max 30 chars)"),
options: z.array(Option).describe("Available choices"),
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
})
.meta({ ref: "QuestionInfo" })
export type Info = z.infer<typeof Info>
export const Request = z
.object({
id: QuestionID.zod,
sessionID: SessionID.zod,
questions: z.array(Info).describe("Questions to ask"),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({ ref: "QuestionRequest" })
export type Request = z.infer<typeof Request>
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
export type Answer = z.infer<typeof Answer>
export const Reply = z.object({
answers: z.array(Answer).describe("User answers in order of questions (each answer is an array of selected labels)"),
})
export type Reply = z.infer<typeof Reply>
export const Event = {
Asked: BusEvent.define("question.asked", Request),
Replied: BusEvent.define(
"question.replied",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
answers: z.array(Answer),
}),
),
Rejected: BusEvent.define(
"question.rejected",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
}),
),
}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
override get message() {
return "The user dismissed this question"
}
}
// --- Effect service ---
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<Answer[], RejectedError>
}
export namespace QuestionService {
export interface Service {
readonly ask: (input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}) => Effect.Effect<Answer[], RejectedError>
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]>
}
}
export class QuestionService extends ServiceMap.Service<QuestionService, QuestionService.Service>()(
"@opencode/Question",
) {
static readonly layer = Layer.effect(
QuestionService,
Effect.gen(function* () {
const pending = new Map<QuestionID, PendingEntry>()
const ask = Effect.fn("QuestionService.ask")(function* (input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}) {
const id = QuestionID.ascending()
log.info("asking", { id, questions: input.questions.length })
const deferred = yield* Deferred.make<Answer[], RejectedError>()
const info: Request = {
id,
sessionID: input.sessionID,
questions: input.questions,
tool: input.tool,
}
pending.set(id, { info, deferred })
Bus.publish(Event.Asked, info)
return yield* Effect.ensuring(
Deferred.await(deferred),
Effect.sync(() => {
pending.delete(id)
}),
)
})
const reply = Effect.fn("QuestionService.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
const existing = pending.get(input.requestID)
if (!existing) {
log.warn("reply for unknown request", { requestID: input.requestID })
return
}
pending.delete(input.requestID)
log.info("replied", { requestID: input.requestID, answers: input.answers })
Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: input.answers,
})
yield* Deferred.succeed(existing.deferred, input.answers)
})
const reject = Effect.fn("QuestionService.reject")(function* (requestID: QuestionID) {
const existing = pending.get(requestID)
if (!existing) {
log.warn("reject for unknown request", { requestID })
return
}
pending.delete(requestID)
log.info("rejected", { requestID })
Bus.publish(Event.Rejected, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
})
yield* Deferred.fail(existing.deferred, new RejectedError())
})
const list = Effect.fn("QuestionService.list")(function* () {
return Array.from(pending.values(), (x) => x.info)
})
return QuestionService.of({ ask, reply, reject, list })
}),
)
}
+61
View File
@@ -0,0 +1,61 @@
import { Instance } from "../project/instance"
import { Log } from "../util/log"
export namespace Scheduler {
const log = Log.create({ service: "scheduler" })
export type Task = {
id: string
interval: number
run: () => Promise<void>
scope?: "instance" | "global"
}
type Timer = ReturnType<typeof setInterval>
type Entry = {
tasks: Map<string, Task>
timers: Map<string, Timer>
}
const create = (): Entry => {
const tasks = new Map<string, Task>()
const timers = new Map<string, Timer>()
return { tasks, timers }
}
const shared = create()
const state = Instance.state(
() => create(),
async (entry) => {
for (const timer of entry.timers.values()) {
clearInterval(timer)
}
entry.tasks.clear()
entry.timers.clear()
},
)
export function register(task: Task) {
const scope = task.scope ?? "instance"
const entry = scope === "global" ? shared : state()
const current = entry.timers.get(task.id)
if (current && scope === "global") return
if (current) clearInterval(current)
entry.tasks.set(task.id, task)
void run(task)
const timer = setInterval(() => {
void run(task)
}, task.interval)
timer.unref()
entry.timers.set(task.id, timer)
}
async function run(task: Task) {
log.info("run", { id: task.id })
await task.run().catch((error) => {
log.error("run failed", { id: task.id, error })
})
}
}
@@ -1,7 +1,7 @@
import { Hono } from "hono" import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi" import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod" import z from "zod"
import { PermissionNext } from "@/permission" import { PermissionNext } from "@/permission/next"
import { PermissionID } from "@/permission/schema" import { PermissionID } from "@/permission/schema"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
@@ -14,7 +14,7 @@ import { Todo } from "../../session/todo"
import { Agent } from "../../agent/agent" import { Agent } from "../../agent/agent"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { Log } from "../../util/log" import { Log } from "../../util/log"
import { PermissionNext } from "@/permission" import { PermissionNext } from "@/permission/next"
import { PermissionID } from "@/permission/schema" import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema" import { ModelID, ProviderID } from "@/provider/schema"
import { errors } from "../error" import { errors } from "../error"
+2 -2
View File
@@ -14,7 +14,7 @@ import { LSP } from "../lsp"
import { Format } from "../format" import { Format } from "../format"
import { TuiRoutes } from "./routes/tui" import { TuiRoutes } from "./routes/tui"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Vcs } from "../project/vcs" import { Vcs, VcsService } from "../project/vcs"
import { runPromiseInstance } from "@/effect/runtime" import { runPromiseInstance } from "@/effect/runtime"
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
import { Skill } from "../skill/skill" import { Skill } from "../skill/skill"
@@ -331,7 +331,7 @@ export namespace Server {
}, },
}), }),
async (c) => { async (c) => {
const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch())) const branch = await runPromiseInstance(VcsService.use((s) => s.branch()))
return c.json({ return c.json({
branch, branch,
}) })
+1 -1
View File
@@ -28,7 +28,7 @@ import { SessionID, MessageID, PartID } from "./schema"
import type { Provider } from "@/provider/provider" import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema" import { ModelID, ProviderID } from "@/provider/schema"
import { PermissionNext } from "@/permission" import { PermissionNext } from "@/permission/next"
import { Global } from "@/global" import { Global } from "@/global"
import type { LanguageModelV2Usage } from "@ai-sdk/provider" import type { LanguageModelV2Usage } from "@ai-sdk/provider"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
+1 -1
View File
@@ -20,7 +20,7 @@ import type { MessageV2 } from "./message-v2"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
import { SystemPrompt } from "./system" import { SystemPrompt } from "./system"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { PermissionNext } from "@/permission" import { PermissionNext } from "@/permission/next"
import { Auth } from "@/auth" import { Auth } from "@/auth"
export namespace LLM { export namespace LLM {
+1 -1
View File
@@ -12,7 +12,7 @@ import type { Provider } from "@/provider/provider"
import { LLM } from "./llm" import { LLM } from "./llm"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { SessionCompaction } from "./compaction" import { SessionCompaction } from "./compaction"
import { PermissionNext } from "@/permission" import { PermissionNext } from "@/permission/next"
import { Question } from "@/question" import { Question } from "@/question"
import { PartID } from "./schema" import { PartID } from "./schema"
import type { SessionID, MessageID } from "./schema" import type { SessionID, MessageID } from "./schema"
+2 -2
View File
@@ -41,12 +41,12 @@ import { fn } from "@/util/fn"
import { SessionProcessor } from "./processor" import { SessionProcessor } from "./processor"
import { TaskTool } from "@/tool/task" import { TaskTool } from "@/tool/task"
import { Tool } from "@/tool/tool" import { Tool } from "@/tool/tool"
import { PermissionNext } from "@/permission" import { PermissionNext } from "@/permission/next"
import { SessionStatus } from "./status" import { SessionStatus } from "./status"
import { LLM } from "./llm" import { LLM } from "./llm"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Shell } from "@/shell/shell" import { Shell } from "@/shell/shell"
import { Truncate } from "@/tool/truncate" import { Truncate } from "@/tool/truncation"
import { decodeDataUrl } from "@/util/data-url" import { decodeDataUrl } from "@/util/data-url"
// @ts-ignore // @ts-ignore
+1 -1
View File
@@ -2,7 +2,7 @@ import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlit
import { ProjectTable } from "../project/project.sql" import { ProjectTable } from "../project/project.sql"
import type { MessageV2 } from "./message-v2" import type { MessageV2 } from "./message-v2"
import type { Snapshot } from "../snapshot" import type { Snapshot } from "../snapshot"
import type { PermissionNext } from "../permission" import type { PermissionNext } from "../permission/next"
import type { ProjectID } from "../project/schema" import type { ProjectID } from "../project/schema"
import type { SessionID, MessageID, PartID } from "./schema" import type { SessionID, MessageID, PartID } from "./schema"
import type { WorkspaceID } from "../control-plane/schema" import type { WorkspaceID } from "../control-plane/schema"
+1 -1
View File
@@ -11,7 +11,7 @@ import PROMPT_CODEX from "./prompt/codex_header.txt"
import PROMPT_TRINITY from "./prompt/trinity.txt" import PROMPT_TRINITY from "./prompt/trinity.txt"
import type { Provider } from "@/provider/provider" import type { Provider } from "@/provider/provider"
import type { Agent } from "@/agent/agent" import type { Agent } from "@/agent/agent"
import { PermissionNext } from "@/permission" import { PermissionNext } from "@/permission/next"
import { Skill } from "@/skill" import { Skill } from "@/skill"
export namespace SystemPrompt { export namespace SystemPrompt {
+21 -22
View File
@@ -1,32 +1,33 @@
import { NodeFileSystem, NodePath } from "@effect/platform-node" import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect" import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { Global } from "../global" import { Global } from "../global"
import { Log } from "../util/log" import { Log } from "../util/log"
import { withTransientReadRetry } from "@/util/effect-http-client"
export namespace Discovery { class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
const skillConcurrency = 4
const fileConcurrency = 8
class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
name: Schema.String, name: Schema.String,
files: Schema.Array(Schema.String), files: Schema.Array(Schema.String),
}) {} }) {}
class Index extends Schema.Class<Index>("Index")({ class Index extends Schema.Class<Index>("Index")({
skills: Schema.Array(IndexSkill), skills: Schema.Array(IndexSkill),
}) {} }) {}
export interface Interface { const skillConcurrency = 4
const fileConcurrency = 8
export namespace DiscoveryService {
export interface Service {
readonly pull: (url: string) => Effect.Effect<string[]> readonly pull: (url: string) => Effect.Effect<string[]>
} }
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SkillDiscovery") {} export class DiscoveryService extends ServiceMap.Service<DiscoveryService, DiscoveryService.Service>()(
"@opencode/SkillDiscovery",
export const layer: Layer.Layer<Service, never, FileSystem.FileSystem | Path.Path | HttpClient.HttpClient> = ) {
Layer.effect( static readonly layer = Layer.effect(
Service, DiscoveryService,
Effect.gen(function* () { Effect.gen(function* () {
const log = Log.create({ service: "skill-discovery" }) const log = Log.create({ service: "skill-discovery" })
const fs = yield* FileSystem.FileSystem const fs = yield* FileSystem.FileSystem
@@ -34,7 +35,7 @@ export namespace Discovery {
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const cache = path.join(Global.Path.cache, "skills") const cache = path.join(Global.Path.cache, "skills")
const download = Effect.fn("Discovery.download")(function* (url: string, dest: string) { const download = Effect.fn("DiscoveryService.download")(function* (url: string, dest: string) {
if (yield* fs.exists(dest).pipe(Effect.orDie)) return true if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
return yield* HttpClientRequest.get(url).pipe( return yield* HttpClientRequest.get(url).pipe(
@@ -55,7 +56,7 @@ export namespace Discovery {
) )
}) })
const pull = Effect.fn("Discovery.pull")(function* (url: string) { const pull: DiscoveryService.Service["pull"] = Effect.fn("DiscoveryService.pull")(function* (url: string) {
const base = url.endsWith("/") ? url : `${url}/` const base = url.endsWith("/") ? url : `${url}/`
const index = new URL("index.json", base).href const index = new URL("index.json", base).href
const host = base.slice(0, -1) const host = base.slice(0, -1)
@@ -93,9 +94,7 @@ export namespace Discovery {
yield* Effect.forEach( yield* Effect.forEach(
skill.files, skill.files,
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)), (file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
{ { concurrency: fileConcurrency },
concurrency: fileConcurrency,
},
) )
const md = path.join(root, "SKILL.md") const md = path.join(root, "SKILL.md")
@@ -107,11 +106,11 @@ export namespace Discovery {
return dirs.filter((dir): dir is string => dir !== null) return dirs.filter((dir): dir is string => dir !== null)
}) })
return Service.of({ pull }) return DiscoveryService.of({ pull })
}), }),
) )
export const defaultLayer: Layer.Layer<Service> = layer.pipe( static readonly defaultLayer = DiscoveryService.layer.pipe(
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(NodeFileSystem.layer), Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer), Layer.provide(NodePath.layer),
+161 -149
View File
@@ -1,30 +1,34 @@
import os from "os"
import path from "path"
import { pathToFileURL } from "url"
import z from "zod" import z from "zod"
import { Effect, Layer, ServiceMap } from "effect" import path from "path"
import { NamedError } from "@opencode-ai/util/error" import os from "os"
import type { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
import { InstanceContext } from "@/effect/instance-context"
import { runPromiseInstance } from "@/effect/runtime"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
import { PermissionNext } from "@/permission"
import { Filesystem } from "@/util/filesystem"
import { Config } from "../config/config" import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { NamedError } from "@opencode-ai/util/error"
import { ConfigMarkdown } from "../config/markdown" import { ConfigMarkdown } from "../config/markdown"
import { Glob } from "../util/glob"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Discovery } from "./discovery" import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { Flag } from "@/flag/flag"
import { Bus } from "@/bus"
import { DiscoveryService } from "./discovery"
import { Glob } from "../util/glob"
import { pathToFileURL } from "url"
import type { Agent } from "@/agent/agent"
import { PermissionNext } from "@/permission/next"
import { InstanceContext } from "@/effect/instance-context"
import { Effect, Layer, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
const log = Log.create({ service: "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_PATTERN = "skills/**/SKILL.md"
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
const SKILL_PATTERN = "**/SKILL.md"
export namespace Skill { export namespace Skill {
const log = Log.create({ service: "skill" })
const EXTERNAL_DIRS = [".claude", ".agents"]
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
const SKILL_PATTERN = "**/SKILL.md"
export const Info = z.object({ export const Info = z.object({
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
@@ -51,24 +55,64 @@ export namespace Skill {
}), }),
) )
type State = { export async function get(name: string) {
skills: Record<string, Info> return runPromiseInstance(SkillService.use((s) => s.get(name)))
dirs: Set<string>
task?: Promise<void>
} }
type Cache = State & { export async function all() {
ensure: () => Promise<void> return runPromiseInstance(SkillService.use((s) => s.all()))
} }
export interface Interface { export async function dirs() {
readonly get: (name: string) => Effect.Effect<Info | undefined> return runPromiseInstance(SkillService.use((s) => s.dirs()))
readonly all: () => Effect.Effect<Info[]> }
export async function available(agent?: Agent.Info) {
return runPromiseInstance(SkillService.use((s) => s.available(agent)))
}
export function fmt(list: Info[], opts: { verbose: boolean }) {
if (list.length === 0) {
return "No skills are currently available."
}
if (opts.verbose) {
return [
"<available_skills>",
...list.flatMap((skill) => [
` <skill>`,
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
` </skill>`,
]),
"</available_skills>",
].join("\n")
}
return ["## Available Skills", ...list.flatMap((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
}
}
export namespace SkillService {
export interface Service {
readonly get: (name: string) => Effect.Effect<Skill.Info | undefined>
readonly all: () => Effect.Effect<Skill.Info[]>
readonly dirs: () => Effect.Effect<string[]> readonly dirs: () => Effect.Effect<string[]>
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]> readonly available: (agent?: Agent.Info) => Effect.Effect<Skill.Info[]>
} }
}
const add = async (state: State, match: string) => { export class SkillService extends ServiceMap.Service<SkillService, SkillService.Service>()("@opencode/Skill") {
static readonly layer = Layer.effect(
SkillService,
Effect.gen(function* () {
const instance = yield* InstanceContext
const discovery = yield* DiscoveryService
const skills: Record<string, Skill.Info> = {}
const skillDirs = new Set<string>()
let task: Promise<void> | undefined
const addSkill = async (match: string) => {
const md = await ConfigMarkdown.parse(match).catch(async (err) => { const md = await ConfigMarkdown.parse(match).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err) const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message ? err.data.message
@@ -81,19 +125,21 @@ export namespace Skill {
if (!md) return if (!md) return
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data) const parsed = Skill.Info.pick({ name: true, description: true }).safeParse(md.data)
if (!parsed.success) return if (!parsed.success) return
if (state.skills[parsed.data.name]) { // Warn on duplicate skill names
if (skills[parsed.data.name]) {
log.warn("duplicate skill name", { log.warn("duplicate skill name", {
name: parsed.data.name, name: parsed.data.name,
existing: state.skills[parsed.data.name].location, existing: skills[parsed.data.name].location,
duplicate: match, duplicate: match,
}) })
} }
state.dirs.add(path.dirname(match)) skillDirs.add(path.dirname(match))
state.skills[parsed.data.name] = {
skills[parsed.data.name] = {
name: parsed.data.name, name: parsed.data.name,
description: parsed.data.description, description: parsed.data.description,
location: match, location: match,
@@ -101,34 +147,30 @@ export namespace Skill {
} }
} }
const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => { const scanExternal = async (root: string, scope: "global" | "project") => {
return Glob.scan(pattern, { return Glob.scan(EXTERNAL_SKILL_PATTERN, {
cwd: root, cwd: root,
absolute: true, absolute: true,
include: "file", include: "file",
dot: true,
symlink: true, symlink: true,
dot: opts?.dot,
}) })
.then((matches) => Promise.all(matches.map((match) => add(state, match)))) .then((matches) => Promise.all(matches.map(addSkill)))
.catch((error) => { .catch((error) => {
if (!opts?.scope) throw error log.error(`failed to scan ${scope} skills`, { dir: root, error })
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
}) })
} }
// TODO: Migrate to Effect function ensureScanned() {
const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => { if (task) return task
const state: State = { task = (async () => {
skills: {}, // Scan external skill directories (.claude/skills/, .agents/skills/, etc.)
dirs: new Set<string>(), // Load global (home) first, then project-level (so project-level overwrites)
}
const load = async () => {
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) { if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
for (const dir of EXTERNAL_DIRS) { for (const dir of EXTERNAL_DIRS) {
const root = path.join(Global.Path.home, dir) const root = path.join(Global.Path.home, dir)
if (!(await Filesystem.isDir(root))) continue if (!(await Filesystem.isDir(root))) continue
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" }) await scanExternal(root, "global")
} }
for await (const root of Filesystem.up({ for await (const root of Filesystem.up({
@@ -136,120 +178,90 @@ export namespace Skill {
start: instance.directory, start: instance.directory,
stop: instance.project.worktree, stop: instance.project.worktree,
})) { })) {
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" }) await scanExternal(root, "project")
} }
} }
// Scan .opencode/skill/ directories
for (const dir of await Config.directories()) { for (const dir of await Config.directories()) {
await scan(state, dir, OPENCODE_SKILL_PATTERN) const matches = await Glob.scan(OPENCODE_SKILL_PATTERN, {
cwd: dir,
absolute: true,
include: "file",
symlink: true,
})
for (const match of matches) {
await addSkill(match)
}
} }
const cfg = await Config.get() // Scan additional skill paths from config
for (const item of cfg.skills?.paths ?? []) { const config = await Config.get()
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item for (const skillPath of config.skills?.paths ?? []) {
const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded) const expanded = skillPath.startsWith("~/") ? path.join(os.homedir(), skillPath.slice(2)) : skillPath
if (!(await Filesystem.isDir(dir))) { const resolved = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
log.warn("skill path not found", { path: dir }) if (!(await Filesystem.isDir(resolved))) {
log.warn("skill path not found", { path: resolved })
continue continue
} }
const matches = await Glob.scan(SKILL_PATTERN, {
await scan(state, dir, SKILL_PATTERN) cwd: resolved,
} absolute: true,
include: "file",
for (const url of cfg.skills?.urls ?? []) { symlink: true,
for (const dir of await Effect.runPromise(discovery.pull(url))) { })
state.dirs.add(dir) for (const match of matches) {
await scan(state, dir, SKILL_PATTERN) await addSkill(match)
} }
} }
log.info("init", { count: Object.keys(state.skills).length }) // Download and load skills from URLs
for (const url of config.skills?.urls ?? []) {
const list = await Effect.runPromise(discovery.pull(url))
for (const dir of list) {
skillDirs.add(dir)
const matches = await Glob.scan(SKILL_PATTERN, {
cwd: dir,
absolute: true,
include: "file",
symlink: true,
})
for (const match of matches) {
await addSkill(match)
}
}
} }
const ensure = () => { log.info("init", { count: Object.keys(skills).length })
if (state.task) return state.task })().catch((err) => {
state.task = load().catch((err) => { task = undefined
state.task = undefined
throw err throw err
}) })
return state.task return task
} }
return { ...state, ensure } return SkillService.of({
} get: Effect.fn("SkillService.get")(function* (name: string) {
yield* Effect.promise(() => ensureScanned())
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {} return skills[name]
export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
const discovery = yield* Discovery.Service
const state = create(instance, discovery)
const get = Effect.fn("Skill.get")(function* (name: string) {
yield* Effect.promise(() => state.ensure())
return state.skills[name]
})
const all = Effect.fn("Skill.all")(function* () {
yield* Effect.promise(() => state.ensure())
return Object.values(state.skills)
})
const dirs = Effect.fn("Skill.dirs")(function* () {
yield* Effect.promise(() => state.ensure())
return Array.from(state.dirs)
})
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
yield* Effect.promise(() => state.ensure())
const list = Object.values(state.skills)
if (!agent) return list
return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
})
return Service.of({ get, all, dirs, available })
}), }),
all: Effect.fn("SkillService.all")(function* () {
yield* Effect.promise(() => ensureScanned())
return Object.values(skills)
}),
dirs: Effect.fn("SkillService.dirs")(function* () {
yield* Effect.promise(() => ensureScanned())
return Array.from(skillDirs)
}),
available: Effect.fn("SkillService.available")(function* (agent?: Agent.Info) {
yield* Effect.promise(() => ensureScanned())
const list = Object.values(skills)
if (!agent) return list
return list.filter(
(skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny",
) )
}),
export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = layer.pipe( })
Layer.provide(Discovery.defaultLayer), }),
) ).pipe(Layer.provide(DiscoveryService.defaultLayer))
export async function get(name: string) {
return runPromiseInstance(Service.use((skill) => skill.get(name)))
}
export async function all() {
return runPromiseInstance(Service.use((skill) => skill.all()))
}
export async function dirs() {
return runPromiseInstance(Service.use((skill) => skill.dirs()))
}
export async function available(agent?: Agent.Info) {
return runPromiseInstance(Service.use((skill) => skill.available(agent)))
}
export function fmt(list: Info[], opts: { verbose: boolean }) {
if (list.length === 0) return "No skills are currently available."
if (opts.verbose) {
return [
"<available_skills>",
...list.flatMap((skill) => [
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
" </skill>",
]),
"</available_skills>",
].join("\n")
}
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
}
} }
+370 -303
View File
@@ -1,21 +1,257 @@
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path" import path from "path"
import z from "zod" import fs from "fs/promises"
import { InstanceContext } from "@/effect/instance-context" import { Filesystem } from "../util/filesystem"
import { runPromiseInstance } from "@/effect/runtime"
import { Config } from "../config/config"
import { Global } from "../global"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Flag } from "../flag/flag"
import { Global } from "../global"
import z from "zod"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { Scheduler } from "../scheduler"
import { Process } from "@/util/process"
export namespace Snapshot { export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
const hour = 60 * 60 * 1000
const prune = "7.days"
function args(git: string, cmd: string[]) {
return ["--git-dir", git, "--work-tree", Instance.worktree, ...cmd]
}
export function init() {
Scheduler.register({
id: "snapshot.cleanup",
interval: hour,
run: cleanup,
scope: "instance",
})
}
export async function cleanup() {
if (Instance.project.vcs !== "git") return
const cfg = await Config.get()
if (cfg.snapshot === false) return
const git = gitdir()
const exists = await fs
.stat(git)
.then(() => true)
.catch(() => false)
if (!exists) return
const result = await Process.run(["git", ...args(git, ["gc", `--prune=${prune}`])], {
cwd: Instance.directory,
nothrow: true,
})
if (result.code !== 0) {
log.warn("cleanup failed", {
exitCode: result.code,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
return
}
log.info("cleanup", { prune })
}
export async function track() {
if (Instance.project.vcs !== "git") return
const cfg = await Config.get()
if (cfg.snapshot === false) return
const git = gitdir()
if (await fs.mkdir(git, { recursive: true })) {
await Process.run(["git", "init"], {
env: {
...process.env,
GIT_DIR: git,
GIT_WORK_TREE: Instance.worktree,
},
nothrow: true,
})
// Configure git to not convert line endings on Windows
await Process.run(["git", "--git-dir", git, "config", "core.autocrlf", "false"], { nothrow: true })
await Process.run(["git", "--git-dir", git, "config", "core.longpaths", "true"], { nothrow: true })
await Process.run(["git", "--git-dir", git, "config", "core.symlinks", "true"], { nothrow: true })
await Process.run(["git", "--git-dir", git, "config", "core.fsmonitor", "false"], { nothrow: true })
log.info("initialized")
}
await add(git)
const hash = await Process.text(["git", ...args(git, ["write-tree"])], {
cwd: Instance.directory,
nothrow: true,
}).then((x) => x.text)
log.info("tracking", { hash, cwd: Instance.directory, git })
return hash.trim()
}
export const Patch = z.object({ export const Patch = z.object({
hash: z.string(), hash: z.string(),
files: z.string().array(), files: z.string().array(),
}) })
export type Patch = z.infer<typeof Patch> export type Patch = z.infer<typeof Patch>
export async function patch(hash: string): Promise<Patch> {
const git = gitdir()
await add(git)
const result = await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--name-only", hash, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)
// If git diff fails, return empty patch
if (result.code !== 0) {
log.warn("failed to get diff", { hash, exitCode: result.code })
return { hash, files: [] }
}
const files = result.text
return {
hash,
files: files
.trim()
.split("\n")
.map((x) => x.trim())
.filter(Boolean)
.map((x) => path.join(Instance.worktree, x).replaceAll("\\", "/")),
}
}
export async function restore(snapshot: string) {
log.info("restore", { commit: snapshot })
const git = gitdir()
const result = await Process.run(
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["read-tree", snapshot])],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (result.code === 0) {
const checkout = await Process.run(
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["checkout-index", "-a", "-f"])],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (checkout.code === 0) return
log.error("failed to restore snapshot", {
snapshot,
exitCode: checkout.code,
stderr: checkout.stderr.toString(),
stdout: checkout.stdout.toString(),
})
return
}
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.code,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
}
export async function revert(patches: Patch[]) {
const files = new Set<string>()
const git = gitdir()
for (const item of patches) {
for (const file of item.files) {
if (files.has(file)) continue
log.info("reverting", { file, hash: item.hash })
const result = await Process.run(
[
"git",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["checkout", item.hash, "--", file]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (result.code !== 0) {
const relativePath = path.relative(Instance.worktree, file)
const checkTree = await Process.text(
[
"git",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["ls-tree", item.hash, "--", relativePath]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (checkTree.code === 0 && checkTree.text.trim()) {
log.info("file existed in snapshot but checkout failed, keeping", {
file,
})
} else {
log.info("file did not exist in snapshot, deleting", { file })
await fs.unlink(file).catch(() => {})
}
}
files.add(file)
}
}
}
export async function diff(hash: string) {
const git = gitdir()
await add(git)
const result = await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", hash, "--", "."]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (result.code !== 0) {
log.warn("failed to get diff", {
hash,
exitCode: result.code,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
return ""
}
return result.text.trim()
}
export const FileDiff = z export const FileDiff = z
.object({ .object({
file: z.string(), file: z.string(),
@@ -29,321 +265,152 @@ export namespace Snapshot {
ref: "FileDiff", ref: "FileDiff",
}) })
export type FileDiff = z.infer<typeof FileDiff> export type FileDiff = z.infer<typeof FileDiff>
export async function diffFull(from: string, to: string): Promise<FileDiff[]> {
export async function cleanup() { const git = gitdir()
return runPromiseInstance(Service.use((svc) => svc.cleanup())) const result: FileDiff[] = []
}
export async function track() {
return runPromiseInstance(Service.use((svc) => svc.track()))
}
export async function patch(hash: string) {
return runPromiseInstance(Service.use((svc) => svc.patch(hash)))
}
export async function restore(snapshot: string) {
return runPromiseInstance(Service.use((svc) => svc.restore(snapshot)))
}
export async function revert(patches: Patch[]) {
return runPromiseInstance(Service.use((svc) => svc.revert(patches)))
}
export async function diff(hash: string) {
return runPromiseInstance(Service.use((svc) => svc.diff(hash)))
}
export async function diffFull(from: string, to: string) {
return runPromiseInstance(Service.use((svc) => svc.diffFull(from, to)))
}
const log = Log.create({ service: "snapshot" })
const prune = "7.days"
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
const cfg = ["-c", "core.autocrlf=false", ...core]
const quote = [...cfg, "-c", "core.quotepath=false"]
interface GitResult {
readonly code: ChildProcessSpawner.ExitCode
readonly text: string
readonly stderr: string
}
export interface Interface {
readonly cleanup: () => Effect.Effect<void>
readonly track: () => Effect.Effect<string | undefined>
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
readonly restore: (snapshot: string) => Effect.Effect<void>
readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>
readonly diff: (hash: string) => Effect.Effect<string>
readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
export const layer: Layer.Layer<
Service,
never,
InstanceContext | FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner
> = Layer.effect(
Service,
Effect.gen(function* () {
const ctx = yield* InstanceContext
const fs = yield* FileSystem.FileSystem
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const directory = ctx.directory
const worktree = ctx.worktree
const project = ctx.project
const gitdir = path.join(Global.Path.data, "snapshot", project.id)
const args = (cmd: string[]) => ["--git-dir", gitdir, "--work-tree", worktree, ...cmd]
const git = Effect.fnUntraced(
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
const proc = ChildProcess.make("git", cmd, {
cwd: opts?.cwd,
env: opts?.env,
extendEnv: true,
})
const handle = yield* spawner.spawn(proc)
const [text, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
return { code, text, stderr } satisfies GitResult
},
Effect.scoped,
Effect.catch((err) =>
Effect.succeed({
code: ChildProcessSpawner.ExitCode(1),
text: "",
stderr: String(err),
}),
),
)
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
const mkdir = (dir: string) => fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
const write = (file: string, text: string) => fs.writeFileString(file, text).pipe(Effect.orDie)
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
const enabled = Effect.fnUntraced(function* () {
if (project.vcs !== "git") return false
return (yield* Effect.promise(() => Config.get())).snapshot !== false
})
const excludes = Effect.fnUntraced(function* () {
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
cwd: worktree,
})
const file = result.text.trim()
if (!file) return
if (!(yield* exists(file))) return
return file
})
const sync = Effect.fnUntraced(function* () {
const file = yield* excludes()
const target = path.join(gitdir, "info", "exclude")
yield* mkdir(path.join(gitdir, "info"))
if (!file) {
yield* write(target, "")
return
}
yield* write(target, yield* read(file))
})
const add = Effect.fnUntraced(function* () {
yield* sync()
yield* git([...cfg, ...args(["add", "."])], { cwd: directory })
})
const cleanup = Effect.fn("Snapshot.cleanup")(function* () {
if (!(yield* enabled())) return
if (!(yield* exists(gitdir))) return
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: directory })
if (result.code !== 0) {
log.warn("cleanup failed", {
exitCode: result.code,
stderr: result.stderr,
})
return
}
log.info("cleanup", { prune })
})
const track = Effect.fn("Snapshot.track")(function* () {
if (!(yield* enabled())) return
const existed = yield* exists(gitdir)
yield* mkdir(gitdir)
if (!existed) {
yield* git(["init"], {
env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree },
})
yield* git(["--git-dir", gitdir, "config", "core.autocrlf", "false"])
yield* git(["--git-dir", gitdir, "config", "core.longpaths", "true"])
yield* git(["--git-dir", gitdir, "config", "core.symlinks", "true"])
yield* git(["--git-dir", gitdir, "config", "core.fsmonitor", "false"])
log.info("initialized")
}
yield* add()
const result = yield* git(args(["write-tree"]), { cwd: directory })
const hash = result.text.trim()
log.info("tracking", { hash, cwd: directory, git: gitdir })
return hash
})
const patch = Effect.fn("Snapshot.patch")(function* (hash: string) {
yield* add()
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], {
cwd: directory,
})
if (result.code !== 0) {
log.warn("failed to get diff", { hash, exitCode: result.code })
return { hash, files: [] }
}
return {
hash,
files: result.text
.trim()
.split("\n")
.map((x) => x.trim())
.filter(Boolean)
.map((x) => path.join(worktree, x).replaceAll("\\", "/")),
}
})
const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) {
log.info("restore", { commit: snapshot })
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: worktree })
if (result.code === 0) {
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: worktree })
if (checkout.code === 0) return
log.error("failed to restore snapshot", {
snapshot,
exitCode: checkout.code,
stderr: checkout.stderr,
})
return
}
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.code,
stderr: result.stderr,
})
})
const revert = Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
const map = new Map(patches.flatMap((patch) => patch.files.map((file) => [file, patch] as const)))
const seen = new Set<string>()
for (const file of patches.flatMap((patch) => patch.files)) {
if (seen.has(file)) continue
const patch = map.get(file)
if (!patch) continue
log.info("reverting", { file, hash: patch.hash })
const result = yield* git([...core, ...args(["checkout", patch.hash, "--", file])], { cwd: worktree })
if (result.code !== 0) {
const rel = path.relative(worktree, file)
const tree = yield* git([...core, ...args(["ls-tree", patch.hash, "--", rel])], { cwd: worktree })
if (tree.code === 0 && tree.text.trim()) {
log.info("file existed in snapshot but checkout failed, keeping", { file })
} else {
log.info("file did not exist in snapshot, deleting", { file })
yield* remove(file)
}
}
seen.add(file)
}
})
const diff = Effect.fn("Snapshot.diff")(function* (hash: string) {
yield* add()
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
cwd: worktree,
})
if (result.code !== 0) {
log.warn("failed to get diff", {
hash,
exitCode: result.code,
stderr: result.stderr,
})
return ""
}
return result.text.trim()
})
const diffFull = Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
const result: Snapshot.FileDiff[] = []
const status = new Map<string, "added" | "deleted" | "modified">() const status = new Map<string, "added" | "deleted" | "modified">()
const statuses = yield* git( const statuses = await Process.text(
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])], [
{ cwd: directory }, "git",
) "-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
).then((x) => x.text)
for (const line of statuses.text.trim().split("\n")) { for (const line of statuses.trim().split("\n")) {
if (!line) continue if (!line) continue
const [code, file] = line.split("\t") const [code, file] = line.split("\t")
if (!code || !file) continue if (!code || !file) continue
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified") const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified"
status.set(file, kind)
} }
const numstat = yield* git( for (const line of await Process.lines(
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
{
cwd: directory,
},
)
for (const line of numstat.text.trim().split("\n")) {
if (!line) continue
const [adds, dels, file] = line.split("\t")
if (!file) continue
const binary = adds === "-" && dels === "-"
const [before, after] = binary
? ["", ""]
: yield* Effect.all(
[ [
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)), "git",
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)), "-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."]),
], ],
{ concurrency: 2 }, {
) cwd: Instance.directory,
const additions = binary ? 0 : parseInt(adds) nothrow: true,
const deletions = binary ? 0 : parseInt(dels) },
)) {
if (!line) continue
const [additions, deletions, file] = line.split("\t")
const isBinaryFile = additions === "-" && deletions === "-"
const before = isBinaryFile
? ""
: await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["show", `${from}:${file}`]),
],
{ nothrow: true },
).then((x) => x.text)
const after = isBinaryFile
? ""
: await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["show", `${to}:${file}`]),
],
{ nothrow: true },
).then((x) => x.text)
const added = isBinaryFile ? 0 : parseInt(additions)
const deleted = isBinaryFile ? 0 : parseInt(deletions)
result.push({ result.push({
file, file,
before, before,
after, after,
additions: Number.isFinite(additions) ? additions : 0, additions: Number.isFinite(added) ? added : 0,
deletions: Number.isFinite(deletions) ? deletions : 0, deletions: Number.isFinite(deleted) ? deleted : 0,
status: status.get(file) ?? "modified", status: status.get(file) ?? "modified",
}) })
} }
return result return result
}) }
yield* cleanup().pipe( function gitdir() {
Effect.catchCause((cause) => { const project = Instance.project
log.error("cleanup loop failed", { cause: Cause.pretty(cause) }) return path.join(Global.Path.data, "snapshot", project.id)
return Effect.void }
}),
Effect.repeat(Schedule.spaced(Duration.hours(1))),
Effect.delay(Duration.minutes(1)),
Effect.forkScoped,
)
return Service.of({ cleanup, track, patch, restore, revert, diff, diffFull }) async function add(git: string) {
}), await syncExclude(git)
await Process.run(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["add", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
) )
}
export const defaultLayer = layer.pipe( async function syncExclude(git: string) {
Layer.provide(NodeChildProcessSpawner.layer), const file = await excludes()
Layer.provide(NodeFileSystem.layer), const target = path.join(git, "info", "exclude")
Layer.provide(NodePath.layer), await fs.mkdir(path.join(git, "info"), { recursive: true })
) if (!file) {
await Filesystem.write(target, "")
return
}
const text = await Filesystem.readText(file).catch(() => "")
await Filesystem.write(target, text)
}
async function excludes() {
const file = await Process.text(["git", "rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
cwd: Instance.worktree,
nothrow: true,
}).then((x) => x.text)
if (!file.trim()) return
const exists = await fs
.stat(file.trim())
.then(() => true)
.catch(() => false)
if (!exists) return
return file.trim()
}
} }
+1 -1
View File
@@ -15,7 +15,7 @@ import { Flag } from "@/flag/flag.ts"
import { Shell } from "@/shell/shell" import { Shell } from "@/shell/shell"
import { BashArity } from "@/permission/arity" import { BashArity } from "@/permission/arity"
import { Truncate } from "./truncate" import { Truncate } from "./truncation"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
const MAX_METADATA_LENGTH = 30_000 const MAX_METADATA_LENGTH = 30_000
+1 -1
View File
@@ -26,7 +26,7 @@ import { CodeSearchTool } from "./codesearch"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { LspTool } from "./lsp" import { LspTool } from "./lsp"
import { Truncate } from "./truncate" import { Truncate } from "./truncation"
import { ApplyPatchTool } from "./apply_patch" import { ApplyPatchTool } from "./apply_patch"
import { Glob } from "../util/glob" import { Glob } from "../util/glob"
+1 -1
View File
@@ -10,7 +10,7 @@ import { SessionPrompt } from "../session/prompt"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { defer } from "@/util/defer" import { defer } from "@/util/defer"
import { Config } from "../config/config" import { Config } from "../config/config"
import { PermissionNext } from "@/permission" import { PermissionNext } from "@/permission/next"
const parameters = z.object({ const parameters = z.object({
description: z.string().describe("A short (3-5 words) description of the task"), description: z.string().describe("A short (3-5 words) description of the task"),
+2 -2
View File
@@ -1,9 +1,9 @@
import z from "zod" import z from "zod"
import type { MessageV2 } from "../session/message-v2" import type { MessageV2 } from "../session/message-v2"
import type { Agent } from "../agent/agent" import type { Agent } from "../agent/agent"
import type { PermissionNext } from "../permission" import type { PermissionNext } from "../permission/next"
import type { SessionID, MessageID } from "../session/schema" import type { SessionID, MessageID } from "../session/schema"
import { Truncate } from "./truncate" import { Truncate } from "./truncation"
export namespace Tool { export namespace Tool {
interface Metadata { interface Metadata {
@@ -1,140 +0,0 @@
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap } from "effect"
import path from "path"
import type { Agent } from "../agent/agent"
import { PermissionEffect } from "../permission/effect"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
import { ToolID } from "./schema"
import { TRUNCATION_DIR } from "./truncation-dir"
export namespace TruncateEffect {
const log = Log.create({ service: "truncation" })
const RETENTION = Duration.days(7)
export const MAX_LINES = 2000
export const MAX_BYTES = 50 * 1024
export const DIR = TRUNCATION_DIR
export const GLOB = path.join(TRUNCATION_DIR, "*")
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
export interface Options {
maxLines?: number
maxBytes?: number
direction?: "head" | "tail"
}
function hasTaskTool(agent?: Agent.Info) {
if (!agent?.permission) return false
return PermissionEffect.evaluate("task", "*", agent.permission).action !== "deny"
}
export interface Interface {
readonly cleanup: () => Effect.Effect<void>
/**
* Returns output unchanged when it fits within the limits, otherwise writes the full text
* to the truncation directory and returns a preview plus a hint to inspect the saved file.
*/
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Truncate") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
Effect.catch(() => Effect.succeed([])),
)
for (const entry of entries) {
if (Identifier.timestamp(entry) >= cutoff) continue
yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
}
})
const output = Effect.fn("Truncate.output")(function* (
text: string,
options: Options = {},
agent?: Agent.Info,
) {
const maxLines = options.maxLines ?? MAX_LINES
const maxBytes = options.maxBytes ?? MAX_BYTES
const direction = options.direction ?? "head"
const lines = text.split("\n")
const totalBytes = Buffer.byteLength(text, "utf-8")
if (lines.length <= maxLines && totalBytes <= maxBytes) {
return { content: text, truncated: false } as const
}
const out: string[] = []
let i = 0
let bytes = 0
let hitBytes = false
if (direction === "head") {
for (i = 0; i < lines.length && i < maxLines; i++) {
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
out.push(lines[i])
bytes += size
}
} else {
for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
out.unshift(lines[i])
bytes += size
}
}
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
const unit = hitBytes ? "bytes" : "lines"
const preview = out.join("\n")
const file = path.join(TRUNCATION_DIR, ToolID.ascending())
yield* fs.makeDirectory(TRUNCATION_DIR, { recursive: true }).pipe(Effect.orDie)
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
const hint = hasTaskTool(agent)
? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\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.`
: `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
return {
content:
direction === "head"
? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
: `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
truncated: true,
outputPath: file,
} as const
})
yield* cleanup().pipe(
Effect.catchCause((cause) => {
log.error("truncation cleanup failed", { cause: Cause.pretty(cause) })
return Effect.void
}),
Effect.repeat(Schedule.spaced(Duration.hours(1))),
Effect.delay(Duration.minutes(1)),
Effect.forkScoped,
)
return Service.of({ cleanup, output })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
}
-19
View File
@@ -1,19 +0,0 @@
import type { Agent } from "../agent/agent"
import { runtime } from "@/effect/runtime"
import { TruncateEffect as S } from "./truncate-effect"
export namespace Truncate {
export const MAX_LINES = S.MAX_LINES
export const MAX_BYTES = S.MAX_BYTES
export const DIR = S.DIR
export const GLOB = S.GLOB
export type Result = S.Result
export type Options = S.Options
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
return runtime.runPromise(S.Service.use((s) => s.output(text, options, agent)))
}
}
@@ -1,4 +0,0 @@
import path from "path"
import { Global } from "../global"
export const TRUNCATION_DIR = path.join(Global.Path.data, "tool-output")
+108
View File
@@ -0,0 +1,108 @@
import fs from "fs/promises"
import path from "path"
import { Global } from "../global"
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"
import { ToolID } from "./schema"
export namespace Truncate {
export const MAX_LINES = 2000
export const MAX_BYTES = 50 * 1024
export const DIR = path.join(Global.Path.data, "tool-output")
export const GLOB = path.join(DIR, "*")
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
const HOUR_MS = 60 * 60 * 1000
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
export interface Options {
maxLines?: number
maxBytes?: number
direction?: "head" | "tail"
}
export function init() {
Scheduler.register({
id: "tool.truncation.cleanup",
interval: HOUR_MS,
run: cleanup,
scope: "global",
})
}
export async function cleanup() {
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - RETENTION_MS))
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(() => {})
}
}
function hasTaskTool(agent?: Agent.Info): boolean {
if (!agent?.permission) return false
const rule = PermissionNext.evaluate("task", "*", agent.permission)
return rule.action !== "deny"
}
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
const maxLines = options.maxLines ?? MAX_LINES
const maxBytes = options.maxBytes ?? MAX_BYTES
const direction = options.direction ?? "head"
const lines = text.split("\n")
const totalBytes = Buffer.byteLength(text, "utf-8")
if (lines.length <= maxLines && totalBytes <= maxBytes) {
return { content: text, truncated: false }
}
const out: string[] = []
let i = 0
let bytes = 0
let hitBytes = false
if (direction === "head") {
for (i = 0; i < lines.length && i < maxLines; i++) {
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
out.push(lines[i])
bytes += size
}
} else {
for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
out.unshift(lines[i])
bytes += size
}
}
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
const unit = hitBytes ? "bytes" : "lines"
const preview = out.join("\n")
const id = ToolID.ascending()
const filepath = path.join(DIR, id)
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.`
: `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
const message =
direction === "head"
? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
: `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`
return { content: message, truncated: true, outputPath: filepath }
}
}
-3
View File
@@ -3,7 +3,6 @@ import { buffer } from "node:stream/consumers"
export namespace Process { export namespace Process {
export type Stdio = "inherit" | "pipe" | "ignore" export type Stdio = "inherit" | "pipe" | "ignore"
export type Shell = boolean | string
export interface Options { export interface Options {
cwd?: string cwd?: string
@@ -11,7 +10,6 @@ export namespace Process {
stdin?: Stdio stdin?: Stdio
stdout?: Stdio stdout?: Stdio
stderr?: Stdio stderr?: Stdio
shell?: Shell
abort?: AbortSignal abort?: AbortSignal
kill?: NodeJS.Signals | number kill?: NodeJS.Signals | number
timeout?: number timeout?: number
@@ -62,7 +60,6 @@ export namespace Process {
cwd: opts.cwd, cwd: opts.cwd,
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined, env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"], stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
shell: opts.shell,
windowsHide: process.platform === "win32", windowsHide: process.platform === "win32",
}) })
+25 -13
View File
@@ -4,7 +4,7 @@ import { Effect, Layer, Option } from "effect"
import { AccountRepo } from "../../src/account/repo" import { AccountRepo } from "../../src/account/repo"
import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema" import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema"
import { Database } from "../../src/storage/db" import { Database } from "../../src/storage/db"
import { testEffect } from "../lib/effect" import { testEffect } from "../fixture/effect"
const truncate = Layer.effectDiscard( const truncate = Layer.effectDiscard(
Effect.sync(() => { Effect.sync(() => {
@@ -16,21 +16,24 @@ const truncate = Layer.effectDiscard(
const it = testEffect(Layer.merge(AccountRepo.layer, truncate)) const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
it.effect("list returns empty when no accounts exist", () => it.effect(
"list returns empty when no accounts exist",
Effect.gen(function* () { Effect.gen(function* () {
const accounts = yield* AccountRepo.use((r) => r.list()) const accounts = yield* AccountRepo.use((r) => r.list())
expect(accounts).toEqual([]) expect(accounts).toEqual([])
}), }),
) )
it.effect("active returns none when no accounts exist", () => it.effect(
"active returns none when no accounts exist",
Effect.gen(function* () { Effect.gen(function* () {
const active = yield* AccountRepo.use((r) => r.active()) const active = yield* AccountRepo.use((r) => r.active())
expect(Option.isNone(active)).toBe(true) expect(Option.isNone(active)).toBe(true)
}), }),
) )
it.effect("persistAccount inserts and getRow retrieves", () => it.effect(
"persistAccount inserts and getRow retrieves",
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
yield* AccountRepo.use((r) => yield* AccountRepo.use((r) =>
@@ -56,7 +59,8 @@ it.effect("persistAccount inserts and getRow retrieves", () =>
}), }),
) )
it.effect("persistAccount sets the active account and org", () => it.effect(
"persistAccount sets the active account and org",
Effect.gen(function* () { Effect.gen(function* () {
const id1 = AccountID.make("user-1") const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2") const id2 = AccountID.make("user-2")
@@ -93,7 +97,8 @@ it.effect("persistAccount sets the active account and org", () =>
}), }),
) )
it.effect("list returns all accounts", () => it.effect(
"list returns all accounts",
Effect.gen(function* () { Effect.gen(function* () {
const id1 = AccountID.make("user-1") const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2") const id2 = AccountID.make("user-2")
@@ -128,7 +133,8 @@ it.effect("list returns all accounts", () =>
}), }),
) )
it.effect("remove deletes an account", () => it.effect(
"remove deletes an account",
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
@@ -151,7 +157,8 @@ it.effect("remove deletes an account", () =>
}), }),
) )
it.effect("use stores the selected org and marks the account active", () => it.effect(
"use stores the selected org and marks the account active",
Effect.gen(function* () { Effect.gen(function* () {
const id1 = AccountID.make("user-1") const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2") const id2 = AccountID.make("user-2")
@@ -191,7 +198,8 @@ it.effect("use stores the selected org and marks the account active", () =>
}), }),
) )
it.effect("persistToken updates token fields", () => it.effect(
"persistToken updates token fields",
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
@@ -225,7 +233,8 @@ it.effect("persistToken updates token fields", () =>
}), }),
) )
it.effect("persistToken with no expiry sets token_expiry to null", () => it.effect(
"persistToken with no expiry sets token_expiry to null",
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
@@ -255,7 +264,8 @@ it.effect("persistToken with no expiry sets token_expiry to null", () =>
}), }),
) )
it.effect("persistAccount upserts on conflict", () => it.effect(
"persistAccount upserts on conflict",
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
@@ -295,7 +305,8 @@ it.effect("persistAccount upserts on conflict", () =>
}), }),
) )
it.effect("remove clears active state when deleting the active account", () => it.effect(
"remove clears active state when deleting the active account",
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
@@ -318,7 +329,8 @@ it.effect("remove clears active state when deleting the active account", () =>
}), }),
) )
it.effect("getRow returns none for nonexistent account", () => it.effect(
"getRow returns none for nonexistent account",
Effect.gen(function* () { Effect.gen(function* () {
const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope"))) const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope")))
expect(Option.isNone(row)).toBe(true) expect(Option.isNone(row)).toBe(true)
+25 -19
View File
@@ -1,12 +1,12 @@
import { expect } from "bun:test" import { expect } from "bun:test"
import { Duration, Effect, Layer, Option, Schema } from "effect" import { Duration, Effect, Layer, Option, Ref, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AccountRepo } from "../../src/account/repo" import { AccountRepo } from "../../src/account/repo"
import { AccountEffect } from "../../src/account/effect" import { AccountService } from "../../src/account/service"
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema" import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
import { Database } from "../../src/storage/db" import { Database } from "../../src/storage/db"
import { testEffect } from "../lib/effect" import { testEffect } from "../fixture/effect"
const truncate = Layer.effectDiscard( const truncate = Layer.effectDiscard(
Effect.sync(() => { Effect.sync(() => {
@@ -19,7 +19,7 @@ const truncate = Layer.effectDiscard(
const it = testEffect(Layer.merge(AccountRepo.layer, truncate)) const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
const live = (client: HttpClient.HttpClient) => const live = (client: HttpClient.HttpClient) =>
AccountEffect.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client))) AccountService.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) => const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
HttpClientResponse.fromWeb( HttpClientResponse.fromWeb(
@@ -34,7 +34,8 @@ const encodeOrg = Schema.encodeSync(Org)
const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name })) const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name }))
it.effect("orgsByAccount groups orgs per account", () => it.effect(
"orgsByAccount groups orgs per account",
Effect.gen(function* () { Effect.gen(function* () {
yield* AccountRepo.use((r) => yield* AccountRepo.use((r) =>
r.persistAccount({ r.persistAccount({
@@ -60,10 +61,10 @@ it.effect("orgsByAccount groups orgs per account", () =>
}), }),
) )
const seen: Array<string> = [] const seen = yield* Ref.make<string[]>([])
const client = HttpClient.make((req) => const client = HttpClient.make((req) =>
Effect.gen(function* () { Effect.gen(function* () {
seen.push(`${req.method} ${req.url}`) yield* Ref.update(seen, (xs) => [...xs, `${req.method} ${req.url}`])
if (req.url === "https://one.example.com/api/orgs") { if (req.url === "https://one.example.com/api/orgs") {
return json(req, [org("org-1", "One")]) return json(req, [org("org-1", "One")])
@@ -77,20 +78,21 @@ it.effect("orgsByAccount groups orgs per account", () =>
}), }),
) )
const rows = yield* AccountEffect.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client))) const rows = yield* AccountService.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([ expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
[AccountID.make("user-1"), [OrgID.make("org-1")]], [AccountID.make("user-1"), [OrgID.make("org-1")]],
[AccountID.make("user-2"), [OrgID.make("org-2"), OrgID.make("org-3")]], [AccountID.make("user-2"), [OrgID.make("org-2"), OrgID.make("org-3")]],
]) ])
expect(seen).toEqual([ expect(yield* Ref.get(seen)).toEqual([
"GET https://one.example.com/api/orgs", "GET https://one.example.com/api/orgs",
"GET https://two.example.com/api/orgs", "GET https://two.example.com/api/orgs",
]) ])
}), }),
) )
it.effect("token refresh persists the new token", () => it.effect(
"token refresh persists the new token",
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
@@ -118,7 +120,7 @@ it.effect("token refresh persists the new token", () =>
), ),
) )
const token = yield* AccountEffect.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client))) const token = yield* AccountService.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
expect(Option.getOrThrow(token)).toBeDefined() expect(Option.getOrThrow(token)).toBeDefined()
expect(String(Option.getOrThrow(token))).toBe("at_new") expect(String(Option.getOrThrow(token))).toBe("at_new")
@@ -131,7 +133,8 @@ it.effect("token refresh persists the new token", () =>
}), }),
) )
it.effect("config sends the selected org header", () => it.effect(
"config sends the selected org header",
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
@@ -147,11 +150,13 @@ it.effect("config sends the selected org header", () =>
}), }),
) )
const seen: { auth?: string; org?: string } = {} const seen = yield* Ref.make<{ auth?: string; org?: string }>({})
const client = HttpClient.make((req) => const client = HttpClient.make((req) =>
Effect.gen(function* () { Effect.gen(function* () {
seen.auth = req.headers.authorization yield* Ref.set(seen, {
seen.org = req.headers["x-org-id"] auth: req.headers.authorization,
org: req.headers["x-org-id"],
})
if (req.url === "https://one.example.com/api/config") { if (req.url === "https://one.example.com/api/config") {
return json(req, { config: { theme: "light", seats: 5 } }) return json(req, { config: { theme: "light", seats: 5 } })
@@ -161,17 +166,18 @@ it.effect("config sends the selected org header", () =>
}), }),
) )
const cfg = yield* AccountEffect.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client))) const cfg = yield* AccountService.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 }) expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
expect(seen).toEqual({ expect(yield* Ref.get(seen)).toEqual({
auth: "Bearer at_1", auth: "Bearer at_1",
org: "org-9", org: "org-9",
}) })
}), }),
) )
it.effect("poll stores the account and first org on success", () => it.effect(
"poll stores the account and first org on success",
Effect.gen(function* () { Effect.gen(function* () {
const login = new Login({ const login = new Login({
code: DeviceCode.make("device-code"), code: DeviceCode.make("device-code"),
@@ -199,7 +205,7 @@ it.effect("poll stores the account and first org on success", () =>
), ),
) )
const res = yield* AccountEffect.Service.use((s) => s.poll(login)).pipe(Effect.provide(live(client))) const res = yield* AccountService.use((s) => s.poll(login)).pipe(Effect.provide(live(client)))
expect(res._tag).toBe("PollSuccess") expect(res._tag).toBe("PollSuccess")
if (res._tag === "PollSuccess") { if (res._tag === "PollSuccess") {
+5 -5
View File
@@ -3,7 +3,7 @@ import path from "path"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { PermissionNext } from "../../src/permission" import { PermissionNext } from "../../src/permission/next"
// Helper to evaluate permission for a tool with wildcard pattern // Helper to evaluate permission for a tool with wildcard pattern
function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined { function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined {
@@ -76,7 +76,7 @@ test("explore agent denies edit and write", async () => {
}) })
test("explore agent asks for external directories and allows Truncate.GLOB", async () => { test("explore agent asks for external directories and allows Truncate.GLOB", async () => {
const { Truncate } = await import("../../src/tool/truncate") const { Truncate } = await import("../../src/tool/truncation")
await using tmp = await tmpdir() await using tmp = await tmpdir()
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
@@ -463,7 +463,7 @@ test("legacy tools config maps write/edit/patch/multiedit to edit permission", a
}) })
test("Truncate.GLOB is allowed even when user denies external_directory globally", async () => { test("Truncate.GLOB is allowed even when user denies external_directory globally", async () => {
const { Truncate } = await import("../../src/tool/truncate") const { Truncate } = await import("../../src/tool/truncation")
await using tmp = await tmpdir({ await using tmp = await tmpdir({
config: { config: {
permission: { permission: {
@@ -483,7 +483,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
}) })
test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => { test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => {
const { Truncate } = await import("../../src/tool/truncate") const { Truncate } = await import("../../src/tool/truncation")
await using tmp = await tmpdir({ await using tmp = await tmpdir({
config: { config: {
agent: { agent: {
@@ -507,7 +507,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen
}) })
test("explicit Truncate.GLOB deny is respected", async () => { test("explicit Truncate.GLOB deny is respected", async () => {
const { Truncate } = await import("../../src/tool/truncate") const { Truncate } = await import("../../src/tool/truncation")
await using tmp = await tmpdir({ await using tmp = await tmpdir({
config: { config: {
permission: { permission: {
+15 -7
View File
@@ -681,7 +681,9 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await File.init() File.init()
// Give the background scan time to populate
await new Promise((r) => setTimeout(r, 500))
const result = await File.search({ query: "", type: "file" }) const result = await File.search({ query: "", type: "file" })
expect(result.length).toBeGreaterThan(0) expect(result.length).toBeGreaterThan(0)
@@ -695,7 +697,8 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await File.init() File.init()
await new Promise((r) => setTimeout(r, 500))
const result = await File.search({ query: "", type: "directory" }) const result = await File.search({ query: "", type: "directory" })
expect(result.length).toBeGreaterThan(0) expect(result.length).toBeGreaterThan(0)
@@ -715,7 +718,8 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await File.init() File.init()
await new Promise((r) => setTimeout(r, 500))
const result = await File.search({ query: "main", type: "file" }) const result = await File.search({ query: "main", type: "file" })
expect(result.some((f) => f.includes("main"))).toBe(true) expect(result.some((f) => f.includes("main"))).toBe(true)
@@ -729,7 +733,8 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await File.init() File.init()
await new Promise((r) => setTimeout(r, 500))
const result = await File.search({ query: "", type: "file" }) const result = await File.search({ query: "", type: "file" })
// Files don't end with / // Files don't end with /
@@ -746,7 +751,8 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await File.init() File.init()
await new Promise((r) => setTimeout(r, 500))
const result = await File.search({ query: "", type: "directory" }) const result = await File.search({ query: "", type: "directory" })
// Directories end with / // Directories end with /
@@ -763,7 +769,8 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await File.init() File.init()
await new Promise((r) => setTimeout(r, 500))
const result = await File.search({ query: "", type: "file", limit: 2 }) const result = await File.search({ query: "", type: "file", limit: 2 })
expect(result.length).toBeLessThanOrEqual(2) expect(result.length).toBeLessThanOrEqual(2)
@@ -777,7 +784,8 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await File.init() File.init()
await new Promise((r) => setTimeout(r, 500))
const result = await File.search({ query: ".hidden", type: "directory" }) const result = await File.search({ query: ".hidden", type: "directory" })
expect(result.length).toBeGreaterThan(0) expect(result.length).toBeGreaterThan(0)
+20 -42
View File
@@ -9,19 +9,6 @@ import { tmpdir } from "../fixture/fixture"
afterEach(() => Instance.disposeAll()) afterEach(() => Instance.disposeAll())
async function touch(file: string, time: number) {
const date = new Date(time)
await fs.utimes(file, date, date)
}
function gate() {
let open!: () => void
const wait = new Promise<void>((resolve) => {
open = resolve
})
return { open, wait }
}
describe("file/time", () => { describe("file/time", () => {
const sessionID = SessionID.make("ses_00000000000000000000000001") const sessionID = SessionID.make("ses_00000000000000000000000001")
@@ -38,6 +25,7 @@ describe("file/time", () => {
expect(before).toBeUndefined() expect(before).toBeUndefined()
await FileTime.read(sessionID, filepath) await FileTime.read(sessionID, filepath)
await Bun.sleep(10)
const after = await FileTime.get(sessionID, filepath) const after = await FileTime.get(sessionID, filepath)
expect(after).toBeInstanceOf(Date) expect(after).toBeInstanceOf(Date)
@@ -56,6 +44,7 @@ describe("file/time", () => {
fn: async () => { fn: async () => {
await FileTime.read(SessionID.make("ses_00000000000000000000000002"), filepath) await FileTime.read(SessionID.make("ses_00000000000000000000000002"), filepath)
await FileTime.read(SessionID.make("ses_00000000000000000000000003"), filepath) await FileTime.read(SessionID.make("ses_00000000000000000000000003"), filepath)
await Bun.sleep(10)
const time1 = await FileTime.get(SessionID.make("ses_00000000000000000000000002"), filepath) const time1 = await FileTime.get(SessionID.make("ses_00000000000000000000000002"), filepath)
const time2 = await FileTime.get(SessionID.make("ses_00000000000000000000000003"), filepath) const time2 = await FileTime.get(SessionID.make("ses_00000000000000000000000003"), filepath)
@@ -74,10 +63,14 @@ describe("file/time", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(sessionID, filepath) FileTime.read(sessionID, filepath)
await Bun.sleep(10)
const first = await FileTime.get(sessionID, filepath) const first = await FileTime.get(sessionID, filepath)
await FileTime.read(sessionID, filepath) await Bun.sleep(10)
FileTime.read(sessionID, filepath)
await Bun.sleep(10)
const second = await FileTime.get(sessionID, filepath) const second = await FileTime.get(sessionID, filepath)
expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime()) expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime())
@@ -91,12 +84,12 @@ describe("file/time", () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt") const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8") await fs.writeFile(filepath, "content", "utf-8")
await touch(filepath, 1_000)
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(sessionID, filepath) FileTime.read(sessionID, filepath)
await Bun.sleep(10)
await FileTime.assert(sessionID, filepath) await FileTime.assert(sessionID, filepath)
}, },
}) })
@@ -119,14 +112,13 @@ describe("file/time", () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt") const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8") await fs.writeFile(filepath, "content", "utf-8")
await touch(filepath, 1_000)
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(sessionID, filepath) FileTime.read(sessionID, filepath)
await Bun.sleep(100)
await fs.writeFile(filepath, "modified content", "utf-8") await fs.writeFile(filepath, "modified content", "utf-8")
await touch(filepath, 2_000)
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read") await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read")
}, },
}) })
@@ -136,14 +128,13 @@ describe("file/time", () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt") const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8") await fs.writeFile(filepath, "content", "utf-8")
await touch(filepath, 1_000)
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(sessionID, filepath) await FileTime.read(sessionID, filepath)
await Bun.sleep(100)
await fs.writeFile(filepath, "modified", "utf-8") await fs.writeFile(filepath, "modified", "utf-8")
await touch(filepath, 2_000)
let error: Error | undefined let error: Error | undefined
try { try {
@@ -200,25 +191,18 @@ describe("file/time", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const order: number[] = [] const order: number[] = []
const hold = gate()
const ready = gate()
const op1 = FileTime.withLock(filepath, async () => { const op1 = FileTime.withLock(filepath, async () => {
order.push(1) order.push(1)
ready.open() await Bun.sleep(50)
await hold.wait
order.push(2) order.push(2)
}) })
await ready.wait
const op2 = FileTime.withLock(filepath, async () => { const op2 = FileTime.withLock(filepath, async () => {
order.push(3) order.push(3)
order.push(4) order.push(4)
}) })
hold.open()
await Promise.all([op1, op2]) await Promise.all([op1, op2])
expect(order).toEqual([1, 2, 3, 4]) expect(order).toEqual([1, 2, 3, 4])
}, },
@@ -235,21 +219,15 @@ describe("file/time", () => {
fn: async () => { fn: async () => {
let started1 = false let started1 = false
let started2 = false let started2 = false
const hold = gate()
const ready = gate()
const op1 = FileTime.withLock(filepath1, async () => { const op1 = FileTime.withLock(filepath1, async () => {
started1 = true started1 = true
ready.open() await Bun.sleep(50)
await hold.wait
expect(started2).toBe(true) expect(started2).toBe(true)
}) })
await ready.wait
const op2 = FileTime.withLock(filepath2, async () => { const op2 = FileTime.withLock(filepath2, async () => {
started2 = true started2 = true
hold.open()
}) })
await Promise.all([op1, op2]) await Promise.all([op1, op2])
@@ -287,12 +265,12 @@ describe("file/time", () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt") const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8") await fs.writeFile(filepath, "content", "utf-8")
await touch(filepath, 1_000)
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(sessionID, filepath) FileTime.read(sessionID, filepath)
await Bun.sleep(10)
const stats = Filesystem.stat(filepath) const stats = Filesystem.stat(filepath)
expect(stats?.mtime).toBeInstanceOf(Date) expect(stats?.mtime).toBeInstanceOf(Date)
@@ -307,17 +285,17 @@ describe("file/time", () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt") const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "original", "utf-8") await fs.writeFile(filepath, "original", "utf-8")
await touch(filepath, 1_000)
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(sessionID, filepath) FileTime.read(sessionID, filepath)
await Bun.sleep(10)
const originalStat = Filesystem.stat(filepath) const originalStat = Filesystem.stat(filepath)
await Bun.sleep(100)
await fs.writeFile(filepath, "modified", "utf-8") await fs.writeFile(filepath, "modified", "utf-8")
await touch(filepath, 2_000)
const newStat = Filesystem.stat(filepath) const newStat = Filesystem.stat(filepath)
expect(newStat!.mtime.getTime()).toBeGreaterThan(originalStat!.mtime.getTime()) expect(newStat!.mtime.getTime()).toBeGreaterThan(originalStat!.mtime.getTime())
+5 -5
View File
@@ -5,7 +5,7 @@ import path from "path"
import { Deferred, Effect, Fiber, Option } from "effect" import { Deferred, Effect, Fiber, Option } from "effect"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { watcherConfigLayer, withServices } from "../fixture/instance" import { watcherConfigLayer, withServices } from "../fixture/instance"
import { FileWatcher } from "../../src/file/watcher" import { FileWatcher, FileWatcherService } from "../../src/file/watcher"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { GlobalBus } from "../../src/bus/global" import { GlobalBus } from "../../src/bus/global"
@@ -19,13 +19,13 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } } type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
/** Run `body` with a live FileWatcher service. */ /** Run `body` with a live FileWatcherService. */
function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) { function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
return withServices( return withServices(
directory, directory,
FileWatcher.layer, FileWatcherService.layer,
async (rt) => { async (rt) => {
await rt.runPromise(FileWatcher.Service.use(() => Effect.void)) await rt.runPromise(FileWatcherService.use((s) => s.init()))
await Effect.runPromise(ready(directory)) await Effect.runPromise(ready(directory))
await Effect.runPromise(body) await Effect.runPromise(body)
}, },
@@ -138,7 +138,7 @@ function ready(directory: string) {
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describeWatcher("FileWatcher", () => { describeWatcher("FileWatcherService", () => {
afterEach(() => Instance.disposeAll()) afterEach(() => Instance.disposeAll())
test("publishes root create, update, and delete events", async () => { test("publishes root create, update, and delete events", async () => {
+7
View File
@@ -0,0 +1,7 @@
import { test } from "bun:test"
import { Effect, Layer } from "effect"
export const testEffect = <R, E>(layer: Layer.Layer<R, E, never>) => ({
effect: <A, E2>(name: string, value: Effect.Effect<A, E2, R>) =>
test(name, () => Effect.runPromise(value.pipe(Effect.provide(layer)))),
})
+12 -18
View File
@@ -1,6 +1,6 @@
import { ConfigProvider, Layer, ManagedRuntime } from "effect"; import { ConfigProvider, Layer, ManagedRuntime } from "effect"
import { InstanceContext } from "../../src/effect/instance-context"; import { InstanceContext } from "../../src/effect/instance-context"
import { Instance } from "../../src/project/instance"; import { Instance } from "../../src/project/instance"
/** ConfigProvider that enables the experimental file watcher. */ /** ConfigProvider that enables the experimental file watcher. */
export const watcherConfigLayer = ConfigProvider.layer( export const watcherConfigLayer = ConfigProvider.layer(
@@ -8,7 +8,7 @@ export const watcherConfigLayer = ConfigProvider.layer(
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false", OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
}), }),
); )
/** /**
* Boot an Instance with the given service layers and run `body` with * Boot an Instance with the given service layers and run `body` with
@@ -28,26 +28,20 @@ export function withServices<S>(
directory, directory,
fn: async () => { fn: async () => {
const ctx = Layer.sync(InstanceContext, () => const ctx = Layer.sync(InstanceContext, () =>
InstanceContext.of({ InstanceContext.of({ directory: Instance.directory, project: Instance.project }),
directory: Instance.directory, )
worktree: Instance.worktree, let resolved: Layer.Layer<S> = Layer.fresh(layer).pipe(Layer.provide(ctx)) as any
project: Instance.project,
}),
);
let resolved: Layer.Layer<S> = Layer.fresh(layer).pipe(
Layer.provide(ctx),
) as any;
if (options?.provide) { if (options?.provide) {
for (const l of options.provide) { for (const l of options.provide) {
resolved = resolved.pipe(Layer.provide(l)) as any; resolved = resolved.pipe(Layer.provide(l)) as any
} }
} }
const rt = ManagedRuntime.make(resolved); const rt = ManagedRuntime.make(resolved)
try { try {
await body(rt); await body(rt)
} finally { } finally {
await rt.dispose(); await rt.dispose()
} }
}, },
}); })
} }
+11 -12
View File
@@ -1,18 +1,17 @@
import { Effect } from "effect"
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { withServices } from "../fixture/instance" import { withServices } from "../fixture/instance"
import { Format } from "../../src/format" import { FormatService } from "../../src/format"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
describe("Format", () => { describe("FormatService", () => {
afterEach(() => Instance.disposeAll()) afterEach(() => Instance.disposeAll())
test("status() returns built-in formatters when no config overrides", async () => { test("status() returns built-in formatters when no config overrides", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
await withServices(tmp.path, Format.layer, async (rt) => { await withServices(tmp.path, FormatService.layer, async (rt) => {
const statuses = await rt.runPromise(Format.Service.use((s) => s.status())) const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
expect(Array.isArray(statuses)).toBe(true) expect(Array.isArray(statuses)).toBe(true)
expect(statuses.length).toBeGreaterThan(0) expect(statuses.length).toBeGreaterThan(0)
@@ -33,8 +32,8 @@ describe("Format", () => {
config: { formatter: false }, config: { formatter: false },
}) })
await withServices(tmp.path, Format.layer, async (rt) => { await withServices(tmp.path, FormatService.layer, async (rt) => {
const statuses = await rt.runPromise(Format.Service.use((s) => s.status())) const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
expect(statuses).toEqual([]) expect(statuses).toEqual([])
}) })
}) })
@@ -48,18 +47,18 @@ describe("Format", () => {
}, },
}) })
await withServices(tmp.path, Format.layer, async (rt) => { await withServices(tmp.path, FormatService.layer, async (rt) => {
const statuses = await rt.runPromise(Format.Service.use((s) => s.status())) const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
const gofmt = statuses.find((s) => s.name === "gofmt") const gofmt = statuses.find((s) => s.name === "gofmt")
expect(gofmt).toBeUndefined() expect(gofmt).toBeUndefined()
}) })
}) })
test("service initializes without error", async () => { test("init() completes without error", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
await withServices(tmp.path, Format.layer, async (rt) => { await withServices(tmp.path, FormatService.layer, async (rt) => {
await rt.runPromise(Format.Service.use(() => Effect.void)) await rt.runPromise(FormatService.use((s) => s.init()))
}) })
}) })
}) })
-37
View File
@@ -1,37 +0,0 @@
import { test, type TestOptions } from "bun:test"
import { Cause, Effect, Exit, Layer } from "effect"
import type * as Scope from "effect/Scope"
import * as TestConsole from "effect/testing/TestConsole"
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
const env = TestConsole.layer
const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2, never>) =>
Effect.gen(function* () {
const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
if (Exit.isFailure(exit)) {
for (const err of Cause.prettyErrors(exit.cause)) {
yield* Effect.logError(err)
}
}
return yield* exit
}).pipe(Effect.runPromise)
const make = <R, E>(layer: Layer.Layer<R, E, never>) => {
const effect = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test(name, () => run(value, layer), opts)
effect.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.only(name, () => run(value, layer), opts)
effect.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.skip(name, () => run(value, layer), opts)
return { effect }
}
export const it = make(env)
export const testEffect = <R, E>(layer: Layer.Layer<R, E, never>) => make(Layer.provideMerge(layer, env))
-10
View File
@@ -1,10 +0,0 @@
import path from "path"
import { Effect, FileSystem } from "effect"
export const writeFileStringScoped = Effect.fn("test.writeFileStringScoped")(function* (file: string, text: string) {
const fs = yield* FileSystem.FileSystem
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
yield* fs.writeFileString(file, text)
yield* Effect.addFinalizer(() => fs.remove(file, { force: true }).pipe(Effect.orDie))
return file
})
@@ -1,5 +1,5 @@
import { describe, test, expect } from "bun:test" import { describe, test, expect } from "bun:test"
import { PermissionNext } from "../src/permission" import { PermissionNext } from "../src/permission/next"
import { Config } from "../src/config/config" import { Config } from "../src/config/config"
import { Instance } from "../src/project/instance" import { Instance } from "../src/project/instance"
import { tmpdir } from "./fixture/fixture" import { tmpdir } from "./fixture/fixture"
@@ -4,8 +4,8 @@ import { Effect } from "effect"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { runtime } from "../../src/effect/runtime" import { runtime } from "../../src/effect/runtime"
import { Instances } from "../../src/effect/instances" import { Instances } from "../../src/effect/instances"
import { PermissionNext } from "../../src/permission" import { PermissionNext } from "../../src/permission/next"
import * as S from "../../src/permission/effect" import * as S from "../../src/permission/service"
import { PermissionID } from "../../src/permission/schema" import { PermissionID } from "../../src/permission/schema"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
@@ -1005,7 +1005,7 @@ test("ask - abort should clear pending request", async () => {
fn: async () => { fn: async () => {
const ctl = new AbortController() const ctl = new AbortController()
const ask = runtime.runPromise( const ask = runtime.runPromise(
S.PermissionEffect.Service.use((svc) => S.PermissionService.use((svc) =>
svc.ask({ svc.ask({
sessionID: SessionID.make("session_test"), sessionID: SessionID.make("session_test"),
permission: "bash", permission: "bash",
-1
View File
@@ -44,7 +44,6 @@ process.env["OPENCODE_TEST_HOME"] = testHome
// Set test managed config directory to isolate tests from system managed settings // Set test managed config directory to isolate tests from system managed settings
const testManagedConfigDir = path.join(dir, "managed") const testManagedConfigDir = path.join(dir, "managed")
process.env["OPENCODE_TEST_MANAGED_CONFIG_DIR"] = testManagedConfigDir process.env["OPENCODE_TEST_MANAGED_CONFIG_DIR"] = testManagedConfigDir
process.env["OPENCODE_DISABLE_DEFAULT_PLUGINS"] = "true"
// Write the cache version file to prevent global/index.ts from clearing the cache // Write the cache version file to prevent global/index.ts from clearing the cache
const cacheDir = path.join(dir, "cache", "opencode") const cacheDir = path.join(dir, "cache", "opencode")
+10 -10
View File
@@ -2,13 +2,13 @@ import { $ } from "bun"
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { Effect, Layer, ManagedRuntime } from "effect" import { Layer, ManagedRuntime } from "effect"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { watcherConfigLayer, withServices } from "../fixture/instance" import { watcherConfigLayer, withServices } from "../fixture/instance"
import { FileWatcher } from "../../src/file/watcher" import { FileWatcher, FileWatcherService } from "../../src/file/watcher"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { GlobalBus } from "../../src/bus/global" import { GlobalBus } from "../../src/bus/global"
import { Vcs } from "../../src/project/vcs" import { Vcs, VcsService } from "../../src/project/vcs"
// Skip in CI — native @parcel/watcher binding needed // Skip in CI — native @parcel/watcher binding needed
const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
@@ -19,14 +19,14 @@ const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe
function withVcs( function withVcs(
directory: string, directory: string,
body: (rt: ManagedRuntime.ManagedRuntime<FileWatcher.Service | Vcs.Service, never>) => Promise<void>, body: (rt: ManagedRuntime.ManagedRuntime<FileWatcherService | VcsService, never>) => Promise<void>,
) { ) {
return withServices( return withServices(
directory, directory,
Layer.merge(FileWatcher.layer, Vcs.layer), Layer.merge(FileWatcherService.layer, VcsService.layer),
async (rt) => { async (rt) => {
await rt.runPromise(FileWatcher.Service.use(() => Effect.void)) await rt.runPromise(FileWatcherService.use((s) => s.init()))
await rt.runPromise(Vcs.Service.use(() => Effect.void)) await rt.runPromise(VcsService.use((s) => s.init()))
await Bun.sleep(200) await Bun.sleep(200)
await body(rt) await body(rt)
}, },
@@ -67,7 +67,7 @@ describeVcs("Vcs", () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
await withVcs(tmp.path, async (rt) => { await withVcs(tmp.path, async (rt) => {
const branch = await rt.runPromise(Vcs.Service.use((s) => s.branch())) const branch = await rt.runPromise(VcsService.use((s) => s.branch()))
expect(branch).toBeDefined() expect(branch).toBeDefined()
expect(typeof branch).toBe("string") expect(typeof branch).toBe("string")
}) })
@@ -77,7 +77,7 @@ describeVcs("Vcs", () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
await withVcs(tmp.path, async (rt) => { await withVcs(tmp.path, async (rt) => {
const branch = await rt.runPromise(Vcs.Service.use((s) => s.branch())) const branch = await rt.runPromise(VcsService.use((s) => s.branch()))
expect(branch).toBeUndefined() expect(branch).toBeUndefined()
}) })
}) })
@@ -110,7 +110,7 @@ describeVcs("Vcs", () => {
await fs.writeFile(head, `ref: refs/heads/${branch}\n`) await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
await pending await pending
const current = await rt.runPromise(Vcs.Service.use((s) => s.branch())) const current = await rt.runPromise(VcsService.use((s) => s.branch()))
expect(current).toBe(branch) expect(current).toBe(branch)
}) })
}) })
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test"
import { Scheduler } from "../src/scheduler"
import { Instance } from "../src/project/instance"
import { tmpdir } from "./fixture/fixture"
describe("Scheduler.register", () => {
const hour = 60 * 60 * 1000
test("defaults to instance scope per directory", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
const runs = { count: 0 }
const id = "scheduler.instance." + Math.random().toString(36).slice(2)
const task = {
id,
interval: hour,
run: async () => {
runs.count += 1
},
}
await Instance.provide({
directory: one.path,
fn: async () => {
Scheduler.register(task)
await Instance.dispose()
},
})
expect(runs.count).toBe(1)
await Instance.provide({
directory: two.path,
fn: async () => {
Scheduler.register(task)
await Instance.dispose()
},
})
expect(runs.count).toBe(2)
})
test("global scope runs once across instances", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
const runs = { count: 0 }
const id = "scheduler.global." + Math.random().toString(36).slice(2)
const task = {
id,
interval: hour,
run: async () => {
runs.count += 1
},
scope: "global" as const,
}
await Instance.provide({
directory: one.path,
fn: async () => {
Scheduler.register(task)
await Instance.dispose()
},
})
expect(runs.count).toBe(1)
await Instance.provide({
directory: two.path,
fn: async () => {
Scheduler.register(task)
await Instance.dispose()
},
})
expect(runs.count).toBe(1)
})
})
@@ -1,6 +1,6 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test" import { describe, test, expect, beforeAll, afterAll } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Discovery } from "../../src/skill/discovery" import { DiscoveryService } from "../../src/skill/discovery"
import { Global } from "../../src/global" import { Global } from "../../src/global"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import { rm } from "fs/promises" import { rm } from "fs/promises"
@@ -48,7 +48,7 @@ afterAll(async () => {
describe("Discovery.pull", () => { describe("Discovery.pull", () => {
const pull = (url: string) => const pull = (url: string) =>
Effect.runPromise(Discovery.Service.use((s) => s.pull(url)).pipe(Effect.provide(Discovery.defaultLayer))) Effect.runPromise(DiscoveryService.use((s) => s.pull(url)).pipe(Effect.provide(DiscoveryService.defaultLayer)))
test("downloads skills from cloudflare url", async () => { test("downloads skills from cloudflare url", async () => {
const dirs = await pull(CLOUDFLARE_SKILLS_URL) const dirs = await pull(CLOUDFLARE_SKILLS_URL)
+2 -2
View File
@@ -5,8 +5,8 @@ import { BashTool } from "../../src/tool/bash"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import type { PermissionNext } from "../../src/permission" import type { PermissionNext } from "../../src/permission/next"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool/truncation"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
const ctx = { const ctx = {
+16 -20
View File
@@ -18,11 +18,6 @@ const ctx = {
ask: async () => {}, ask: async () => {},
} }
async function touch(file: string, time: number) {
const date = new Date(time)
await fs.utimes(file, date, date)
}
describe("tool.edit", () => { describe("tool.edit", () => {
describe("creating new files", () => { describe("creating new files", () => {
test("creates new file when oldString is empty", async () => { test("creates new file when oldString is empty", async () => {
@@ -116,7 +111,7 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init() const edit = await EditTool.init()
const result = await edit.execute( const result = await edit.execute(
@@ -143,7 +138,7 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init() const edit = await EditTool.init()
await expect( await expect(
@@ -191,7 +186,7 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init() const edit = await EditTool.init()
await expect( await expect(
@@ -235,17 +230,18 @@ describe("tool.edit", () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt") const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "original content", "utf-8") await fs.writeFile(filepath, "original content", "utf-8")
await touch(filepath, 1_000)
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
// Read first // Read first
await FileTime.read(ctx.sessionID, filepath) FileTime.read(ctx.sessionID, filepath)
// Wait a bit to ensure different timestamps
await new Promise((resolve) => setTimeout(resolve, 100))
// Simulate external modification // Simulate external modification
await fs.writeFile(filepath, "modified externally", "utf-8") await fs.writeFile(filepath, "modified externally", "utf-8")
await touch(filepath, 2_000)
// Try to edit with the new content // Try to edit with the new content
const edit = await EditTool.init() const edit = await EditTool.init()
@@ -271,7 +267,7 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init() const edit = await EditTool.init()
await edit.execute( await edit.execute(
@@ -298,7 +294,7 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) FileTime.read(ctx.sessionID, filepath)
const { Bus } = await import("../../src/bus") const { Bus } = await import("../../src/bus")
const { File } = await import("../../src/file") const { File } = await import("../../src/file")
@@ -336,7 +332,7 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init() const edit = await EditTool.init()
await edit.execute( await edit.execute(
@@ -362,7 +358,7 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init() const edit = await EditTool.init()
await edit.execute( await edit.execute(
@@ -411,7 +407,7 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, dirpath) FileTime.read(ctx.sessionID, dirpath)
const edit = await EditTool.init() const edit = await EditTool.init()
await expect( await expect(
@@ -436,7 +432,7 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init() const edit = await EditTool.init()
const result = await edit.execute( const result = await edit.execute(
@@ -507,7 +503,7 @@ describe("tool.edit", () => {
fn: async () => { fn: async () => {
const edit = await EditTool.init() const edit = await EditTool.init()
const filePath = path.join(tmp.path, "test.txt") const filePath = path.join(tmp.path, "test.txt")
await FileTime.read(ctx.sessionID, filePath) FileTime.read(ctx.sessionID, filePath)
await edit.execute( await edit.execute(
{ {
filePath, filePath,
@@ -648,7 +644,7 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init() const edit = await EditTool.init()
@@ -663,7 +659,7 @@ describe("tool.edit", () => {
) )
// Need to read again since FileTime tracks per-session // Need to read again since FileTime tracks per-session
await FileTime.read(ctx.sessionID, filepath) FileTime.read(ctx.sessionID, filepath)
const promise2 = edit.execute( const promise2 = edit.execute(
{ {
@@ -3,7 +3,7 @@ import path from "path"
import type { Tool } from "../../src/tool/tool" import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { assertExternalDirectory } from "../../src/tool/external-directory" import { assertExternalDirectory } from "../../src/tool/external-directory"
import type { PermissionNext } from "../../src/permission" import type { PermissionNext } from "../../src/permission/next"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
const baseCtx: Omit<Tool.Context, "ask"> = { const baseCtx: Omit<Tool.Context, "ask"> = {
+1 -1
View File
@@ -4,7 +4,7 @@ import { ReadTool } from "../../src/tool/read"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { PermissionNext } from "../../src/permission" import { PermissionNext } from "../../src/permission/next"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
+1 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import type { PermissionNext } from "../../src/permission" import type { PermissionNext } from "../../src/permission/next"
import type { Tool } from "../../src/tool/tool" import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { SkillTool } from "../../src/tool/skill" import { SkillTool } from "../../src/tool/skill"
+29 -21
View File
@@ -1,13 +1,9 @@
import { describe, test, expect } from "bun:test" import { describe, test, expect, afterAll } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node" import { Truncate } from "../../src/tool/truncation"
import { Effect, FileSystem, Layer } from "effect"
import { Truncate } from "../../src/tool/truncate"
import { TruncateEffect } from "../../src/tool/truncate-effect"
import { Identifier } from "../../src/id/id" import { Identifier } from "../../src/id/id"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import fs from "fs/promises"
import path from "path" import path from "path"
import { testEffect } from "../lib/effect"
import { writeFileStringScoped } from "../lib/filesystem"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
@@ -129,24 +125,36 @@ describe("Truncate", () => {
describe("cleanup", () => { describe("cleanup", () => {
const DAY_MS = 24 * 60 * 60 * 1000 const DAY_MS = 24 * 60 * 60 * 1000
const it = testEffect(Layer.mergeAll(TruncateEffect.defaultLayer, NodeFileSystem.layer)) let oldFile: string
let recentFile: string
it.effect("deletes files older than 7 days and preserves recent files", () => afterAll(async () => {
Effect.gen(function* () { await fs.unlink(oldFile).catch(() => {})
const fs = yield* FileSystem.FileSystem await fs.unlink(recentFile).catch(() => {})
})
yield* fs.makeDirectory(Truncate.DIR, { recursive: true }) test("deletes files older than 7 days and preserves recent files", async () => {
await fs.mkdir(Truncate.DIR, { recursive: true })
const old = path.join(Truncate.DIR, Identifier.create("tool", false, Date.now() - 10 * DAY_MS)) // Create an old file (10 days ago)
const recent = path.join(Truncate.DIR, Identifier.create("tool", false, Date.now() - 3 * DAY_MS)) const oldTimestamp = Date.now() - 10 * DAY_MS
const oldId = Identifier.create("tool", false, oldTimestamp)
oldFile = path.join(Truncate.DIR, oldId)
await Filesystem.write(oldFile, "old content")
yield* writeFileStringScoped(old, "old content") // Create a recent file (3 days ago)
yield* writeFileStringScoped(recent, "recent content") const recentTimestamp = Date.now() - 3 * DAY_MS
yield* TruncateEffect.Service.use((s) => s.cleanup()) const recentId = Identifier.create("tool", false, recentTimestamp)
recentFile = path.join(Truncate.DIR, recentId)
await Filesystem.write(recentFile, "recent content")
expect(yield* fs.exists(old)).toBe(false) await Truncate.cleanup()
expect(yield* fs.exists(recent)).toBe(true)
}), // Old file should be deleted
) expect(await Filesystem.exists(oldFile)).toBe(false)
// Recent file should still exist
expect(await Filesystem.exists(recentFile)).toBe(true)
})
}) })
}) })
+3 -3
View File
@@ -99,7 +99,7 @@ describe("tool.write", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const { FileTime } = await import("../../src/file/time") const { FileTime } = await import("../../src/file/time")
await FileTime.read(ctx.sessionID, filepath) FileTime.read(ctx.sessionID, filepath)
const write = await WriteTool.init() const write = await WriteTool.init()
const result = await write.execute( const result = await write.execute(
@@ -128,7 +128,7 @@ describe("tool.write", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const { FileTime } = await import("../../src/file/time") const { FileTime } = await import("../../src/file/time")
await FileTime.read(ctx.sessionID, filepath) FileTime.read(ctx.sessionID, filepath)
const write = await WriteTool.init() const write = await WriteTool.init()
const result = await write.execute( const result = await write.execute(
@@ -306,7 +306,7 @@ describe("tool.write", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const { FileTime } = await import("../../src/file/time") const { FileTime } = await import("../../src/file/time")
await FileTime.read(ctx.sessionID, readonlyPath) FileTime.read(ctx.sessionID, readonlyPath)
const write = await WriteTool.init() const write = await WriteTool.init()
await expect( await expect(
-3
View File
@@ -1343,9 +1343,6 @@ export type Config = {
ignore?: Array<string> ignore?: Array<string>
} }
plugin?: Array<string> plugin?: Array<string>
/**
* Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.
*/
snapshot?: boolean snapshot?: boolean
/** /**
* Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing * Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing
-1
View File
@@ -10405,7 +10405,6 @@
} }
}, },
"snapshot": { "snapshot": {
"description": "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
"type": "boolean" "type": "boolean"
}, },
"share": { "share": {
-17
View File
@@ -424,23 +424,6 @@ Customize keybinds in `tui.json`.
--- ---
### Snapshot
OpenCode uses snapshots to track file changes during agent operations, enabling you to undo and revert changes within a session. Snapshots are enabled by default.
For large repositories or projects with many submodules, the snapshot system can cause slow indexing and significant disk usage as it tracks all changes using an internal git repository. You can disable snapshots using the `snapshot` option.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"snapshot": false
}
```
Note that disabling snapshots means changes made by the agent cannot be rolled back through the UI.
---
### Autoupdate ### Autoupdate
OpenCode will automatically download any new updates when it starts up. You can disable this with the `autoupdate` option. OpenCode will automatically download any new updates when it starts up. You can disable this with the `autoupdate` option.