Compare commits

..

3 Commits

Author SHA1 Message Date
Frank
dadddc9c8c zen: deprecate gemini 3 pro 2026-03-17 12:31:54 -04:00
OpeOginni
6708c3f6cf docs: mark tools config as deprecated (#17951) 2026-03-17 10:07:35 -05:00
Shoubhit Dash
ba22976568 fix: inline review comment submit and layout (#17948) 2026-03-17 19:54:20 +05:30
18 changed files with 940 additions and 872 deletions

View File

@@ -123,6 +123,101 @@ async function spot(page: Parameters<typeof test>[0]["page"], file: string) {
}, file)
}
async function comment(page: Parameters<typeof test>[0]["page"], file: string, note: string) {
const row = page.locator(`[data-file="${file}"]`).first()
await expect(row).toBeVisible()
const line = row.locator('diffs-container [data-line="2"]').first()
await expect(line).toBeVisible()
await line.hover()
const add = row.getByRole("button", { name: /^Comment$/ }).first()
await expect(add).toBeVisible()
await add.click()
const area = row.locator('[data-slot="line-comment-textarea"]').first()
await expect(area).toBeVisible()
await area.fill(note)
const submit = row.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
await expect(submit).toBeEnabled()
await submit.click()
await expect(row.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
await expect(row.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
}
async function overflow(page: Parameters<typeof test>[0]["page"], file: string) {
const row = page.locator(`[data-file="${file}"]`).first()
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
const pop = row.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
const tools = row.locator('[data-slot="line-comment-tools"]').first()
const [width, viewBox, popBox, toolsBox] = await Promise.all([
view.evaluate((el) => el.scrollWidth - el.clientWidth),
view.boundingBox(),
pop.boundingBox(),
tools.boundingBox(),
])
if (!viewBox || !popBox || !toolsBox) return null
return {
width,
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
}
}
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
test.setTimeout(180_000)
const tag = `review-comment-${Date.now()}`
const file = `review-comment-${tag}.txt`
const note = `comment ${tag}`
await page.setViewportSize({ width: 1280, height: 900 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review comment ${tag}`, async (session) => {
await patch(sdk, session.id, seed([{ file, mark: tag }]))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(1)
await project.gotoSession(session.id)
await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible()
await tab.click()
await expand(page)
await waitMark(page, file, tag)
await comment(page, file, note)
await expect
.poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
})
})
})
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
test.setTimeout(180_000)

View File

@@ -11,52 +11,46 @@ const seed = async () => {
const { Instance } = await import("../src/project/instance")
const { InstanceBootstrap } = await import("../src/project/bootstrap")
const { Config } = await import("../src/config/config")
const { disposeRuntime } = await import("../src/effect/runtime")
const { Session } = await import("../src/session")
const { MessageID, PartID } = await import("../src/session/schema")
const { Project } = await import("../src/project/project")
const { ModelID, ProviderID } = await import("../src/provider/schema")
const { ToolRegistry } = await import("../src/tool/registry")
try {
await Instance.provide({
directory: dir,
init: InstanceBootstrap,
fn: async () => {
await Config.waitForDependencies()
await ToolRegistry.ids()
await Instance.provide({
directory: dir,
init: InstanceBootstrap,
fn: async () => {
await Config.waitForDependencies()
await ToolRegistry.ids()
const session = await Session.create({ title })
const messageID = MessageID.ascending()
const partID = PartID.ascending()
const message = {
id: messageID,
sessionID: session.id,
role: "user" as const,
time: { created: now },
agent: "build",
model: {
providerID: ProviderID.make(providerID),
modelID: ModelID.make(modelID),
},
}
const part = {
id: partID,
sessionID: session.id,
messageID,
type: "text" as const,
text,
time: { start: now },
}
await Session.updateMessage(message)
await Session.updatePart(part)
await Project.update({ projectID: Instance.project.id, name: "E2E Project" })
},
})
} finally {
await Instance.disposeAll().catch(() => {})
await disposeRuntime().catch(() => {})
}
const session = await Session.create({ title })
const messageID = MessageID.ascending()
const partID = PartID.ascending()
const message = {
id: messageID,
sessionID: session.id,
role: "user" as const,
time: { created: now },
agent: "build",
model: {
providerID: ProviderID.make(providerID),
modelID: ModelID.make(modelID),
},
}
const part = {
id: partID,
sessionID: session.id,
messageID,
type: "text" as const,
text,
time: { start: now },
}
await Session.updateMessage(message)
await Session.updatePart(part)
await Project.update({ projectID: Instance.project.id, name: "E2E Project" })
},
})
}
await seed()

View File

@@ -1,15 +1,13 @@
import { ServiceMap } from "effect";
import type { Project } from "@/project/project";
import { ServiceMap } from "effect"
import type { Project } from "@/project/project"
export declare namespace InstanceContext {
export interface Shape {
readonly directory: string;
readonly worktree: string;
readonly project: Project.Info;
}
export interface Shape {
readonly directory: string
readonly project: Project.Info
}
}
export class InstanceContext extends ServiceMap.Service<
InstanceContext,
InstanceContext.Shape
>()("opencode/InstanceContext") {}
export class InstanceContext extends ServiceMap.Service<InstanceContext, InstanceContext.Shape>()(
"opencode/InstanceContext",
) {}

View File

@@ -1,17 +1,16 @@
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
import { FileService } from "@/file"
import { FileTimeService } from "@/file/time"
import { FileWatcherService } from "@/file/watcher"
import { FormatService } from "@/format"
import { PermissionService } from "@/permission/service"
import { Instance } from "@/project/instance"
import { VcsService } from "@/project/vcs"
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 { SnapshotService } from "@/snapshot"
import { InstanceContext } from "./instance-context"
import { registerDisposer } from "./instance-registry"
import { Instance } from "@/project/instance"
export { InstanceContext } from "./instance-context"
@@ -25,16 +24,10 @@ export type InstanceServices =
| FormatService
| FileService
| SkillService
| SnapshotService
// NOTE: LayerMap only passes the key (directory string) to lookup, but we need
// the full instance context (directory, worktree, project). We read from the
// legacy Instance ALS here, which is safe because lookup is only triggered via
// 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))
function lookup(directory: string) {
const project = Instance.project
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of({ directory, project }))
return Layer.mergeAll(
Layer.fresh(QuestionService.layer),
Layer.fresh(PermissionService.layer),
@@ -45,7 +38,6 @@ function lookup(_key: string) {
Layer.fresh(FormatService.layer),
Layer.fresh(FileService.layer),
Layer.fresh(SkillService.layer),
Layer.fresh(SnapshotService.layer),
).pipe(Layer.provide(ctx))
}
@@ -55,9 +47,7 @@ export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<s
static readonly layer = Layer.effect(
Instances,
Effect.gen(function* () {
const layerMap = yield* LayerMap.make(lookup, {
idleTimeToLive: Infinity,
})
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
yield* Effect.addFinalizer(() => Effect.sync(unregister))
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> {
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))
}
}

View File

@@ -3,19 +3,12 @@ import { AccountService } from "@/account/service"
import { AuthService } from "@/auth/service"
import { Instances } from "@/effect/instances"
import type { InstanceServices } from "@/effect/instances"
import { TruncateService } from "@/tool/truncate-service"
import { Instance } from "@/project/instance"
export const runtime = ManagedRuntime.make(
Layer.mergeAll(AccountService.defaultLayer, Instances.layer, TruncateService.layer).pipe(
Layer.provideMerge(AuthService.defaultLayer),
),
Layer.mergeAll(AccountService.defaultLayer, Instances.layer).pipe(Layer.provideMerge(AuthService.defaultLayer)),
)
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
}
export function disposeRuntime() {
return runtime.dispose()
}

View File

@@ -11,6 +11,7 @@ import { VcsService } from "./vcs"
import { Log } from "@/util/log"
import { ShareNext } from "@/share/share-next"
import { Snapshot } from "../snapshot"
import { Truncate } from "../tool/truncation"
import { runPromiseInstance } from "@/effect/runtime"
export async function InstanceBootstrap() {
@@ -23,6 +24,7 @@ export async function InstanceBootstrap() {
File.init()
await runPromiseInstance(VcsService.use((s) => s.init()))
Snapshot.init()
Truncate.init()
Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {

View File

@@ -1,185 +1,163 @@
import { GlobalBus } from "@/bus/global";
import { disposeInstance } from "@/effect/instance-registry";
import { Filesystem } from "@/util/filesystem";
import { iife } from "@/util/iife";
import { Log } from "@/util/log";
import { Context } from "../util/context";
import { Project } from "./project";
import { State } from "./state";
import { Log } from "@/util/log"
import { Context } from "../util/context"
import { Project } from "./project"
import { State } from "./state"
import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global"
import { Filesystem } from "@/util/filesystem"
import { disposeInstance } from "@/effect/instance-registry"
interface Context {
directory: string;
worktree: string;
project: Project.Info;
directory: string
worktree: string
project: Project.Info
}
const context = Context.create<Context>("instance");
const cache = new Map<string, Promise<Context>>();
const context = Context.create<Context>("instance")
const cache = new Map<string, Promise<Context>>()
const disposal = {
all: undefined as Promise<void> | undefined,
};
function emit(directory: string) {
GlobalBus.emit("event", {
directory,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
});
all: undefined as Promise<void> | undefined,
}
function boot(input: {
directory: string;
init?: () => Promise<any>;
project?: Project.Info;
worktree?: string;
}) {
return iife(async () => {
const ctx =
input.project && input.worktree
? {
directory: input.directory,
worktree: input.worktree,
project: input.project,
}
: await Project.fromDirectory(input.directory).then(
({ project, sandbox }) => ({
directory: input.directory,
worktree: sandbox,
project,
}),
);
await context.provide(ctx, async () => {
await input.init?.();
});
return ctx;
});
function emit(directory: string) {
GlobalBus.emit("event", {
directory,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
})
}
function boot(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
return iife(async () => {
const ctx =
input.project && input.worktree
? {
directory: input.directory,
worktree: input.worktree,
project: input.project,
}
: await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({
directory: input.directory,
worktree: sandbox,
project,
}))
await context.provide(ctx, async () => {
await input.init?.()
})
return ctx
})
}
function track(directory: string, next: Promise<Context>) {
const task = next.catch((error) => {
if (cache.get(directory) === task) cache.delete(directory);
throw error;
});
cache.set(directory, task);
return task;
const task = next.catch((error) => {
if (cache.get(directory) === task) cache.delete(directory)
throw error
})
cache.set(directory, task)
return task
}
export const Instance = {
async provide<R>(input: {
directory: string;
init?: () => Promise<any>;
fn: () => R;
}): Promise<R> {
const directory = Filesystem.resolve(input.directory);
let existing = cache.get(directory);
if (!existing) {
Log.Default.info("creating instance", { directory });
existing = track(
directory,
boot({
directory,
init: input.init,
}),
);
}
const ctx = await existing;
return context.provide(ctx, async () => {
return input.fn();
});
},
get current() {
return context.use();
},
get directory() {
return context.use().directory;
},
get worktree() {
return context.use().worktree;
},
get project() {
return context.use().project;
},
/**
* Check if a path is within the project boundary.
* Returns true if path is inside Instance.directory OR Instance.worktree.
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
*/
containsPath(filepath: string) {
if (Filesystem.contains(Instance.directory, filepath)) return true;
// Non-git projects set worktree to "/" which would match ANY absolute path.
// Skip worktree check in this case to preserve external_directory permissions.
if (Instance.worktree === "/") return false;
return Filesystem.contains(Instance.worktree, filepath);
},
/**
* Captures the current instance ALS context and returns a wrapper that
* restores it when called. Use this for callbacks that fire outside the
* instance async context (native addons, event emitters, timers, etc.).
*/
bind<F extends (...args: any[]) => any>(fn: F): F {
const ctx = context.use();
return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F;
},
state<S>(
init: () => S,
dispose?: (state: Awaited<S>) => Promise<void>,
): () => S {
return State.create(() => Instance.directory, init, dispose);
},
async reload(input: {
directory: string;
init?: () => Promise<any>;
project?: Project.Info;
worktree?: string;
}) {
const directory = Filesystem.resolve(input.directory);
Log.Default.info("reloading instance", { directory });
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() {
const directory = Instance.directory;
Log.Default.info("disposing instance", { directory });
await Promise.all([State.dispose(directory), disposeInstance(directory)]);
cache.delete(directory);
emit(directory);
},
async disposeAll() {
if (disposal.all) return disposal.all;
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
const directory = Filesystem.resolve(input.directory)
let existing = cache.get(directory)
if (!existing) {
Log.Default.info("creating instance", { directory })
existing = track(
directory,
boot({
directory,
init: input.init,
}),
)
}
const ctx = await existing
return context.provide(ctx, async () => {
return input.fn()
})
},
get directory() {
return context.use().directory
},
get worktree() {
return context.use().worktree
},
get project() {
return context.use().project
},
/**
* Check if a path is within the project boundary.
* Returns true if path is inside Instance.directory OR Instance.worktree.
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
*/
containsPath(filepath: string) {
if (Filesystem.contains(Instance.directory, filepath)) return true
// Non-git projects set worktree to "/" which would match ANY absolute path.
// Skip worktree check in this case to preserve external_directory permissions.
if (Instance.worktree === "/") return false
return Filesystem.contains(Instance.worktree, filepath)
},
/**
* Captures the current instance ALS context and returns a wrapper that
* restores it when called. Use this for callbacks that fire outside the
* instance async context (native addons, event emitters, timers, etc.).
*/
bind<F extends (...args: any[]) => any>(fn: F): F {
const ctx = context.use()
return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F
},
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
return State.create(() => Instance.directory, init, dispose)
},
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
const directory = Filesystem.resolve(input.directory)
Log.Default.info("reloading instance", { directory })
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() {
const directory = Instance.directory
Log.Default.info("disposing instance", { directory })
await Promise.all([State.dispose(directory), disposeInstance(directory)])
cache.delete(directory)
emit(directory)
},
async disposeAll() {
if (disposal.all) return disposal.all
disposal.all = iife(async () => {
Log.Default.info("disposing all instances");
const entries = [...cache.entries()];
for (const [key, value] of entries) {
if (cache.get(key) !== value) continue;
disposal.all = iife(async () => {
Log.Default.info("disposing all instances")
const entries = [...cache.entries()]
for (const [key, value] of entries) {
if (cache.get(key) !== value) continue
const ctx = await value.catch((error) => {
Log.Default.warn("instance dispose failed", { key, error });
return undefined;
});
const ctx = await value.catch((error) => {
Log.Default.warn("instance dispose failed", { key, error })
return undefined
})
if (!ctx) {
if (cache.get(key) === value) cache.delete(key);
continue;
}
if (!ctx) {
if (cache.get(key) === value) cache.delete(key)
continue
}
if (cache.get(key) !== value) continue;
if (cache.get(key) !== value) continue
await context.provide(ctx, async () => {
await Instance.dispose();
});
}
}).finally(() => {
disposal.all = undefined;
});
await context.provide(ctx, async () => {
await Instance.dispose()
})
}
}).finally(() => {
disposal.all = undefined
})
return disposal.all;
},
};
return disposal.all
},
}

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 })
})
}
}

View File

@@ -1,516 +1,416 @@
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 z from "zod";
import { InstanceContext } from "@/effect/instance-context";
import { runPromiseInstance } from "@/effect/runtime";
import { Config } from "../config/config";
import { Global } from "../global";
import { Log } from "../util/log";
const log = Log.create({ service: "snapshot" });
const PRUNE = "7.days";
// Common git config flags shared across snapshot operations
const GIT_CORE = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"];
const GIT_CFG = ["-c", "core.autocrlf=false", ...GIT_CORE];
const GIT_CFG_QUOTE = [...GIT_CFG, "-c", "core.quotepath=false"];
interface GitResult {
readonly code: ChildProcessSpawner.ExitCode;
readonly text: string;
readonly stderr: string;
}
import path from "path"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
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 const Patch = z.object({
hash: z.string(),
files: z.string().array(),
});
export type Patch = z.infer<typeof Patch>;
const log = Log.create({ service: "snapshot" })
const hour = 60 * 60 * 1000
const prune = "7.days"
export const FileDiff = z
.object({
file: z.string(),
before: z.string(),
after: z.string(),
additions: z.number(),
deletions: z.number(),
status: z.enum(["added", "deleted", "modified"]).optional(),
})
.meta({
ref: "FileDiff",
});
export type FileDiff = z.infer<typeof FileDiff>;
function args(git: string, cmd: string[]) {
return ["--git-dir", git, "--work-tree", Instance.worktree, ...cmd]
}
// Promise facade — existing callers use these
export function init() {
void runPromiseInstance(SnapshotService.use((s) => s.init()));
}
export function init() {
Scheduler.register({
id: "snapshot.cleanup",
interval: hour,
run: cleanup,
scope: "instance",
})
}
export async function cleanup() {
return runPromiseInstance(SnapshotService.use((s) => s.cleanup()));
}
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() {
return runPromiseInstance(SnapshotService.use((s) => s.track()));
}
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,
})
export async function patch(hash: string) {
return runPromiseInstance(SnapshotService.use((s) => s.patch(hash)));
}
// 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 async function restore(snapshot: string) {
return runPromiseInstance(SnapshotService.use((s) => s.restore(snapshot)));
}
export const Patch = z.object({
hash: z.string(),
files: z.string().array(),
})
export type Patch = z.infer<typeof Patch>
export async function revert(patches: Patch[]) {
return runPromiseInstance(SnapshotService.use((s) => s.revert(patches)));
}
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,
},
)
export async function diff(hash: string) {
return runPromiseInstance(SnapshotService.use((s) => s.diff(hash)));
}
// If git diff fails, return empty patch
if (result.code !== 0) {
log.warn("failed to get diff", { hash, exitCode: result.code })
return { hash, files: [] }
}
export async function diffFull(from: string, to: string) {
return runPromiseInstance(SnapshotService.use((s) => s.diffFull(from, to)));
}
}
export namespace SnapshotService {
export interface Service {
readonly init: () => Effect.Effect<void>;
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 SnapshotService extends ServiceMap.Service<
SnapshotService,
SnapshotService.Service
>()("@opencode/Snapshot") {
static readonly layer = Layer.effect(
SnapshotService,
Effect.gen(function* () {
const ctx = yield* InstanceContext;
const fileSystem = yield* FileSystem.FileSystem;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const { directory, worktree, project } = ctx;
const isGit = project.vcs === "git";
const snapshotGit = path.join(Global.Path.data, "snapshot", project.id);
const gitArgs = (cmd: string[]) => [
"--git-dir",
snapshotGit,
"--work-tree",
worktree,
...cmd,
];
// Run git with nothrow semantics — always returns a result, never fails
const git = (
args: string[],
opts?: { cwd?: string; env?: Record<string, string> },
): Effect.Effect<GitResult> =>
Effect.gen(function* () {
const command = ChildProcess.make("git", args, {
cwd: opts?.cwd,
env: opts?.env,
extendEnv: true,
});
const handle = yield* spawner.spawn(command);
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 };
}).pipe(
Effect.scoped,
Effect.catch((err) =>
Effect.succeed({
code: ChildProcessSpawner.ExitCode(1),
text: "",
stderr: String(err),
}),
),
);
// FileSystem helpers — orDie converts PlatformError to defects
const exists = (p: string) => fileSystem.exists(p).pipe(Effect.orDie);
const mkdir = (p: string) =>
fileSystem.makeDirectory(p, { recursive: true }).pipe(Effect.orDie);
const writeFile = (p: string, content: string) =>
fileSystem.writeFileString(p, content).pipe(Effect.orDie);
const readFile = (p: string) =>
fileSystem
.readFileString(p)
.pipe(Effect.catch(() => Effect.succeed("")));
const removeFile = (p: string) =>
fileSystem.remove(p).pipe(Effect.catch(() => Effect.void));
// --- internal Effect helpers ---
const isEnabled = Effect.gen(function* () {
if (!isGit) return false;
const cfg = yield* Effect.promise(() => Config.get());
return cfg.snapshot !== false;
});
const excludesPath = Effect.gen(function* () {
const result = yield* git(
["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"],
{
cwd: worktree,
},
);
const file = result.text.trim();
if (!file) return undefined;
if (!(yield* exists(file))) return undefined;
return file;
});
const syncExclude = Effect.gen(function* () {
const file = yield* excludesPath;
const target = path.join(snapshotGit, "info", "exclude");
yield* mkdir(path.join(snapshotGit, "info"));
if (!file) {
yield* writeFile(target, "");
return;
}
const text = yield* readFile(file);
yield* writeFile(target, text);
});
const add = Effect.gen(function* () {
yield* syncExclude;
yield* git([...GIT_CFG, ...gitArgs(["add", "."])], { cwd: directory });
});
// --- service methods ---
const cleanup = Effect.fn("SnapshotService.cleanup")(function* () {
if (!(yield* isEnabled)) return;
if (!(yield* exists(snapshotGit))) return;
const result = yield* git(gitArgs(["gc", `--prune=${PRUNE}`]), {
cwd: directory,
});
if (result.code !== 0) {
log.warn("cleanup failed", {
exitCode: result.code,
stderr: result.stderr,
});
return;
}
log.info("cleanup", { prune: PRUNE });
});
const track = Effect.fn("SnapshotService.track")(function* () {
if (!(yield* isEnabled)) return undefined;
const existed = yield* exists(snapshotGit);
yield* mkdir(snapshotGit);
if (!existed) {
yield* git(["init"], {
env: { GIT_DIR: snapshotGit, GIT_WORK_TREE: worktree },
});
yield* git([
"--git-dir",
snapshotGit,
"config",
"core.autocrlf",
"false",
]);
yield* git([
"--git-dir",
snapshotGit,
"config",
"core.longpaths",
"true",
]);
yield* git([
"--git-dir",
snapshotGit,
"config",
"core.symlinks",
"true",
]);
yield* git([
"--git-dir",
snapshotGit,
"config",
"core.fsmonitor",
"false",
]);
log.info("initialized");
}
yield* add;
const result = yield* git(gitArgs(["write-tree"]), { cwd: directory });
const hash = result.text.trim();
log.info("tracking", { hash, cwd: directory, git: snapshotGit });
return hash;
});
const patch = Effect.fn("SnapshotService.patch")(function* (
hash: string,
) {
yield* add;
const result = yield* git(
[
...GIT_CFG_QUOTE,
...gitArgs([
"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: [] } as Snapshot.Patch;
}
return {
hash,
files: result.text
.trim()
.split("\n")
.map((x: string) => x.trim())
.filter(Boolean)
.map((x: string) => path.join(worktree, x).replaceAll("\\", "/")),
} as Snapshot.Patch;
});
const restore = Effect.fn("SnapshotService.restore")(function* (
snapshot: string,
) {
log.info("restore", { commit: snapshot });
const result = yield* git(
[...GIT_CORE, ...gitArgs(["read-tree", snapshot])],
{ cwd: worktree },
);
if (result.code === 0) {
const checkout = yield* git(
[...GIT_CORE, ...gitArgs(["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("SnapshotService.revert")(function* (
patches: Snapshot.Patch[],
) {
const seen = new Set<string>();
for (const item of patches) {
for (const file of item.files) {
if (seen.has(file)) continue;
log.info("reverting", { file, hash: item.hash });
const result = yield* git(
[...GIT_CORE, ...gitArgs(["checkout", item.hash, "--", file])],
{
cwd: worktree,
},
);
if (result.code !== 0) {
const relativePath = path.relative(worktree, file);
const checkTree = yield* git(
[
...GIT_CORE,
...gitArgs(["ls-tree", item.hash, "--", relativePath]),
],
{
cwd: worktree,
},
);
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 });
yield* removeFile(file);
}
}
seen.add(file);
}
}
});
const diff = Effect.fn("SnapshotService.diff")(function* (hash: string) {
yield* add;
const result = yield* git(
[
...GIT_CFG_QUOTE,
...gitArgs(["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("SnapshotService.diffFull")(function* (
from: string,
to: string,
) {
const result: Snapshot.FileDiff[] = [];
const status = new Map<string, "added" | "deleted" | "modified">();
const statuses = yield* git(
[
...GIT_CFG_QUOTE,
...gitArgs([
"diff",
"--no-ext-diff",
"--name-status",
"--no-renames",
from,
to,
"--",
".",
]),
],
{ cwd: directory },
);
for (const line of statuses.text.trim().split("\n")) {
if (!line) continue;
const [code, file] = line.split("\t");
if (!code || !file) continue;
const kind = code.startsWith("A")
? "added"
: code.startsWith("D")
? "deleted"
: "modified";
status.set(file, kind);
}
const numstat = yield* git(
[
...GIT_CFG_QUOTE,
...gitArgs([
"diff",
"--no-ext-diff",
"--no-renames",
"--numstat",
from,
to,
"--",
".",
]),
],
{ cwd: directory },
);
for (const line of numstat.text.trim().split("\n")) {
if (!line) continue;
const [additions, deletions, file] = line.split("\t");
const isBinaryFile = additions === "-" && deletions === "-";
const [before, after] = isBinaryFile
? ["", ""]
: yield* Effect.all(
[
git([
...GIT_CFG,
...gitArgs(["show", `${from}:${file}`]),
]).pipe(Effect.map((r) => r.text)),
git([...GIT_CFG, ...gitArgs(["show", `${to}:${file}`])]).pipe(
Effect.map((r) => r.text),
),
],
{ concurrency: 2 },
);
const added = isBinaryFile ? 0 : parseInt(additions!);
const deleted = isBinaryFile ? 0 : parseInt(deletions!);
result.push({
file: file!,
before,
after,
additions: Number.isFinite(added) ? added : 0,
deletions: Number.isFinite(deleted) ? deleted : 0,
status: status.get(file!) ?? "modified",
});
}
return result;
});
// Start hourly cleanup fiber — scoped to instance lifetime
yield* cleanup().pipe(
Effect.catchCause((cause) => {
log.error("cleanup loop failed", { cause: Cause.pretty(cause) });
return Effect.void;
}),
Effect.repeat(Schedule.spaced(Duration.hours(1))),
Effect.forkScoped,
);
return SnapshotService.of({
init: Effect.fn("SnapshotService.init")(function* () {}),
cleanup,
track,
patch,
restore,
revert,
diff,
diffFull,
});
}),
).pipe(
Layer.provide(NodeChildProcessSpawner.layer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
);
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
.object({
file: z.string(),
before: z.string(),
after: z.string(),
additions: z.number(),
deletions: z.number(),
status: z.enum(["added", "deleted", "modified"]).optional(),
})
.meta({
ref: "FileDiff",
})
export type FileDiff = z.infer<typeof FileDiff>
export async function diffFull(from: string, to: string): Promise<FileDiff[]> {
const git = gitdir()
const result: FileDiff[] = []
const status = new Map<string, "added" | "deleted" | "modified">()
const statuses = 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-status", "--no-renames", from, to, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
).then((x) => x.text)
for (const line of statuses.trim().split("\n")) {
if (!line) continue
const [code, file] = line.split("\t")
if (!code || !file) continue
const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified"
status.set(file, kind)
}
for (const line of await Process.lines(
[
"git",
"-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, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)) {
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({
file,
before,
after,
additions: Number.isFinite(added) ? added : 0,
deletions: Number.isFinite(deleted) ? deleted : 0,
status: status.get(file) ?? "modified",
})
}
return result
}
function gitdir() {
const project = Instance.project
return path.join(Global.Path.data, "snapshot", project.id)
}
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,
},
)
}
async function syncExclude(git: string) {
const file = await excludes()
const target = path.join(git, "info", "exclude")
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()
}
}

View File

@@ -1,52 +0,0 @@
import path from "path"
import { Log } from "../util/log"
import { TRUNCATION_DIR } from "./truncation-dir"
import { Identifier } from "../id/id"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap } from "effect"
const log = Log.create({ service: "truncation" })
const RETENTION = Duration.days(7)
export namespace TruncateService {
export interface Service {
readonly cleanup: () => Effect.Effect<void>
}
}
export class TruncateService extends ServiceMap.Service<TruncateService, TruncateService.Service>()(
"@opencode/Truncate",
) {
static readonly layer = Layer.effect(
TruncateService,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const cleanup = Effect.fn("TruncateService.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))
}
})
// Start hourly cleanup — scoped to runtime lifetime
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.forkScoped,
)
return TruncateService.of({ cleanup })
}),
).pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
}

View File

@@ -1,4 +0,0 @@
import path from "path"
import { Global } from "../global"
export const TRUNCATION_DIR = path.join(Global.Path.data, "tool-output")

View File

@@ -1,18 +1,21 @@
import fs from "fs/promises"
import path from "path"
import { Global } from "../global"
import { Identifier } from "../id/id"
import { PermissionNext } from "../permission/next"
import { TRUNCATION_DIR } from "./truncation-dir"
import type { Agent } from "../agent/agent"
import { Scheduler } from "../scheduler"
import { Filesystem } from "../util/filesystem"
import { Glob } from "../util/glob"
import { ToolID } from "./schema"
import { TruncateService } from "./truncate-service"
import { runtime } from "@/effect/runtime"
export namespace Truncate {
export const MAX_LINES = 2000
export const MAX_BYTES = 50 * 1024
export const DIR = TRUNCATION_DIR
export const GLOB = path.join(TRUNCATION_DIR, "*")
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 }
@@ -22,8 +25,22 @@ export namespace Truncate {
direction?: "head" | "tail"
}
export function init() {
Scheduler.register({
id: "tool.truncation.cleanup",
interval: HOUR_MS,
run: cleanup,
scope: "global",
})
}
export async function cleanup() {
return runtime.runPromise(TruncateService.use((s) => s.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 {

View File

@@ -1,14 +1,14 @@
import { ConfigProvider, Layer, ManagedRuntime } from "effect";
import { InstanceContext } from "../../src/effect/instance-context";
import { Instance } from "../../src/project/instance";
import { ConfigProvider, Layer, ManagedRuntime } from "effect"
import { InstanceContext } from "../../src/effect/instance-context"
import { Instance } from "../../src/project/instance"
/** ConfigProvider that enables the experimental file watcher. */
export const watcherConfigLayer = ConfigProvider.layer(
ConfigProvider.fromUnknown({
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
}),
);
ConfigProvider.fromUnknown({
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
}),
)
/**
* Boot an Instance with the given service layers and run `body` with
@@ -19,35 +19,29 @@ export const watcherConfigLayer = ConfigProvider.layer(
* Pass extra layers via `options.provide` (e.g. ConfigProvider.layer).
*/
export function withServices<S>(
directory: string,
layer: Layer.Layer<S, any, InstanceContext>,
body: (rt: ManagedRuntime.ManagedRuntime<S, never>) => Promise<void>,
options?: { provide?: Layer.Layer<never>[] },
directory: string,
layer: Layer.Layer<S, any, InstanceContext>,
body: (rt: ManagedRuntime.ManagedRuntime<S, never>) => Promise<void>,
options?: { provide?: Layer.Layer<never>[] },
) {
return Instance.provide({
directory,
fn: async () => {
const ctx = Layer.sync(InstanceContext, () =>
InstanceContext.of({
directory: Instance.directory,
worktree: Instance.worktree,
project: Instance.project,
}),
);
let resolved: Layer.Layer<S> = Layer.fresh(layer).pipe(
Layer.provide(ctx),
) as any;
if (options?.provide) {
for (const l of options.provide) {
resolved = resolved.pipe(Layer.provide(l)) as any;
}
}
const rt = ManagedRuntime.make(resolved);
try {
await body(rt);
} finally {
await rt.dispose();
}
},
});
return Instance.provide({
directory,
fn: async () => {
const ctx = Layer.sync(InstanceContext, () =>
InstanceContext.of({ directory: Instance.directory, project: Instance.project }),
)
let resolved: Layer.Layer<S> = Layer.fresh(layer).pipe(Layer.provide(ctx)) as any
if (options?.provide) {
for (const l of options.provide) {
resolved = resolved.pipe(Layer.provide(l)) as any
}
}
const rt = ManagedRuntime.make(resolved)
try {
await body(rt)
} finally {
await rt.dispose()
}
},
})
}

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)
})
})

View File

@@ -15,6 +15,7 @@ export const lineCommentStyles = `
right: auto;
display: flex;
width: 100%;
min-width: 0;
align-items: flex-start;
}
@@ -64,6 +65,7 @@ export const lineCommentStyles = `
z-index: var(--line-comment-popover-z, 40);
min-width: 200px;
max-width: none;
box-sizing: border-box;
border-radius: 8px;
background: var(--surface-raised-stronger-non-alpha);
box-shadow: var(--shadow-xxs-border);
@@ -75,9 +77,10 @@ export const lineCommentStyles = `
top: auto;
right: auto;
margin-left: 8px;
flex: 0 1 600px;
width: min(100%, 600px);
max-width: min(100%, 600px);
flex: 1 1 0%;
width: auto;
max-width: 100%;
min-width: 0;
}
[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"][data-inline-body] {
@@ -96,23 +99,27 @@ export const lineCommentStyles = `
}
[data-component="line-comment"][data-inline][data-variant="editor"] [data-slot="line-comment-popover"] {
flex-basis: 600px;
width: 100%;
}
[data-component="line-comment"] [data-slot="line-comment-content"] {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
min-width: 0;
}
[data-component="line-comment"] [data-slot="line-comment-head"] {
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 0;
}
[data-component="line-comment"] [data-slot="line-comment-text"] {
flex: 1;
min-width: 0;
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-weight: var(--font-weight-regular);
@@ -120,6 +127,7 @@ export const lineCommentStyles = `
letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
[data-component="line-comment"] [data-slot="line-comment-tools"] {
@@ -127,6 +135,7 @@ export const lineCommentStyles = `
display: flex;
align-items: center;
justify-content: flex-end;
min-width: 0;
}
[data-component="line-comment"] [data-slot="line-comment-label"],
@@ -137,17 +146,22 @@ export const lineCommentStyles = `
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-weak);
white-space: nowrap;
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
}
[data-component="line-comment"] [data-slot="line-comment-editor"] {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
min-width: 0;
}
[data-component="line-comment"] [data-slot="line-comment-textarea"] {
width: 100%;
box-sizing: border-box;
resize: vertical;
padding: 8px;
border-radius: var(--radius-md);
@@ -167,11 +181,14 @@ export const lineCommentStyles = `
[data-component="line-comment"] [data-slot="line-comment-actions"] {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding-left: 8px;
min-width: 0;
}
[data-component="line-comment"] [data-slot="line-comment-editor-label"] {
flex: 1 1 220px;
margin-right: auto;
}

View File

@@ -206,6 +206,16 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
const [text, setText] = createSignal(split.value)
const focus = () => refs.textarea?.focus()
const hold: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> = (e) => {
e.preventDefault()
e.stopPropagation()
}
const click =
(fn: VoidFunction): JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> =>
(e) => {
e.stopPropagation()
fn()
}
createEffect(() => {
setText(split.value)
@@ -268,7 +278,8 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
type="button"
data-slot="line-comment-action"
data-variant="ghost"
on:click={split.onCancel as any}
on:mousedown={hold as any}
on:click={click(split.onCancel) as any}
>
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
</button>
@@ -277,7 +288,8 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
data-slot="line-comment-action"
data-variant="primary"
disabled={text().trim().length === 0}
on:click={submit as any}
on:mousedown={hold as any}
on:click={click(submit) as any}
>
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
</button>

View File

@@ -366,9 +366,11 @@ The model ID in your OpenCode config uses the format `provider/model-id`. For ex
---
### Tools
### Tools (deprecated)
Control which tools are available in this agent with the `tools` config. You can enable or disable specific tools by setting them to `true` or `false`.
`tools` is **deprecated**. Prefer the agent's [`permission`](#permissions) field for new configs, updates and more fine-grained control.
Allows you to control which tools are available in this agent. You can enable or disable specific tools by setting them to `true` or `false`. In an agent's `tools` config, `true` is equivalent to `{"*": "allow"}` permission and `false` is equivalent to `{"*": "deny"}` permission.
```json title="opencode.json" {3-6,9-12}
{
@@ -392,7 +394,7 @@ Control which tools are available in this agent with the `tools` config. You can
The agent-specific config overrides the global config.
:::
You can also use wildcards to control multiple tools at once. For example, to disable all tools from an MCP server:
You can also use wildcards in legacy `tools` entries to control multiple tools at once. For example, to disable all tools from an MCP server:
```json title="opencode.json"
{

View File

@@ -86,7 +86,6 @@ You can also access our models through the following API endpoints.
| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
| Gemini 3 Pro | gemini-3-pro | `https://opencode.ai/zen/v1/models/gemini-3-pro` | `@ai-sdk/google` |
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -95,8 +94,6 @@ You can also access our models through the following API endpoints.
| GLM 4.7 | glm-4.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| GLM 4.6 | glm-4.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2 Thinking | kimi-k2-thinking | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2 | kimi-k2 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Qwen3 Coder 480B | qwen3-coder | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo V2 Flash Free | mimo-v2-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -134,8 +131,6 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
| GLM 4.7 | $0.60 | $2.20 | $0.10 | - |
| GLM 4.6 | $0.60 | $2.20 | $0.10 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Kimi K2 Thinking | $0.40 | $2.50 | - | - |
| Kimi K2 | $0.40 | $2.50 | - | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
@@ -149,8 +144,6 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
| Claude Haiku 3.5 | $0.80 | $4.00 | $0.08 | $1.00 |
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.4 Pro | $30.00 | $180.00 | $30.00 | - |
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
@@ -206,12 +199,13 @@ charging you more than $20 if your balance goes below $5.
| Model | Deprecation date |
| ---------------- | ---------------- |
| Qwen3 Coder 480B | Feb 6, 2026 |
| Kimi K2 Thinking | March 6, 2026 |
| Kimi K2 | March 6, 2026 |
| MiniMax M2.1 | March 15, 2026 |
| GLM 4.7 | March 15, 2026 |
| GLM 4.6 | March 15, 2026 |
| Gemini 3 Pro | March 9, 2026 |
| Kimi K2 Thinking | March 6, 2026 |
| Kimi K2 | March 6, 2026 |
| Qwen3 Coder 480B | Feb 6, 2026 |
---