Compare commits

..

1 Commits

Author SHA1 Message Date
Filip Hejmowski a77c71c93e fix(desktop): install matching V2 CLI in WSL 2026-08-11 17:40:40 +00:00
50 changed files with 580 additions and 927 deletions
+2 -12
View File
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "00924d88-1842-4d71-ac74-5682ddc47e1c",
"prevIds": ["15060ec5-05f7-4b86-b2a5-9108609432b3"],
"id": "15060ec5-05f7-4b86-b2a5-9108609432b3",
"prevIds": ["1551a157-8959-4ba9-a52b-4ea3b7b28cae"],
"ddl": [
{
"name": "account_state",
@@ -1302,16 +1302,6 @@
"entityType": "columns",
"table": "session_v2"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "resume_attempts",
"entityType": "columns",
"table": "session_v2"
},
{
"type": "text",
"notNull": false,
+69 -48
View File
@@ -83,14 +83,8 @@ export function normalize(input: unknown): Result {
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
if (legacyShare !== undefined) encoded.share = legacyShare
const legacyReferences = decodeMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics, decodeEncoded)
const nativeReferences = decodeMap(
input.references,
ConfigReference.Entry,
["references"],
diagnostics,
decodeEncoded,
)
const legacyReferences = decodeEncodedMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics)
const nativeReferences = decodeEncodedMap(input.references, ConfigReference.Entry, ["references"], diagnostics)
mergeMap(
encoded,
"references",
@@ -100,13 +94,13 @@ export function normalize(input: unknown): Result {
diagnostics,
)
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics, decodeValue)
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics)
diagnoseSelectionMap(input.command, ["command"], diagnostics)
const migratedCommands = mapValues(legacyCommands, (value) => {
const migrated = ConfigMigrateV1.commands({ value })?.value
return migrated === undefined ? undefined : canonical(ConfigCommand.Info, migrated)
})
const nativeCommands = decodeMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics, decodeEncoded)
const nativeCommands = decodeEncodedMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics)
mergeMap(
encoded,
"commands",
@@ -116,9 +110,8 @@ export function normalize(input: unknown): Result {
diagnostics,
)
const legacyAgents = mapValues(
decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics, decodeValue),
(value) => canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) =>
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
)
const legacySmallModel = own(input, "small_model")
? decodeValue(Schema.String, input.small_model, ["small_model"], diagnostics)
@@ -137,11 +130,11 @@ export function normalize(input: unknown): Result {
model: migratedSmallModel,
...legacyAgents.title,
}
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics, decodeValue), (value) =>
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) =>
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
)
const migratedAgents = mergeMaps(legacyAgents, modeAgents, ["agents"], diagnostics)
const nativeAgents = decodeMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics, decodeEncoded)
const nativeAgents = decodeEncodedMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics)
diagnoseAgentUnsupported(input.agent, ["agent"], diagnostics)
diagnoseAgentUnsupported(input.mode, ["mode"], diagnostics)
mergeMap(
@@ -154,7 +147,7 @@ export function normalize(input: unknown): Result {
)
const legacyProviders = migrateProviders(input.provider, diagnostics)
const nativeProviders = decodeMap(input.providers, ConfigProvider.Info, ["providers"], diagnostics, decodeEncoded)
const nativeProviders = decodeEncodedMap(input.providers, ConfigProvider.Info, ["providers"], diagnostics)
mergeMap(
encoded,
"providers",
@@ -166,14 +159,14 @@ export function normalize(input: unknown): Result {
const toolRules = migrateTools(input.tools, diagnostics)
const permissionRules = migratePermissions(input.permission, diagnostics)
const nativePermissions = decodeList(input.permissions, Permission.Rule, ["permissions"], diagnostics, decodeEncoded)
const nativePermissions = decodeEncodedList(input.permissions, Permission.Rule, ["permissions"], diagnostics)
const permissions = [...toolRules, ...permissionRules, ...nativePermissions]
if (permissions.length || Array.isArray(input.permissions)) encoded.permissions = permissions
const legacyPlugins = decodeList(input.plugin, ConfigPluginV1.Spec, ["plugin"], diagnostics, decodeValue).map(
(plugin) => (typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] }),
const legacyPlugins = decodeList(input.plugin, ConfigPluginV1.Spec, ["plugin"], diagnostics).map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
)
const nativePlugins = decodeList(input.plugins, ConfigPlugin.Plugin, ["plugins"], diagnostics, decodeEncoded)
const nativePlugins = decodeEncodedList(input.plugins, ConfigPlugin.Plugin, ["plugins"], diagnostics)
if (legacyPlugins.length || nativePlugins.length || Array.isArray(input.plugin) || Array.isArray(input.plugins))
encoded.plugins = [...legacyPlugins, ...nativePlugins]
@@ -207,7 +200,7 @@ export function normalize(input: unknown): Result {
overlay(encoded, key, value, [key], diagnostics)
})
const instructions = decodeList(input.instructions, Schema.String, ["instructions"], diagnostics, decodeEncoded)
const instructions = decodeEncodedList(input.instructions, Schema.String, ["instructions"], diagnostics)
if (instructions.length || Array.isArray(input.instructions)) encoded.instructions = instructions
return { type: "normalized", encoded, diagnostics }
@@ -216,7 +209,7 @@ export function normalize(input: unknown): Result {
function normalizeSkills(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
if (!own(input, "skills")) return
if (Array.isArray(input.skills)) {
encoded.skills = decodeList(input.skills, Schema.String, ["skills"], diagnostics, decodeEncoded)
encoded.skills = decodeEncodedList(input.skills, Schema.String, ["skills"], diagnostics)
return
}
if (!isRecord(input.skills)) {
@@ -224,8 +217,8 @@ function normalizeSkills(input: Record<string, unknown>, encoded: Record<string,
return
}
encoded.skills = [
...decodeList(input.skills.paths, Schema.String, ["skills", "paths"], diagnostics, decodeEncoded),
...decodeList(input.skills.urls, Schema.String, ["skills", "urls"], diagnostics, decodeEncoded),
...decodeEncodedList(input.skills.paths, Schema.String, ["skills", "paths"], diagnostics),
...decodeEncodedList(input.skills.urls, Schema.String, ["skills", "urls"], diagnostics),
]
}
@@ -255,8 +248,8 @@ function normalizeMcp(input: Record<string, unknown>, encoded: Record<string, un
return
}
if (name === "servers" && !isDirectLegacyMcp(value)) {
Object.entries(decodeMap(value, ConfigMCP.Server, path, diagnostics, decodeEncoded)).forEach(
([key, server]) => setOwn(nativeServers, key, server),
Object.entries(decodeEncodedMap(value, ConfigMCP.Server, path, diagnostics)).forEach(([key, server]) =>
setOwn(nativeServers, key, server),
)
return
}
@@ -411,13 +404,7 @@ function normalizeExperimental(
if (value !== undefined) result.subagent_depth = value
}
native.push(
...decodeList(
experimental.policies,
ConfigPolicy.Info,
["experimental", "policies"],
diagnostics,
decodeEncoded,
),
...decodeEncodedList(experimental.policies, ConfigPolicy.Info, ["experimental", "policies"], diagnostics),
)
}
}
@@ -433,7 +420,7 @@ function normalizeWatcher(input: Record<string, unknown>, encoded: Record<string
invalid(["watcher"], diagnostics)
return
}
const ignore = decodeList(input.watcher.ignore, Schema.String, ["watcher", "ignore"], diagnostics, decodeEncoded)
const ignore = decodeEncodedList(input.watcher.ignore, Schema.String, ["watcher", "ignore"], diagnostics)
encoded.watcher = ignore.length || Array.isArray(input.watcher.ignore) ? { ignore } : {}
}
@@ -448,7 +435,7 @@ function normalizeFormatter(
if (value !== undefined) encoded.formatter = value
return
}
const entries = decodeMap(input.formatter, ConfigFormatter.Entry, ["formatter"], diagnostics, decodeEncoded)
const entries = decodeEncodedMap(input.formatter, ConfigFormatter.Entry, ["formatter"], diagnostics)
if (isRecord(input.formatter) && (!Object.keys(input.formatter).length || Object.keys(entries).length))
encoded.formatter = entries
}
@@ -460,7 +447,7 @@ function normalizeLsp(input: Record<string, unknown>, encoded: Record<string, un
if (value !== undefined) encoded.lsp = value
return
}
const entries = decodeMap(input.lsp, ConfigLSP.Entry, ["lsp"], diagnostics, decodeEncoded)
const entries = decodeEncodedMap(input.lsp, ConfigLSP.Entry, ["lsp"], diagnostics)
if (isRecord(input.lsp) && (!Object.keys(input.lsp).length || Object.keys(entries).length)) encoded.lsp = entries
}
@@ -610,44 +597,78 @@ function decodeProviderList(
return {
present: true,
nonEmpty: input[key].length > 0,
values: decodeList(input[key], Schema.String, [key], diagnostics, decodeValue),
values: decodeList(input[key], Schema.String, [key], diagnostics),
}
}
function decodeMap<S extends Schema.Codec<unknown, unknown, never>, A>(
function decodeEncodedMap<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
decode: (schema: S, value: unknown, path: string[], diagnostics: Diagnostic[]) => A | undefined,
): Record<string, A> {
) {
if (value === undefined) return {}
if (!isRecord(value)) {
invalid(path, diagnostics)
return {}
}
return Object.fromEntries(
Object.entries(value).flatMap(([name, raw]): [string, A][] => {
const decoded = decode(schema, raw, [...path, name], diagnostics)
Object.entries(value).flatMap(([name, raw]) => {
const decoded = decodeEncoded(schema, raw, [...path, name], diagnostics)
return decoded === undefined ? [] : [[name, decoded]]
}),
)
}
function decodeList<S extends Schema.Codec<unknown, unknown, never>, A>(
function decodeMap<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
decode: (schema: S, value: unknown, path: string[], diagnostics: Diagnostic[]) => A | undefined,
): A[] {
if (value === undefined) return []
) {
if (value === undefined) return {} as Record<string, S["Type"]>
if (!isRecord(value)) {
invalid(path, diagnostics)
return {} as Record<string, S["Type"]>
}
return Object.fromEntries(
Object.entries(value).flatMap(([name, raw]) => {
const decoded = decodeValue(schema, raw, [...path, name], diagnostics)
return decoded === undefined ? [] : [[name, decoded]]
}),
) as Record<string, S["Type"]>
}
function decodeEncodedList<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
) {
if (value === undefined) return [] as S["Encoded"][]
if (!Array.isArray(value)) {
invalid(path, diagnostics)
return []
return [] as S["Encoded"][]
}
return value.flatMap((item, index) => {
const decoded = decode(schema, item, [...path, String(index)], diagnostics)
const decoded = decodeEncoded(schema, item, [...path, String(index)], diagnostics)
return decoded === undefined ? [] : [decoded]
})
}
function decodeList<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
) {
if (value === undefined) return [] as S["Type"][]
if (!Array.isArray(value)) {
invalid(path, diagnostics)
return [] as S["Type"][]
}
return value.flatMap((item, index) => {
const decoded = decodeValue(schema, item, [...path, String(index)], diagnostics)
return decoded === undefined ? [] : [decoded]
})
}
@@ -27,10 +27,8 @@ export const Plugin = define({
const changes = yield* PubSub.sliding<string>(1)
const lock = Semaphore.makeUnsafe(1)
const start = yield* fs.resolve(location.directory)
const root = yield* fs.resolve(location.project.directory)
const home = yield* fs.resolve(global.home)
const project = discovery.project && FSUtil.contains(root, start)
const stop = FSUtil.contains(home, start) ? home : root
const stop = yield* fs.resolve(location.project.directory)
const project = discovery.project && FSUtil.contains(stop, start)
const globalFile = yield* fs.resolve(join(global.config, "AGENTS.md"))
const loaded: { current: Loaded } = { current: { type: "available", files: [] } }
-2
View File
@@ -40,7 +40,6 @@ import m37 from "./migration/20260622202450_simplify_session_input"
import m38 from "./migration/20260804233008_loose_psylocke"
import m39 from "./migration/20260805200742_import_legacy_credentials"
import m40 from "./migration/20260808023530_workspace_domain"
import m41 from "./migration/20260811161259_execution_claim_attempts"
export const migrations = [
m00,
@@ -84,5 +83,4 @@ export const migrations = [
m38,
m39,
m40,
m41,
] satisfies DatabaseMigration.Migration[]
@@ -1,13 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
id: "20260811161259_execution_claim_attempts",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`resume_attempts\` integer DEFAULT 0 NOT NULL;`)
})
},
}
export default migration
-1
View File
@@ -200,7 +200,6 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
\`time_compacting\` integer,
\`time_archived\` integer,
\`time_suspended\` integer,
\`resume_attempts\` integer DEFAULT 0 NOT NULL,
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
+94 -7
View File
@@ -1,15 +1,102 @@
import { FileFinder } from "@ff-labs/fff-bun"
import { bind } from "./fff"
export type { Directory, DirSearch, File, Init, Mixed, MixedSearch, Picker, Result, Search } from "./fff"
import {
FileFinder,
type DirItem,
type DirSearchResult,
type FileItem,
type InitOptions,
type MixedItem,
type MixedSearchResult,
type SearchResult,
} from "@ff-labs/fff-bun"
declare global {
const FFF_LIBC: "gnu" | "musl"
}
const adapter = bind(FileFinder)
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
export const available = adapter.available
export const create = adapter.create
export type Init = InitOptions
export interface Search {
items: FileItem[]
scores: SearchResult["scores"]
totalMatched: number
totalFiles: number
}
export interface DirSearch {
items: DirItem[]
scores: DirSearchResult["scores"]
totalMatched: number
totalDirs: number
}
export interface MixedSearch {
items: MixedItem[]
scores: MixedSearchResult["scores"]
totalMatched: number
totalFiles: number
totalDirs: number
}
export type File = FileItem
export type Directory = DirItem
export type Mixed = MixedItem
export interface Picker {
destroy(): void
isScanning(): boolean
waitForScan(timeoutMs?: number): Promise<Result<boolean>>
refreshGitStatus(): Result<number>
fileSearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<Search>
directorySearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<DirSearch>
mixedSearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<MixedSearch>
trackQuery(query: string, file: string): Result<boolean>
getHistoricalQuery(offset: number): Result<string | null>
}
export function available() {
return FileFinder.isAvailable()
}
export function create(opts: Init): Result<Picker> {
const made = FileFinder.create(opts)
if (!made.ok) return made
const pick = made.value
return {
ok: true,
value: {
destroy: () => pick.destroy(),
isScanning: () => pick.isScanning(),
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
refreshGitStatus: () => pick.refreshGitStatus(),
fileSearch: (query, next) => pick.fileSearch(query, next),
directorySearch: (query, next) => pick.directorySearch(query, next),
mixedSearch: (query, next) => pick.mixedSearch(query, next),
trackQuery: (query, file) => pick.trackQuery(query, file),
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
},
}
}
export * as Fff from "./fff.bun"
+94 -6
View File
@@ -1,12 +1,100 @@
import { bind } from "./fff"
export type { Directory, DirSearch, File, Init, Mixed, MixedSearch, Picker, Result, Search } from "./fff"
import type {
DirItem,
DirSearchResult,
FileItem,
InitOptions,
MixedItem,
MixedSearchResult,
SearchResult,
} from "@ff-labs/fff-node"
const { FileFinder } = await import("@ff-labs/fff-node").catch(() => ({ FileFinder: undefined }))
const adapter = bind(FileFinder, "fff unavailable on node runtime")
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
export const available = adapter.available
export const create = adapter.create
export type Init = InitOptions
export interface Search {
items: FileItem[]
scores: SearchResult["scores"]
totalMatched: number
totalFiles: number
}
export interface DirSearch {
items: DirItem[]
scores: DirSearchResult["scores"]
totalMatched: number
totalDirs: number
}
export interface MixedSearch {
items: MixedItem[]
scores: MixedSearchResult["scores"]
totalMatched: number
totalFiles: number
totalDirs: number
}
export type File = FileItem
export type Directory = DirItem
export type Mixed = MixedItem
export interface Picker {
destroy(): void
isScanning(): boolean
waitForScan(timeoutMs?: number): Promise<Result<boolean>>
refreshGitStatus(): Result<number>
fileSearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<Search>
directorySearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<DirSearch>
mixedSearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<MixedSearch>
trackQuery(query: string, file: string): Result<boolean>
getHistoricalQuery(offset: number): Result<string | null>
}
export function available() {
return FileFinder?.isAvailable() ?? false
}
export function create(opts: Init): Result<Picker> {
if (!FileFinder) return { ok: false, error: "fff unavailable on node runtime" }
const made = FileFinder.create(opts)
if (!made.ok) return made
const pick = made.value
return {
ok: true,
value: {
destroy: () => pick.destroy(),
isScanning: () => pick.isScanning(),
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
refreshGitStatus: () => pick.refreshGitStatus(),
fileSearch: (query, next) => pick.fileSearch(query, next),
directorySearch: (query, next) => pick.directorySearch(query, next),
mixedSearch: (query, next) => pick.mixedSearch(query, next),
trackQuery: (query, file) => pick.trackQuery(query, file),
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
},
}
}
export * as Fff from "./fff.node"
-66
View File
@@ -1,66 +0,0 @@
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
export interface Init {
basePath: string
aiMode?: boolean
disableMmapCache?: boolean
disableContentIndexing?: boolean
}
export interface SearchOptions {
currentFile?: string
pageIndex?: number
pageSize?: number
}
export interface File {
relativePath: string
}
export interface Directory {
relativePath: string
}
export type Mixed = { type: "file"; item: File } | { type: "directory"; item: Directory }
export interface Score {
total: number
}
export interface Search {
items: File[]
scores: Score[]
}
export interface DirSearch {
items: Directory[]
scores: Score[]
}
export interface MixedSearch {
items: Mixed[]
scores: Score[]
}
export interface Picker {
destroy(): void
fileSearch(query: string, options?: SearchOptions): Result<Search>
directorySearch(query: string, options?: SearchOptions): Result<DirSearch>
mixedSearch(query: string, options?: SearchOptions): Result<MixedSearch>
}
export interface Backend {
isAvailable(): boolean
create(options: Init): Result<Picker>
}
export function bind(backend: Backend | undefined, unavailable = "fff unavailable") {
return {
available: () => backend?.isAvailable() ?? false,
create: (options: Init): Result<Picker> =>
backend?.create(options) ?? {
ok: false,
error: unavailable,
},
}
}
+2 -7
View File
@@ -4,7 +4,7 @@ export { Event, ID, Info } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "./app"
import { Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
import { Agent } from "./agent"
import { AISDK } from "./aisdk"
import { Catalog } from "./catalog"
@@ -44,12 +44,7 @@ const layer = Layer.effect(
const inherit = yield* State.inherit()
const loaded = yield* Effect.suspend(() => plugin.effect(host)).pipe(
inherit,
Effect.updateContext((context: Context.Context<never>) =>
Context.make(Scope.Scope, child).pipe(
Context.add(Logger.CurrentLoggers, Context.get(context, Logger.CurrentLoggers)),
Context.add(References.MinimumLogLevel, Context.get(context, References.MinimumLogLevel)),
),
),
Effect.updateContext((_context: Context.Context<never>) => Context.make(Scope.Scope, child)),
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": plugin.id } }),
Effect.andThen(bus.publish(Plugin.Event.Added, { id: Plugin.ID.make(plugin.id) })),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
+10
View File
@@ -101,6 +101,16 @@ export const Plugin = define({
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
})
draft.update(Agent.ID.make("plan"), (item) => {
item.name = Agent.Name.make("Plan")
item.description = "Plan mode. Disallows all edit tools."
item.mode = "primary"
item.permissions.push(
{ action: "question", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
)
})
draft.update(Agent.ID.make("general"), (item) => {
item.name = Agent.Name.make("General")
item.description =
-2
View File
@@ -61,7 +61,6 @@ import { WellKnown } from "../wellknown"
import { WriteTool } from "../tool/plugin/write"
import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { PlanPlugin } from "./plan"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { WebSearchPlugins } from "./websearch"
@@ -197,7 +196,6 @@ export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
WellKnownPlugin.Plugin,
AgentPlugin.Plugin,
PlanPlugin.Plugin,
CommandPlugin.Plugin,
SkillPlugin.Plugin,
...SystemPromptPlugin.Plugins,
-70
View File
@@ -1,70 +0,0 @@
export * as PlanPlugin from "./plan"
import { ToolFailure } from "@opencode-ai/ai"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Agent } from "../agent"
import { SessionEvent } from "../session/event"
const plan = Agent.ID.make("plan")
const enter = `<system-reminder>
You are in Plan mode. You are not allowed to edit or create files, and you may not ask a subagent to do that either.
You are in Plan mode until the user switches agents. Plan mode is not changed by user intent, tone, or imperative language. If the user asks you to change files, do not edit. Tell them they need to switch agents.
</system-reminder>`
const leave = `<system-reminder>
You are NO LONGER in Plan mode. The previous Plan restrictions no longer apply. Any Plan mode instructions from earlier in this conversation are no longer active.
</system-reminder>`
export const Plugin = define({
id: "opencode.plan",
effect: Effect.fn(function* (ctx) {
yield* ctx.agent.transform((draft) => {
draft.update(plan, (item) => {
item.name = Agent.Name.make("Plan")
item.description = "Read-only agent for exploring the codebase and planning work before implementation."
item.mode = "primary"
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
})
})
yield* ctx.tool.hook("execute.before", (event) => {
if (event.agent !== plan) return Effect.void
if (event.tool !== "edit" && event.tool !== "write" && event.tool !== "patch") return Effect.void
return new ToolFailure({
message: `Cannot use ${event.tool} in Plan mode. You are in a read-only mode and must not modify files.`,
})
})
yield* ctx.event.subscribe().pipe(
Stream.filter(
(event): event is SessionEvent.Created | SessionEvent.AgentSelected =>
event.type === "session.created" || event.type === "session.agent.selected",
),
Stream.runForEach((event) => {
const text = reminder(event)
if (!text) return Effect.void
return ctx.session
.synthetic({
sessionID: event.data.sessionID,
text,
resume: false,
})
.pipe(Effect.catch(() => Effect.void))
}),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
function reminder(event: SessionEvent.Created | SessionEvent.AgentSelected) {
if (event.type === "session.created") {
if (event.data.agent !== plan) return
return enter
}
if (event.data.agent === event.data.previous) return
if (event.data.agent === plan) return enter
if (event.data.previous === plan) return leave
}
+1 -1
View File
@@ -30,7 +30,7 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
draft.update("exa", (integration) => (integration.name = "Exa"))
draft.method.update({
integrationID: "exa",
method: { type: "key" },
method: { type: "key", label: "API key (optional)" },
})
draft.method.update({
integrationID: "exa",
@@ -41,7 +41,7 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
draft.update("firecrawl", (integration) => (integration.name = "Firecrawl"))
draft.method.update({
integrationID: "firecrawl",
method: { type: "key" },
method: { type: "key", label: "API key (optional)" },
})
draft.method.update({
integrationID: "firecrawl",
@@ -56,7 +56,7 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
draft.update("parallel", (integration) => (integration.name = "Parallel"))
draft.method.update({
integrationID: "parallel",
method: { type: "key" },
method: { type: "key", label: "API key (optional)" },
})
draft.method.update({
integrationID: "parallel",
+8 -20
View File
@@ -56,22 +56,16 @@ export const layer = Layer.effect(
),
Effect.asVoid,
)
// Write-ahead claim: starting records the durable intent that a turn is in flight, in the same
// transaction as the started event. Terminals release it — except shutdown interruption, which
// preserves the claim so the next server start resumes the turn. A claim that survives with no
// terminal is the signature of a process that died without teardown (crash, SIGKILL, eviction);
// recovery is a property of the database, never of a shutdown hook that may not run.
const claimOnCommit = (sessionID: SessionSchema.ID) => ({
commit: () => store.claim(sessionID),
})
const releaseOnCommit = (sessionID: SessionSchema.ID) => ({
commit: () => store.release(sessionID),
// Starting or finishing on its own clears stale suspension; interruption preserves it because
// managed-server teardown suspends active Sessions immediately before interrupting their drains.
const clearSuspensionOnCommit = (sessionID: SessionSchema.ID) => ({
commit: () => Effect.asVoid(store.consumeSuspended(sessionID)),
})
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
started: (sessionID) =>
reportLifecycle(
sessionID,
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
bus.publish(SessionEvent.Execution.Started, { sessionID }, clearSuspensionOnCommit(sessionID)),
),
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
const session = yield* store.get(sessionID)
@@ -92,17 +86,11 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const outcome = terminal(exit, reason)
if (outcome.type === "succeeded") {
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID }, releaseOnCommit(sessionID))
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID }, clearSuspensionOnCommit(sessionID))
return
}
if (outcome.type === "interrupted") {
// A user cancel (or a superseding execution) releases the claim: the turn must not
// resurrect at the next boot. Shutdown interruption keeps it for restart continuity.
yield* bus.publish(
SessionEvent.Execution.Interrupted,
{ sessionID, reason: outcome.reason },
outcome.reason === "shutdown" ? undefined : releaseOnCommit(sessionID),
)
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: outcome.reason })
return
}
yield* bus.publish(
@@ -111,7 +99,7 @@ export const layer = Layer.effect(
sessionID,
error: outcome.error,
},
releaseOnCommit(sessionID),
clearSuspensionOnCommit(sessionID),
)
}),
),
+38 -88
View File
@@ -5,111 +5,61 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../../bus"
import { SessionEvent } from "../event"
import { SessionExecution } from "../execution"
import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
const CONTINUE_AFTER_SERVER_RESTART =
"The server restarted while you were working. Continue from where you left off without repeating completed work."
const RESUME_EXHAUSTED = {
type: "aborted",
message: "Execution was interrupted repeatedly and will not be resumed automatically.",
} as const
export interface Options {
/**
* Times a single turn may be resumed before it is terminalized instead.
* The counter is durable and only a terminal event resets it, so a turn
* that keeps dying cannot crash-loop across restarts. Turns that complete
* never accumulate: the budget is per-turn, not per-session.
*/
readonly maxAttempts?: number
}
const DEFAULT_MAX_ATTEMPTS = 10
export interface Interface {
/**
* Resumes Sessions whose execution claim was never released — turns orphaned
* by a process that died without teardown, or interrupted by a graceful
* shutdown (which preserves the claim on purpose). The claim is never
* cleared here: only a terminal event releases it, so a death anywhere in
* the resume path leaves the same orphaned claim for the next boot.
* Marks every execution active in this process for resumption by the next server start.
* Call once new work has stopped arriving and before teardown interrupts the drains.
*/
readonly suspendActiveSessions: Effect.Effect<void>
/** Resumes suspended Sessions. Each suspension is consumed atomically, so a Session resumes at most once. */
readonly resumeSuspendedSessions: Effect.Effect<void>
}
/**
* Recovery for orphaned executions. Claims are written at turn start by
* SessionExecution, so this sweep needs no cooperation from the previous
* process: crash, SIGKILL, isolate eviction, and graceful restart all leave
* the same durable signature.
*
* The sweep assumes every orphaned claim's owner is dead. The managed-server
* protocol guarantees this: a successor is only spawned after the previous
* process is confirmed dead (client service `kill`/`evict` poll the PID), the
* registration lock admits one managed server at a time, and unregistered
* servers sharing the database never sweep. The service is inert until called
* — the managed server invokes it at boot; embedders may call it from their
* own start-up.
* Restart continuity actions for the managed server. The service is inert until called: only the
* managed server invokes it, so default, embedded, and stdio servers never suspend or auto-resume.
*/
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRestart") {}
export const layer = (options?: Options) =>
Layer.effect(
Service,
Effect.gen(function* () {
const store = yield* SessionStore.Service
const execution = yield* SessionExecution.Service
const bus = yield* Bus.Service
const scope = yield* Effect.scope
const maxAttempts = options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS
const resumeOne = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
// Durable before the resume runs, so a crash inside the resumed turn is
// counted by the next sweep and the budget cannot be dodged.
const attempts = yield* store.countResume(sessionID)
if (attempts === undefined) return // the Session was deleted since listing
if (attempts > maxAttempts) {
// Terminalize instead: the release hook clears the claim and resets the
// counter atomically with the terminal event.
yield* bus.publish(
SessionEvent.Execution.Failed,
{ sessionID, error: RESUME_EXHAUSTED },
{ commit: () => store.release(sessionID) },
)
return
}
yield* bus.publish(SessionEvent.Synthetic, {
sessionID,
text: CONTINUE_AFTER_SERVER_RESTART,
description: "Continuing after restart",
})
// Forked into the service scope so boot never waits on resumed turns;
// resuming an already-live Session joins its execution. Drain failures
// are logged and durably recorded by the execution layer.
yield* execution.resume(sessionID).pipe(Effect.ignore, Effect.forkIn(scope))
})
return Service.of({
resumeSuspendedSessions: Effect.gen(function* () {
// Child claims never drive recovery (children are not resumed), so a
// dead child's claim is noise no terminal will ever release. Clearing
// is safe even against a live child: claims are recovery markers, not
// locks, and children are excluded from that recovery.
yield* store.releaseChildClaims
const active = yield* execution.active
// Sessions already draining in this process keep their claim; resuming
// them would only inject a stray continuation into a live turn.
const orphaned = (yield* store.listSuspended()).filter((sessionID) => !active.has(sessionID))
yield* Effect.forEach(orphaned, resumeOne, { concurrency: "unbounded", discard: true })
}),
})
}),
)
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const store = yield* SessionStore.Service
const execution = yield* SessionExecution.Service
const bus = yield* Bus.Service
return Service.of({
suspendActiveSessions: Effect.gen(function* () {
yield* store.suspend(yield* execution.active)
}),
resumeSuspendedSessions: Effect.gen(function* () {
const sessions = yield* store.listSuspended()
yield* Effect.forEach(
sessions,
(sessionID) =>
Effect.gen(function* () {
if (!(yield* store.consumeSuspended(sessionID))) return
yield* bus.publish(SessionEvent.Synthetic, {
sessionID,
text: CONTINUE_AFTER_SERVER_RESTART,
description: "Continuing after restart",
})
// Drain failures are already logged and durably recorded by the execution layer.
yield* Effect.ignore(execution.resume(sessionID))
}),
{ concurrency: "unbounded", discard: true },
)
}),
})
}),
)
export const node = makeGlobalNode({
service: Service,
layer: layer(),
layer,
deps: [SessionStore.node, SessionExecution.node, Bus.node],
})
+4 -3
View File
@@ -1,9 +1,8 @@
import { and, asc, desc, eq, gte, sql } from "drizzle-orm"
import { Effect } from "effect"
import { Effect, Schema } from "effect"
import { Database } from "../database/database"
import { MessageDecodeError } from "./error"
import { SessionMessage } from "./message"
import { SessionMessageRow } from "./message-row"
import { SessionSchema } from "./schema"
import { Instructions } from "../instructions/index"
import { InstructionState } from "./instruction-state"
@@ -11,6 +10,8 @@ import { SessionMessageTable } from "./sql"
type DatabaseService = Database.Interface["db"]
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select({ seq: SessionMessageTable.seq })
@@ -49,7 +50,7 @@ const messageRows = Effect.fnUntraced(function* (
})
const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
SessionMessageRow.decode(row).pipe(
decode({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.mapError(
() =>
new MessageDecodeError({
-20
View File
@@ -1,20 +0,0 @@
export * as SessionMessageRow from "./message-row"
import { Schema } from "effect"
import { SessionMessage } from "./message"
import type { SessionMessageTable } from "./sql"
export type Representation = Pick<typeof SessionMessageTable.$inferSelect, "id" | "type" | "data">
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
const decodeMessageSync = Schema.decodeUnknownSync(SessionMessage.Info)
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
export const decode = (row: Representation) => decodeMessage({ ...row.data, id: row.id, type: row.type })
export const decodeSync = (row: Representation) => decodeMessageSync({ ...row.data, id: row.id, type: row.type })
export function encode(message: SessionMessage.Info): Representation {
const { id, type, ...data } = encodeMessage(message)
return { id: SessionMessage.ID.make(id), type, data }
}
+2 -2
View File
@@ -17,7 +17,6 @@ import { Bus } from "../bus"
import { KeyedMutex } from "../effect/keyed-mutex"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { SessionMessageRow } from "./message-row"
import { SessionSchema } from "./schema"
import { SessionMessageTable, SessionPendingTable } from "./sql"
@@ -36,6 +35,7 @@ const decodeUser = Schema.decodeUnknownSync(UserData)
const encodeUser = Schema.encodeSync(UserData)
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
const encodeSynthetic = Schema.encodeSync(SyntheticData)
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }
@@ -113,7 +113,7 @@ const promotedFromMessage = Effect.fn("SessionPending.promotedFromMessage")(func
if (row === undefined) return undefined
if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
return yield* Effect.die(new LifecycleConflict({ id }))
const message = SessionMessageRow.decodeSync(row)
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
const base = { id, sessionID, timeCreated: message.time.created, delivery }
if (message.type === "user")
return User.make({
+19 -9
View File
@@ -9,7 +9,6 @@ import { Agent } from "../agent"
import { Model } from "../model"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { SessionMessageRow } from "./message-row"
import { SessionMessageUpdater } from "./message-updater"
import { SessionPending } from "./pending"
import { Workspace } from "../workspace"
@@ -23,6 +22,9 @@ type DatabaseService = Database.Interface["db"]
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
type MessageEvent = Exclude<CurrentDurableEvent, typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type>
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
export class SessionAlreadyProjected extends Error {}
type Usage = {
@@ -208,15 +210,22 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
function run(db: DatabaseService, event: MessageEvent) {
return Effect.gen(function* () {
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) => SessionMessageRow.decodeSync(row)
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type })
const updateMessage = (message: SessionMessage.Info) => {
if (event.durable === undefined)
return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
const row = SessionMessageRow.encode(message)
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
return db
.update(SessionMessageTable)
.set({ type: row.type, time_created: DateTime.toEpochMillis(message.time.created), data: row.data })
.where(and(eq(SessionMessageTable.id, row.id), eq(SessionMessageTable.session_id, event.data.sessionID)))
.set({ type, time_created: DateTime.toEpochMillis(message.time.created), data })
.where(
and(
eq(SessionMessageTable.id, SessionMessage.ID.make(id)),
eq(SessionMessageTable.session_id, event.data.sessionID),
),
)
.run()
.pipe(Effect.orDie)
}
@@ -334,16 +343,17 @@ function run(db: DatabaseService, event: MessageEvent) {
function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Info) {
if (event.durable === undefined) return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
const row = SessionMessageRow.encode(message)
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
return db
.insert(SessionMessageTable)
.values({
id: row.id,
id: SessionMessage.ID.make(id),
session_id: event.data.sessionID,
type: row.type,
type,
seq: event.durable.seq,
time_created: DateTime.toEpochMillis(message.time.created),
data: row.data,
data,
})
.run()
.pipe(Effect.orDie)
+2 -2
View File
@@ -8,7 +8,6 @@ import { RelativePath } from "../schema"
import { Snapshot } from "../snapshot"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { SessionMessageRow } from "./message-row"
import { SessionSchema } from "./schema"
import { SessionMessageTable } from "./sql"
@@ -47,9 +46,10 @@ const plan = Effect.fn("SessionRevert.plan")(function* (input: BoundaryInput) {
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
const files = new Map<RelativePath, Snapshot.ID>()
for (const row of rows) {
const message = yield* SessionMessageRow.decode(row).pipe(Effect.orDie)
const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
if (message.type !== "assistant" || !message.snapshot?.start) continue
for (const file of message.snapshot.files ?? [])
if (!files.has(file)) files.set(file, Snapshot.ID.make(message.snapshot.start))
-2
View File
@@ -58,9 +58,7 @@ export const SessionTable = sqliteTable(
...Timestamps,
time_compacting: integer(),
time_archived: integer(),
/** The execution claim timestamp (historical column name; see SessionStore.claim). */
time_suspended: integer(),
resume_attempts: integer().notNull().default(0),
},
(table) => [
index("session_v2_project_idx").on(table.project_id),
+26 -64
View File
@@ -1,13 +1,12 @@
export * as SessionStore from "./store"
import { and, eq, isNotNull, isNull, sql } from "drizzle-orm"
import { Context, Effect, Layer } from "effect"
import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { SessionHistory } from "./history"
import { MessageDecodeError } from "./error"
import { SessionMessage } from "./message"
import { SessionMessageRow } from "./message-row"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessageTable, SessionTable } from "./sql"
import { fromRow } from "./info"
@@ -18,32 +17,10 @@ export interface Interface {
readonly message: (
messageID: SessionMessage.ID,
) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Info } | undefined>
/**
* Top-level Sessions holding an execution claim. Child (subagent) Sessions
* are excluded: a resumed parent re-runs its tool call and spawns fresh
* children, so resuming orphaned children would duplicate their work.
*/
readonly listSuspended: () => Effect.Effect<ReadonlyArray<Session.ID>>
/**
* Records the execution claim: the durable write-ahead intent that a turn is
* (or was) in flight. Set when execution starts; a claim that survives to the
* next boot marks a turn that never completed — its process crashed or shut
* down mid-turn.
*/
readonly claim: (sessionID: Session.ID) => Effect.Effect<void>
/** Releases the claim and resets resume accounting. Terminal events call this on commit. */
readonly release: (sessionID: Session.ID) => Effect.Effect<void>
/**
* Clears orphaned child (subagent) claims. Children are never resumed
* independently, so a dead child's claim is noise no terminal will ever
* release.
*/
readonly releaseChildClaims: Effect.Effect<void>
/**
* Durably counts one more resume of an orphaned claim, returning the new
* total — or undefined when the Session no longer exists.
*/
readonly countResume: (sessionID: Session.ID) => Effect.Effect<number | undefined>
/** Clears suspension, reporting whether this caller consumed it. At most one concurrent caller receives true. */
readonly consumeSuspended: (sessionID: Session.ID) => Effect.Effect<boolean>
readonly suspend: (sessionIDs: Iterable<Session.ID>) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionStore") {}
@@ -52,6 +29,8 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
return Service.of({
get: Effect.fn("SessionStore.get")(function* (sessionID) {
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
@@ -70,7 +49,7 @@ const layer = Layer.effect(
return row
? {
sessionID: Session.ID.make(row.session_id),
message: yield* SessionMessageRow.decode(row).pipe(Effect.orDie),
message: yield* decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie),
}
: undefined
}),
@@ -78,52 +57,35 @@ const layer = Layer.effect(
return yield* db
.select({ sessionID: SessionTable.id })
.from(SessionTable)
.where(and(isNotNull(SessionTable.time_suspended), isNull(SessionTable.parent_id)))
.where(isNotNull(SessionTable.time_suspended))
.all()
.pipe(
Effect.orDie,
Effect.map((rows) => rows.map((row) => row.sessionID)),
)
}),
claim: Effect.fn("SessionStore.claim")(function* (sessionID) {
// The null guard makes re-claiming a still-claimed Session a zero-row
// no-op (a resumed turn re-claims through the same started hook).
// Claim bookkeeping never counts as user activity: time_updated is
// pinned so session ordering only moves on real changes.
consumeSuspended: Effect.fn("SessionStore.consumeSuspended")(function* (sessionID) {
return (
(yield* db
.update(SessionTable)
.set({ time_suspended: null })
.where(and(eq(SessionTable.id, sessionID), isNotNull(SessionTable.time_suspended)))
.returning({ sessionID: SessionTable.id })
.get()
.pipe(Effect.orDie)) !== undefined
)
}),
suspend: Effect.fn("SessionStore.suspend")(function* (sessionIDs) {
const ids = Array.from(sessionIDs)
if (ids.length === 0) return
// The null guard preserves the original suspension time if a Session is somehow suspended twice.
yield* db
.update(SessionTable)
.set({ time_suspended: Date.now(), time_updated: sql`${SessionTable.time_updated}` })
.where(and(eq(SessionTable.id, sessionID), isNull(SessionTable.time_suspended)))
.set({ time_suspended: Date.now() })
.where(and(inArray(SessionTable.id, ids), isNull(SessionTable.time_suspended)))
.run()
.pipe(Effect.orDie)
}),
release: Effect.fn("SessionStore.release")(function* (sessionID) {
yield* db
.update(SessionTable)
.set({ time_suspended: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
.where(eq(SessionTable.id, sessionID))
.run()
.pipe(Effect.orDie)
}),
releaseChildClaims: db
.update(SessionTable)
.set({ time_suspended: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
.where(and(isNotNull(SessionTable.time_suspended), isNotNull(SessionTable.parent_id)))
.run()
.pipe(Effect.orDie, Effect.asVoid, Effect.withSpan("SessionStore.releaseChildClaims")),
countResume: Effect.fn("SessionStore.countResume")(function* (sessionID) {
const row = yield* db
.update(SessionTable)
.set({
resume_attempts: sql`${SessionTable.resume_attempts} + 1`,
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, sessionID))
.returning({ attempts: SessionTable.resume_attempts })
.get()
.pipe(Effect.orDie)
return row?.attempts
}),
})
}),
)
+6 -4
View File
@@ -18,7 +18,6 @@ import { Session } from "../session"
import { Slug } from "../util/slug"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { SessionMessageRow } from "./message-row"
import { SessionProjector } from "./projector"
import { SessionMessageTable, SessionTable } from "./sql"
@@ -48,6 +47,8 @@ const layer = Layer.effect(
const { db } = yield* Database.Service
const projects = yield* Project.Service
const sessions = yield* Session.Service
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
return Service.of({
@@ -70,14 +71,15 @@ const layer = Layer.effect(
const project = yield* projects.resolve(input.location.directory)
yield* persistProject(project)
const messages = input.data.messages.map((message, index) => {
const row = SessionMessageRow.encode(message)
const encoded = encodeMessage(message)
const { id: _, type, ...data } = encoded
return {
id: message.id,
session_id: sessionID,
type: row.type,
type,
seq: index + 1,
time_created: DateTime.toEpochMillis(message.time.created),
data: row.data,
data,
}
})
yield* bus
+1
View File
@@ -165,6 +165,7 @@ describe("Agent", () => {
"compaction",
"explore",
"general",
"plan",
"summary",
"title",
])
@@ -24,7 +24,6 @@ const it = testEffect(Layer.empty)
const instructionLayer = (input: {
config?: string
home?: string
locationServiceLayer: Layer.Layer<Location.Service>
filesystemLayer?: Layer.Layer<FSUtil.Service>
project?: boolean
@@ -35,15 +34,7 @@ const instructionLayer = (input: {
LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]),
[
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
[
Global.node,
input.config || input.home
? Global.layerWith({
...(input.config ? { config: input.config } : {}),
...(input.home ? { home: input.home } : {}),
})
: tempGlobalLayer,
],
[Global.node, input.config ? Global.layerWith({ config: input.config }) : tempGlobalLayer],
[Location.node, input.locationServiceLayer],
[Watcher.node, watcher],
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
@@ -121,13 +112,10 @@ describe("ConfigInstructionPlugin.Plugin", () => {
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const home = path.join(tmp.path, "home")
const shared = path.join(home, "code")
const project = path.join(shared, "repo")
const project = path.join(tmp.path, "project")
const directory = path.join(project, "packages", "core")
const outside = path.join(tmp.path, "AGENTS.md")
const globalFile = path.join(global, "AGENTS.md")
const sharedFile = path.join(shared, "AGENTS.md")
const projectFile = path.join(project, "AGENTS.md")
const packageFile = path.join(directory, "AGENTS.md")
return Effect.gen(function* () {
@@ -136,7 +124,6 @@ describe("ConfigInstructionPlugin.Plugin", () => {
await fs.mkdir(directory, { recursive: true })
await fs.writeFile(outside, "outside")
await fs.writeFile(globalFile, "global")
await fs.writeFile(sharedFile, "shared")
await fs.writeFile(projectFile, "project")
await fs.writeFile(packageFile, "package")
})
@@ -148,20 +135,13 @@ describe("ConfigInstructionPlugin.Plugin", () => {
{ path: packageFile, type: "file" },
{ path: path.join(project, "packages", "AGENTS.md"), type: "file" },
{ path: projectFile, type: "file" },
{ path: sharedFile, type: "file" },
{ path: path.join(home, "AGENTS.md"), type: "file" },
])
expect(yield* watcher.subscriptions()).not.toContainEqual({
path: path.join(tmp.path, "AGENTS.md"),
type: "file",
})
const initialized = yield* readInitial(yield* discovery.load())
expect(initialized.text).toBe(
[
`Instructions from: ${globalFile}\nglobal`,
`Instructions from: ${packageFile}\npackage`,
`Instructions from: ${projectFile}\nproject`,
`Instructions from: ${sharedFile}\nshared`,
].join("\n\n"),
)
expect(initialized.text).not.toContain("outside")
@@ -179,7 +159,6 @@ describe("ConfigInstructionPlugin.Plugin", () => {
"These instructions replace all previously loaded ambient instructions.",
`Instructions from: ${globalFile}\nglobal`,
`Instructions from: ${projectFile}\nproject`,
`Instructions from: ${sharedFile}\nshared`,
].join("\n\n"),
)
@@ -187,8 +166,6 @@ describe("ConfigInstructionPlugin.Plugin", () => {
yield* emitAndWait({ type: "delete", path: globalFile })
yield* Effect.promise(() => fs.rm(projectFile))
yield* emitAndWait({ type: "delete", path: projectFile })
yield* Effect.promise(() => fs.rm(sharedFile))
yield* emitAndWait({ type: "delete", path: sharedFile })
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
"Previously loaded instructions no longer apply.",
)
@@ -196,7 +173,6 @@ describe("ConfigInstructionPlugin.Plugin", () => {
Effect.provide(
instructionLayer({
config: global,
home,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
@@ -239,17 +215,15 @@ describe("ConfigInstructionPlugin.Plugin", () => {
),
)
it.live("discovers a newly created instruction file above the project root", () =>
it.live("discovers a newly created instruction file in an intermediate directory", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const home = path.join(tmp.path, "home")
const shared = path.join(home, "code")
const project = path.join(shared, "repo")
const intermediate = path.join(shared, "AGENTS.md")
const directory = path.join(project, "core")
const project = path.join(tmp.path, "project")
const intermediate = path.join(project, "packages", "AGENTS.md")
const directory = path.join(project, "packages", "core")
const projectFile = path.join(project, "AGENTS.md")
return Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
@@ -261,7 +235,7 @@ describe("ConfigInstructionPlugin.Plugin", () => {
yield* emitAndWait({ type: "create", path: intermediate })
expect((yield* readInitial(yield* discovery.load())).text).toBe(
[`Instructions from: ${projectFile}\nproject`, `Instructions from: ${intermediate}\nintermediate`].join(
[`Instructions from: ${intermediate}\nintermediate`, `Instructions from: ${projectFile}\nproject`].join(
"\n\n",
),
)
@@ -269,48 +243,6 @@ describe("ConfigInstructionPlugin.Plugin", () => {
Effect.provide(
instructionLayer({
config: path.join(tmp.path, "global"),
home,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(project) },
),
),
),
}),
),
)
}),
),
)
it.live("stops instruction candidates at the project root outside home", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const home = path.join(tmp.path, "home")
const project = path.join(tmp.path, "scratch", "repo")
const directory = path.join(project, "packages", "core")
return Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
yield* start()
const watcher = yield* Watcher.Test
expect(yield* watcher.subscriptions()).toEqual([
{ path: path.join(global, "AGENTS.md"), type: "file" },
{ path: path.join(directory, "AGENTS.md"), type: "file" },
{ path: path.join(project, "packages", "AGENTS.md"), type: "file" },
{ path: path.join(project, "AGENTS.md"), type: "file" },
])
}).pipe(
Effect.provide(
instructionLayer({
config: global,
home,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
@@ -3,7 +3,6 @@ import { Effect } from "effect"
import { Integration } from "@opencode-ai/core/integration"
import { WebSearch } from "@opencode-ai/core/websearch"
import { WebSearchExa } from "@opencode-ai/core/plugin/websearch/exa"
import { WebSearchFirecrawl } from "@opencode-ai/core/plugin/websearch/firecrawl"
import { WebSearchParallel } from "@opencode-ai/core/plugin/websearch/parallel"
import { host, integrationHost, webSearchHost } from "./host"
import { requests, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture"
@@ -54,22 +53,6 @@ describe("built-in web search providers", () => {
}),
)
it.effect("registers Firecrawl with the standard key method", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const websearch = yield* WebSearch.Service
yield* WebSearchFirecrawl.Plugin.effect(
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
)
expect(yield* integrations.get(Integration.ID.make("firecrawl"))).toMatchObject({
id: "firecrawl",
name: "Firecrawl",
methods: [{ type: "key" }, { type: "env", names: ["FIRECRAWL_API_KEY"] }],
})
}),
)
it.effect("registers Exa with its MCP schema", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
@@ -146,9 +129,6 @@ describe("built-in web search providers", () => {
yield* WebSearchParallel.Plugin.effect(
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
)
expect(yield* integrations.get(Integration.ID.make("parallel"))).toMatchObject({
methods: [{ type: "key" }, { type: "env", names: ["PARALLEL_API_KEY"] }],
})
yield* integrations.connection.key({
integrationID: Integration.ID.make("parallel"),
key: "parallel-secret",
+32 -178
View File
@@ -18,7 +18,6 @@ import { SessionRunner } from "@opencode-ai/core/session/runner"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Context, Deferred, Effect, Exit, Fiber, Layer, LayerMap, Scope } from "effect"
import { eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionStore.node])))
@@ -50,90 +49,58 @@ describe("SessionExecution lifecycle", () => {
})
})
it.effect("the sweep only lists claimed top-level Sessions", () =>
it.effect("atomically consumes each suspension at most once", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const store = yield* SessionStore.Service
const parent = Session.ID.make("ses_recover_parent")
const child = Session.ID.make("ses_recover_child")
const idle = Session.ID.make("ses_recover_idle")
yield* seedSessions(database, [parent], { time_suspended: Date.now() })
yield* seedSessions(database, [idle])
// An orphaned child is never resumed: the resumed parent re-runs its
// tool call and spawns a fresh child instead.
yield* seedSessions(database, [child], { time_suspended: Date.now(), parent_id: parent })
const first = Session.ID.make("ses_recover_first")
const second = Session.ID.make("ses_recover_second")
yield* seedSessions(database, [first, second], { time_suspended: Date.now() })
expect(yield* store.listSuspended()).toEqual([parent])
// The sweep clears orphaned child claims outright; parents keep theirs.
yield* store.releaseChildClaims
expect(yield* claims(database)).toEqual({ [parent]: true, [child]: false, [idle]: false })
expect(yield* store.consumeSuspended(first)).toBe(true)
expect(yield* store.consumeSuspended(first)).toBe(false)
expect(yield* store.consumeSuspended(second)).toBe(true)
expect(yield* suspensions(database)).toEqual({ [first]: false, [second]: false })
}),
)
it.effect("claims at execution start, releases on completion, and preserves through teardown", () =>
it.effect("suspension survives teardown interruption and clears when a drain finishes on its own", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const interrupted = Session.ID.make("ses_claim_interrupted")
const completed = Session.ID.make("ses_claim_completed")
const interrupted = Session.ID.make("ses_suspend_interrupted")
const completed = Session.ID.make("ses_suspend_completed")
yield* seedSessions(database, [interrupted, completed])
// Each drain signals once it runs; the claim commits before the drain starts.
const interruptedRunning = yield* Deferred.make<void>()
const completedRunning = yield* Deferred.make<void>()
const draining = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const scope = yield* Scope.make()
const context = yield* buildExecution(scope, ({ sessionID }) =>
sessionID === completed
? Deferred.succeed(completedRunning, undefined).pipe(Effect.andThen(Deferred.await(release)))
: Deferred.succeed(interruptedRunning, undefined).pipe(Effect.andThen(Effect.never)),
? Deferred.await(release)
: Deferred.succeed(draining, undefined).pipe(Effect.andThen(Effect.never)),
)
const execution = Context.get(context, SessionExecution.Service)
const restart = Context.get(context, SessionRestart.Service)
yield* execution.resume(interrupted).pipe(Effect.forkScoped)
const completing = yield* execution.resume(completed).pipe(Effect.forkIn(scope))
yield* Deferred.await(interruptedRunning)
yield* Deferred.await(completedRunning)
yield* Deferred.await(draining)
// The write-ahead claim exists WHILE the turns run — no shutdown hook involved.
expect(yield* claims(database)).toEqual({ [interrupted]: true, [completed]: true })
yield* restart.suspendActiveSessions
expect(yield* suspensions(database)).toEqual({ [interrupted]: true, [completed]: true })
// A drain that finishes on its own releases its claim.
// A drain that finishes on its own after suspension clears its stale suspension.
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(completing)
yield* execution.awaitIdle(completed)
expect((yield* claims(database))[completed]).toBe(false)
expect((yield* suspensions(database))[completed]).toBe(false)
// Teardown interruption (graceful twin of an unclean death) preserves the claim
// for the next server start.
// Teardown interruption preserves suspension for the next server start.
yield* Scope.close(scope, Exit.void)
expect((yield* claims(database))[interrupted]).toBe(true)
expect((yield* suspensions(database))[interrupted]).toBe(true)
}),
)
it.effect("a user interrupt releases the claim so the turn never resurrects", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const sessionID = Session.ID.make("ses_claim_user_cancel")
yield* seedSessions(database, [sessionID])
const draining = yield* Deferred.make<void>()
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* buildExecution(scope, () =>
Deferred.succeed(draining, undefined).pipe(Effect.andThen(Effect.never)),
)
const execution = Context.get(context, SessionExecution.Service)
yield* execution.resume(sessionID).pipe(Effect.forkScoped)
yield* Deferred.await(draining)
expect((yield* claims(database))[sessionID]).toBe(true)
yield* execution.interrupt(sessionID)
yield* execution.awaitIdle(sessionID)
expect((yield* claims(database))[sessionID]).toBe(false)
}),
)
it.effect("starts every claimed execution without waiting for earlier drains to finish", () =>
it.effect("starts every suspended execution without waiting for earlier drains to finish", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const sessionIDs = Array.from({ length: 5 }, (_, index) => Session.ID.make(`ses_resume_concurrent_${index}`))
@@ -158,7 +125,7 @@ describe("SessionExecution lifecycle", () => {
}),
)
it.effect("resumes each claimed Session at most once", () =>
it.effect("resumes each suspended Session at most once", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
@@ -167,22 +134,14 @@ describe("SessionExecution lifecycle", () => {
yield* seedSessions(database, [first, second], { time_suspended: Date.now() })
const drained: string[] = []
const bothDraining = yield* Deferred.make<void>()
const continued: SessionEvent.Synthetic[] = []
const scope = yield* Scope.make()
const context = yield* buildExecution(scope, ({ sessionID }) =>
Effect.sync(() => {
drained.push(sessionID)
if (drained.length === 2) Deferred.doneUnsafe(bothDraining, Effect.void)
}),
)
const context = yield* buildExecution(scope, ({ sessionID }) => Effect.sync(() => void drained.push(sessionID)))
const execution = Context.get(context, SessionExecution.Service)
const restart = Context.get(context, SessionRestart.Service)
yield* bus.project(SessionEvent.Synthetic, (event) => Effect.sync(() => void continued.push(event)))
// The sweep forks resumed drains, so completion is observed through the executions.
yield* restart.resumeSuspendedSessions
yield* Deferred.await(bothDraining)
yield* Effect.forEach([first, second], execution.awaitIdle, { discard: true })
expect(drained.toSorted()).toEqual([first, second])
expect(continued.map((event) => event.data).toSorted((a, b) => a.sessionID.localeCompare(b.sessionID))).toEqual(
@@ -192,9 +151,7 @@ describe("SessionExecution lifecycle", () => {
description: "Continuing after restart",
})),
)
// Drains completed naturally, so claims are released and counters reset.
expect(yield* claims(database)).toEqual({ [first]: false, [second]: false })
expect(yield* attempts(database, first)).toBe(0)
expect(yield* suspensions(database)).toEqual({ [first]: false, [second]: false })
yield* restart.resumeSuspendedSessions
expect(drained.length).toBe(2)
@@ -202,104 +159,17 @@ describe("SessionExecution lifecycle", () => {
yield* Scope.close(scope, Exit.void)
}),
)
it.effect("terminalizes a turn that exhausts its resume budget instead of crash-looping", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const sessionID = Session.ID.make("ses_resume_exhausted")
// A claim from a dead process, already resumed twice without completing.
yield* seedSessions(database, [sessionID], { time_suspended: Date.now(), resume_attempts: 2 })
const drained: string[] = []
const failures: SessionEvent.Execution.Failed[] = []
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* buildExecution(scope, ({ sessionID: id }) => Effect.sync(() => void drained.push(id)), {
maxAttempts: 2,
})
const restart = Context.get(context, SessionRestart.Service)
yield* bus.project(SessionEvent.Execution.Failed, (event) => Effect.sync(() => void failures.push(event)))
yield* restart.resumeSuspendedSessions
expect(drained).toEqual([])
expect(failures.map((event) => event.data.error.type)).toEqual(["aborted"])
// The terminal released the claim and reset the counter atomically.
expect(yield* claims(database)).toEqual({ [sessionID]: false })
expect(yield* attempts(database, sessionID)).toBe(0)
}),
)
it.effect("counts every resume durably and never consumes the claim it recovers", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const sessionID = Session.ID.make("ses_resume_counted")
yield* seedSessions(database, [sessionID], { time_suspended: Date.now() })
const draining = yield* Deferred.make<void>()
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
// The drain never terminalizes (mirrors a process that will die mid-turn).
const context = yield* buildExecution(scope, () =>
Deferred.succeed(draining, undefined).pipe(Effect.andThen(Effect.never)),
)
const restart = Context.get(context, SessionRestart.Service)
yield* restart.resumeSuspendedSessions.pipe(Effect.forkIn(scope))
yield* Deferred.await(draining)
// The attempt is durable before the drain runs, and the claim is held
// throughout: a crash anywhere in the resume path leaves both intact.
expect(yield* attempts(database, sessionID)).toBe(1)
expect((yield* claims(database))[sessionID]).toBe(true)
// Teardown (a graceful shutdown's interrupt) preserves both, so the next
// boot counts attempt 2 against the same turn.
yield* Scope.close(scope, Exit.void)
expect((yield* claims(database))[sessionID]).toBe(true)
expect(yield* attempts(database, sessionID)).toBe(1)
}),
)
it.effect("the sweep leaves Sessions already draining in this process untouched", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const sessionID = Session.ID.make("ses_resume_local_active")
yield* seedSessions(database, [sessionID])
const draining = yield* Deferred.make<void>()
const continued: SessionEvent.Synthetic[] = []
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* buildExecution(scope, () =>
Deferred.succeed(draining, undefined).pipe(Effect.andThen(Effect.never)),
)
const execution = Context.get(context, SessionExecution.Service)
const restart = Context.get(context, SessionRestart.Service)
yield* bus.project(SessionEvent.Synthetic, (event) => Effect.sync(() => void continued.push(event)))
// A live local turn holds a claim; the sweep must not count, continue, or terminalize it.
yield* execution.resume(sessionID).pipe(Effect.forkScoped)
yield* Deferred.await(draining)
yield* restart.resumeSuspendedSessions
expect(continued).toEqual([])
expect(yield* attempts(database, sessionID)).toBe(0)
expect((yield* claims(database))[sessionID]).toBe(true)
}),
)
})
function seedSessions(
database: Database.Service["Service"],
sessionIDs: ReadonlyArray<Session.ID>,
values: Partial<Pick<typeof SessionTable.$inferInsert, "time_suspended" | "resume_attempts" | "parent_id">> = {},
values: { time_suspended?: number } = {},
) {
return Effect.gen(function* () {
yield* database.db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* database.db
@@ -320,35 +190,19 @@ function seedSessions(
})
}
function claims(database: Database.Service["Service"]) {
function suspensions(database: Database.Service["Service"]) {
return database.db
.select({ id: SessionTable.id, claimed: SessionTable.time_suspended })
.select({ id: SessionTable.id, suspended: SessionTable.time_suspended })
.from(SessionTable)
.all()
.pipe(
Effect.orDie,
Effect.map((rows) => Object.fromEntries(rows.map((row) => [row.id, row.claimed !== null]))),
)
}
function attempts(database: Database.Service["Service"], sessionID: Session.ID) {
return database.db
.select({ attempts: SessionTable.resume_attempts })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(
Effect.orDie,
Effect.map((row) => row?.attempts),
Effect.map((rows) => Object.fromEntries(rows.map((row) => [row.id, row.suspended !== null]))),
)
}
/** Builds the local execution layer plus the restart actions against the test harness services. */
function buildExecution(
scope: Scope.Closeable,
drain: SessionRunner.Interface["drain"],
options?: SessionRestart.Options,
) {
function buildExecution(scope: Scope.Closeable, drain: SessionRunner.Interface["drain"]) {
return Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
@@ -364,7 +218,7 @@ function buildExecution(
),
)
return yield* Layer.buildWithScope(
SessionRestart.layer(options).pipe(
SessionRestart.layer.pipe(
Layer.provideMerge(SessionExecution.layer),
Layer.provide(Layer.succeed(Database.Service, database)),
Layer.provide(Layer.succeed(Bus.Service, bus)),
@@ -1,33 +0,0 @@
import { expect, test } from "bun:test"
import { DateTime, Effect } from "effect"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionMessageRow } from "@opencode-ai/core/session/message-row"
const message = SessionMessage.Synthetic.make({
id: SessionMessage.ID.make("msg_row"),
type: "synthetic",
text: "hello",
time: { created: DateTime.makeUnsafe(1_000) },
})
test("round trips the persisted message representation", async () => {
const row = SessionMessageRow.encode(message)
expect(row.id).toBe(message.id)
expect(row.type).toBe(message.type)
expect(row.data).toHaveProperty("text", message.text)
expect(row.data).toHaveProperty("time.created", 1_000)
expect(await Effect.runPromise(SessionMessageRow.decode(row))).toEqual(message)
expect(SessionMessageRow.decodeSync(row)).toEqual(message)
})
test("canonical columns override stale values in message data", () => {
const row = SessionMessageRow.encode(message)
const data = {
...row.data,
id: SessionMessage.ID.make("msg_stale"),
type: "system" as const,
}
expect(SessionMessageRow.decodeSync({ ...row, data })).toEqual(message)
})
+2 -3
View File
@@ -27,7 +27,6 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Permission } from "@opencode-ai/core/permission"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Shell } from "@opencode-ai/core/shell"
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import { ShellTool } from "@opencode-ai/core/tool/plugin/shell"
@@ -36,7 +35,7 @@ import { Tool } from "@opencode-ai/core/tool"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, toolDefinitions, waitForTool } from "./lib/tool"
const sessionID = Session.ID.make("ses_shell_tool_test")
const sessionModel = Model.Ref.make({ id: Model.ID.make("test"), providerID: Provider.ID.make("test") })
@@ -196,8 +195,8 @@ const withSession = <A, E, R>(directory: string, body: (registry: Tool.Interface
const locations = yield* LocationServiceMap.Service
const locationLayer = locations.get(location)
return yield* Effect.gen(function* () {
yield* (yield* PluginSupervisor.Service).flush
const registry = yield* Tool.Service
yield* waitForTool(registry, ShellTool.name)
return yield* body(registry)
}).pipe(Effect.provide(locationLayer), Effect.ensuring(locations.invalidate(location)))
})
-1
View File
@@ -63,7 +63,6 @@ const session = (
time_compacting: 3,
time_archived: null,
time_suspended: null,
resume_attempts: 0,
...overrides,
})
@@ -37,6 +37,7 @@ export async function startBackgroundCli(logger: Logger) {
url: service.url,
username: service.auth.username,
password: service.auth.password,
version,
}
}
+5 -3
View File
@@ -134,10 +134,10 @@ const main = Effect.gen(function* () {
initCrashReporter()
const wslServers = createWslServersController(
app.getVersion(),
async (distro) => {
CHANNEL === "beta" ? null : app.getVersion(),
async (distro, version, channel) => {
logger.log("spawning wsl sidecar", { distro })
return spawnWslSidecar(distro, {
return spawnWslSidecar(distro, version, channel, {
onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }),
})
},
@@ -146,6 +146,7 @@ const main = Effect.gen(function* () {
log: (message, meta) => logger.log(message, meta),
error: (message, meta) => logger.error(message, meta),
},
channel: CHANNEL,
},
)
const stopSidecars = async () => wslServers.stopAll()
@@ -311,6 +312,7 @@ const main = Effect.gen(function* () {
logger.log("starting v2 background service")
const sidecar = yield* Effect.promise(() => startBackgroundCli(logger))
if (CHANNEL === "beta") wslServers.setCliVersion(sidecar.version)
yield* Deferred.succeed(serverReady, {
url: sidecar.url,
username: sidecar.username,
+29 -8
View File
@@ -264,18 +264,22 @@ export async function installWslDistro(name: string, opts?: RunWslOptions) {
)
}
export async function installWslOpencode(version: string, distro: string, opts?: RunWslOptions) {
export async function installWslOpencode(version: string, channel: string, distro: string, opts?: RunWslOptions) {
return runInteractiveCommand(
resolveSystem32Command("wsl.exe"),
wslArgs(
["bash", "-lc", `curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)}`],
distro,
),
wslArgs(["bash", "-lc", wslOpencodeInstallCommand(version, channel)], distro),
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
)
}
export function wslOpencodeInstallCommand(version: string, channel: string) {
if (channel === "beta") {
return `npm install --global --no-audit --no-fund ${shellEscape(`@opencode-ai/cli@${version}`)}`
}
return `curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)}`
}
export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise<WslDistroProbe> {
const executable = await runWslInDistro(["/bin/true"], name, opts).catch((error) => ({
code: 1,
@@ -307,11 +311,22 @@ export async function probeWslDistro(name: string, opts?: RunWslOptions): Promis
}
}
export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) {
export async function resolveWslOpencode(distro: string, channel: string, opts?: RunWslOptions) {
if (channel !== "beta") {
return firstLine(
(
await runWslSh(
'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi',
distro,
opts,
)
).stdout,
)
}
return firstLine(
(
await runWslSh(
'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi',
'PATH=$(awk -v RS=: -v ORS=: \'$0 !~ /^\\/mnt\\//\' <<<"$PATH" | sed "s/:$//"); export PATH; command -v opencode2 || true',
distro,
opts,
)
@@ -321,7 +336,13 @@ export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) {
export async function readWslCommandVersion(command: string, distro: string, opts?: RunWslOptions) {
const result = await runWslSh(`${shellEscape(command)} --version 2>/dev/null || true`, distro, opts)
return firstLine(result.stdout)
return parseWslOpencodeVersion(firstLine(result.stdout))
}
export function parseWslOpencodeVersion(output: string | null) {
if (!output) return null
const marker = output.lastIndexOf(" v")
return marker === -1 ? output : output.slice(marker + 2)
}
export function openWslTerminal(distro?: string | null) {
+53 -4
View File
@@ -13,6 +13,10 @@ import {
wslServerIdsToStartOnInitialize,
} from "./startup"
import { createWslServersController, type WslServerConfig } from "./servers"
import {
parseWslOpencodeVersion,
wslOpencodeInstallCommand,
} from "./runtime"
let persistedServers: WslServerConfig[] = []
let releaseOpencodeResolve: (() => void) | undefined
@@ -33,6 +37,51 @@ test("rejects an update that did not install the desktop version", () => {
)
})
test("installs the exact V2 CLI package through npm", () => {
const version = "0.0.0-next-17181"
const command = wslOpencodeInstallCommand(version, "beta")
expect(command).toBe("npm install --global --no-audit --no-fund '@opencode-ai/cli@0.0.0-next-17181'")
expect(command).not.toContain("@next")
expect(command).not.toContain("https://opencode.ai/install")
})
test("keeps the curl installer outside the beta channel", () => {
expect(wslOpencodeInstallCommand("1.18.16", "prod")).toBe(
"curl -fsSL https://opencode.ai/install | bash -s -- --version '1.18.16'",
)
expect(wslOpencodeInstallCommand("1.18.16", "dev")).toBe(
"curl -fsSL https://opencode.ai/install | bash -s -- --version '1.18.16'",
)
})
test("reads the version reported by the V2 binary", () => {
expect(parseWslOpencodeVersion("opencode2 v0.0.0-next-17181")).toBe("0.0.0-next-17181")
expect(parseWslOpencodeVersion("1.18.16")).toBe("1.18.16")
})
test("installs the bundled CLI version instead of the Desktop release version", async () => {
persistedServers = []
let requested: { version: string; channel: string; distro: string } | undefined
const controller = createWslServersController(null, async () => new Promise<never>(() => undefined), {
channel: "beta",
readServers: () => persistedServers,
writeServers: () => undefined,
resolveOpencode: async () => "/home/me/.npm/bin/opencode2",
readCommandVersion: async () => "0.0.0-next-17181",
installOpencode: async (version, channel, distro) => {
requested = { version, channel, distro }
return { code: 0, signal: null, stdout: "", stderr: "" }
},
})
controller.setCliVersion("0.0.0-next-17181")
await controller.installOpencode("Debian")
expect(requested).toEqual({ version: "0.0.0-next-17181", channel: "beta", distro: "Debian" })
expect(controller.getState().opencodeChecks.Debian?.matchesDesktop).toBe(true)
})
test("restarts an existing distro server after updating OpenCode", () => {
expect(
wslServerIdToRestart(
@@ -55,7 +104,7 @@ test("clears cached distro probes when removing a WSL server", () => {
{
Debian: {
distro: "Debian",
resolvedPath: "/home/luke/.opencode/bin/opencode",
resolvedPath: "/home/luke/.local/share/opencode/desktop/beta/1.16.2/opencode2",
version: "1.16.2",
expectedVersion: "1.16.2",
matchesDesktop: true,
@@ -164,7 +213,7 @@ test("probes addable distros in parallel before checking OpenCode", async () =>
},
resolveOpencode: async (distro) => {
opencode.push(distro)
return "/home/me/.opencode/bin/opencode"
return "/home/me/.local/share/opencode/desktop/dev/1.16.2/opencode2"
},
})
@@ -195,7 +244,7 @@ test("does not check OpenCode in addable distros that cannot execute commands",
}),
resolveOpencode: async (distro) => {
opencode.push(distro)
return "/home/me/.opencode/bin/opencode"
return "/home/me/.local/share/opencode/desktop/dev/1.16.2/opencode2"
},
})
@@ -225,7 +274,7 @@ function testControllerOptions() {
await new Promise<void>((resolve) => {
releaseOpencodeResolve = resolve
})
return "/home/me/.opencode/bin/opencode"
return "/home/me/.local/share/opencode/desktop/dev/1.16.2/opencode2"
},
}
}
+25 -8
View File
@@ -37,7 +37,7 @@ type RunningSidecar = {
password: string
}
type SpawnSidecar = (distro: string) => Promise<RunningSidecar>
type SpawnSidecar = (distro: string, version: string, channel: string) => Promise<RunningSidecar>
type ControllerLogger = {
log: (message: string, meta?: unknown) => void
@@ -51,6 +51,8 @@ type WslServersControllerOptions = {
probeDistro?: typeof probeWslDistro
resolveOpencode?: typeof resolveWslOpencode
readCommandVersion?: typeof readWslCommandVersion
installOpencode?: typeof installWslOpencode
channel?: string
}
export type WslServersController = ReturnType<typeof createWslServersController>
@@ -60,7 +62,7 @@ export function wslServerIdForDistro(distro: string) {
}
export function createWslServersController(
appVersion: string,
initialCliVersion: string | null,
spawnSidecar: SpawnSidecar,
options?: WslServersControllerOptions,
) {
@@ -69,10 +71,17 @@ export function createWslServersController(
const sidecars = new Map<string, RunningSidecar>()
const startAttempts = new Map<string, number>()
let jobAbort: AbortController | undefined
let cliVersion = initialCliVersion
const logger = options?.logger
const readServers = options?.readServers ?? readPersistedServers
const writeServers = options?.writeServers ?? writePersistedServers
const probeDistro = options?.probeDistro ?? probeWslDistro
const channel = options?.channel ?? "dev"
const expectedVersion = () => {
if (cliVersion) return cliVersion
throw new Error(nativeT("desktop.wsl.error.opencodeCannotRun"))
}
const emit = () => {
for (const listener of listeners) listener({ type: "state", state })
@@ -132,11 +141,12 @@ export function createWslServersController(
}
const checkOpencode = async (distro: string, opts?: { signal?: AbortSignal }) => {
const resolved = await (options?.resolveOpencode ?? resolveWslOpencode)(distro, opts)
const version = resolved
const version = expectedVersion()
const resolved = await (options?.resolveOpencode ?? resolveWslOpencode)(distro, channel, opts)
const installed = resolved
? await (options?.readCommandVersion ?? readWslCommandVersion)(resolved, distro, opts)
: null
return opencodeCheck(distro, resolved, version, appVersion)
return opencodeCheck(distro, resolved, installed, version)
}
const refreshOpencodeCheck = async (distro: string, opts?: { signal?: AbortSignal }) => {
@@ -229,7 +239,7 @@ export function createWslServersController(
setRuntime(id, { kind: "starting" })
logger?.log("wsl sidecar starting", { id, distro: item.config.distro })
try {
const sidecar = await spawnSidecar(item.config.distro)
const sidecar = await spawnSidecar(item.config.distro, expectedVersion(), channel)
if (!isCurrentStartAttempt(id, attempt)) {
try {
sidecar.listener.stop()
@@ -294,6 +304,10 @@ export function createWslServersController(
}
return {
setCliVersion(version: string) {
cliVersion = version
},
getState() {
return state
},
@@ -362,12 +376,15 @@ export function createWslServersController(
async installOpencode(name: string) {
await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
const result = await installWslOpencode(appVersion, name, { signal: abort.signal })
const version = expectedVersion()
const result = await (options?.installOpencode ?? installWslOpencode)(version, channel, name, {
signal: abort.signal,
})
if (result.code !== 0) {
throw new Error(summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installOpencode"))
}
await refreshOpencodeCheck(name, { signal: abort.signal })
expectOpencodeVersion(state.opencodeChecks[name]?.version ?? null, appVersion, name)
expectOpencodeVersion(state.opencodeChecks[name]?.version ?? null, version, name)
const id = wslServerIdToRestart(state.servers, name)
if (id) await startServer(id)
})
+14 -2
View File
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"
import { createServer } from "node:net"
import { app } from "electron"
import { checkHealth } from "../server"
import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from "./runtime"
import { type WslCommandLine, readWslCommandVersion, resolveWslOpencode, shellEscape, wslArgs } from "./runtime"
import { pollWslHealth } from "./startup"
import { nativeT } from "../native-translations"
@@ -16,10 +16,22 @@ export type WslSidecar = {
export async function spawnWslSidecar(
distro: string,
version: string,
channel: string,
opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {},
): Promise<WslSidecar> {
const opencode = await resolveWslOpencode(distro)
const opencode = await resolveWslOpencode(distro, channel)
if (!opencode) throw new Error(nativeT("desktop.wsl.error.opencodeNotInstalled", { distro }))
const installed = await readWslCommandVersion(opencode, distro)
if (installed !== version) {
throw new Error(
nativeT("desktop.wsl.error.updateVersion", {
distro,
installed: installed ?? nativeT("desktop.wsl.error.noVersion"),
expected: version,
}),
)
}
const port = await allocatePort()
const password = randomUUID()
+1 -14
View File
@@ -13,19 +13,6 @@ const session = yield * opencode.sessions.get({ sessionID })
It also exports `Tool` for plugins that add tools with `ctx.tool.transform(...)`. Embedded plugins run through the ordinary discovery flow and register tools into each Location's `ToolRegistry` through the normal `Tools.Service.register(...)` path. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations.
Embedded hosts are silent by default. Set `log` to receive structured log entries at the selected minimum level:
```ts
const opencode =
yield *
OpenCode.create({
log: {
level: "warn",
emit: (entry) => console.error(entry.message, entry.attributes, entry.cause),
},
})
```
`sessions.events({ sessionID, after })` replays durable events after the optional aggregate sequence, then emits newly committed durable events. `sessions.interrupt(...)` targets execution owned by this host, and `sessions.message(...)` retrieves one projected Session message.
The same constructor is available as a service Layer:
@@ -39,4 +26,4 @@ const program = Effect.gen(function* () {
yield * program.pipe(Effect.provide(OpenCode.layer))
```
`OpenCode.layer` adapts the silent default `OpenCode.create()` for dependency injection; use `OpenCode.layerWith(options)` to configure the host.
`OpenCode.layer` adapts `OpenCode.create()` for dependency injection; it does not define another host implementation.
-71
View File
@@ -1,71 +0,0 @@
import { Context, Formatter, Layer, Logger, References } from "effect"
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal"
export type LogEntry = {
readonly level: LogLevel
readonly message: string
readonly attributes?: Readonly<Record<string, unknown>>
readonly cause?: unknown
}
export type LogWriter = (entry: LogEntry) => void
export type LogOptions = {
readonly level?: LogLevel
readonly emit: LogWriter
}
const levels: Record<LogLevel, Logger.Options<unknown>["logLevel"]> = {
trace: "Trace",
debug: "Debug",
info: "Info",
warn: "Warn",
error: "Error",
fatal: "Fatal",
}
function normalizeLevel(level: Logger.Options<unknown>["logLevel"]): LogLevel | undefined {
const output = Object.fromEntries(Object.entries(levels).map(([name, effect]) => [effect, name]))
return output[level] as LogLevel
}
export function layer(log?: LogOptions) {
const logger = Logger.make((options) => {
if (!log) return
const level = normalizeLevel(options.logLevel)
if (!level) return
const entry = Logger.formatStructured.log(options)
const values = Array.isArray(entry.message) ? entry.message : [entry.message]
const [message, ...data] = values
const details =
data.length === 1 && !Array.isArray(data[0]) ? (data[0] as Readonly<Record<string, unknown>>) : undefined
const { cause: detailCause, ...detailAttributes } = details ?? {}
const attributes = {
...entry.annotations,
...detailAttributes,
...(Object.keys(entry.spans).length > 0 ? { spans: entry.spans } : {}),
...(!details && data.length > 0 ? { data: data.length === 1 ? data[0] : data } : {}),
}
try {
log.emit({
level,
message: typeof message === "string" ? message : Formatter.format(message),
...(Object.keys(attributes).length > 0 ? { attributes } : {}),
...(entry.cause === undefined && detailCause === undefined ? {} : { cause: entry.cause ?? detailCause }),
})
} catch {
// A host logger must not break OpenCode operations.
}
})
return Layer.merge(
Logger.layer([logger], { mergeWithExisting: false }),
Layer.succeed(References.MinimumLogLevel, levels[log?.level ?? "info"]),
)
}
export function context(source: Context.Context<never>) {
return Context.make(Logger.CurrentLoggers, Context.get(source, Logger.CurrentLoggers)).pipe(
Context.add(References.MinimumLogLevel, Context.get(source, References.MinimumLogLevel)),
)
}
+8 -19
View File
@@ -2,27 +2,18 @@ import { OpenCode } from "@opencode-ai/client/effect"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
import type { ServerOptions } from "@opencode-ai/server/options"
import { Context, Effect, Layer, ManagedRuntime, Scope } from "effect"
import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
import * as Logging from "./logging"
import { Context, Effect, Layer, ManagedRuntime } from "effect"
import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
export type { LogEntry, LogLevel, LogOptions, LogWriter } from "./logging"
import type { LogOptions } from "./logging"
export type CreateOptions = ServerOptions & {
readonly log?: LogOptions
}
export const create = Effect.fn("OpenCode.create")(function* (options: CreateOptions = {}) {
const { log, ...server } = options
export const create = Effect.fn("OpenCode.create")(function* (options: ServerOptions = {}) {
const runtime = yield* Effect.acquireRelease(
Effect.sync(() =>
ManagedRuntime.make(
createEmbeddedRoutes({
...server,
app: { ...server.app, name: server.app?.name ?? "sdk" },
database: { path: ":memory:", ...server.database },
}).pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(Logging.layer(log))),
...options,
app: { ...options.app, name: options.app?.name ?? "sdk" },
database: { path: ":memory:", ...options.database },
}).pipe(Layer.provide(HttpServer.layerServices)),
),
),
(runtime) => runtime.disposeEffect,
@@ -30,9 +21,7 @@ export const create = Effect.fn("OpenCode.create")(function* (options: CreateOpt
const context = yield* runtime.contextEffect
const plugins = Context.get(context, SdkPlugins.Service)
const router = Context.get(context, HttpRouter.HttpRouter)
const handler = HttpEffect.toWebHandlerWith<never, HttpServerRequest.HttpServerRequest | Scope.Scope>(
Logging.context(context),
)(router.asHttpEffect())
const handler = HttpEffect.toWebHandler(router.asHttpEffect())
const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => handler(new Request(input, init)), {
preconnect: () => undefined,
}) satisfies typeof globalThis.fetch
+5 -3
View File
@@ -247,10 +247,12 @@ function unavailable(status: Status.State) {
}
/**
* The managed server owns restart continuity: at boot it resumes Sessions whose execution claim was
* never released. Claims are written when execution starts (see SessionExecution), so recovery covers
* graceful restarts and unclean deaths alike — no shutdown hook participates.
* The managed server owns restart continuity: it resumes Sessions the previous server suspended and
* suspends its own active Sessions on graceful shutdown. Suspension runs while the drains are still
* alive: connections close first, this finalizer runs next, and Session execution teardown follows.
*/
const installRestartContinuity = Effect.fnUntraced(function* (restart: SessionRestart.Interface) {
yield* Effect.forkScoped(restart.resumeSuspendedSessions)
// Registered after the fork so suspension observes still-running resumed drains during teardown.
yield* Effect.addFinalizer(() => restart.suspendActiveSessions)
})
+3 -3
View File
@@ -7,13 +7,13 @@ import Config from "@npmcli/config"
import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js"
import { Effect } from "effect"
const npmPath = fileURLToPath(new URL("..", import.meta.url))
export const load = (dir: string) =>
Effect.tryPromise({
try: async () => {
const config = new Config({
// Resolved per call: on workerd import.meta.url is undefined and building
// this URL at module scope fails startup validation; npm config never runs there.
npmPath: fileURLToPath(new URL("..", import.meta.url)),
npmPath,
cwd: dir,
env: { ...process.env },
argv: [process.execPath, process.execPath, "--prefix", dir],
+2 -8
View File
@@ -1,6 +1,6 @@
export * as Observability from "./observability.js"
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
import { NodeFileSystem } from "@effect/platform-node"
import { LayerNode } from "./effect/layer-node.js"
import { Effect, Layer, Logger, References, Schema } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
@@ -50,10 +50,4 @@ export function layer(
).pipe(Layer.catchCause(() => local))
}
// Layer.suspend: constructing the loggers eagerly at module scope performs
// I/O (file logger, run id) that workerd forbids in global scope.
export const node = LayerNode.make({
name: "observability",
layer: Layer.suspend(() => layer()),
deps: [],
})
export const node = LayerNode.make({ name: "observability", layer: layer(), deps: [] })
+2 -2
View File
@@ -3,7 +3,7 @@ import path from "path"
import { Global } from "../global.js"
import { runID } from "./shared.js"
function formatter(id: string = runID()) {
function formatter(id: string = runID) {
return Logger.map(Logger.formatStructured, (output) => {
const messages = Array.isArray(output.message) ? output.message : [output.message]
return [
@@ -51,7 +51,7 @@ export function file(local = true, channel = "local") {
return path.join(Global.Path.log, `opencode-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.log`)
}
export function fileLogger(target = file(), id: string = runID()) {
export function fileLogger(target = file(), id: string = runID) {
// Do not set batchWindow to 0; it causes high idle CPU usage.
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
+2 -2
View File
@@ -54,8 +54,8 @@ export function resource(app: App = { client: "opencode", version: "unknown", ch
...resourceAttributes(),
"deployment.environment.name": app.channel,
"opencode.client": app.client,
"opencode.run": runID(),
"service.instance.id": runID(),
"opencode.run": runID,
"service.instance.id": runID,
},
}
}
+1 -8
View File
@@ -1,8 +1 @@
// Lazy: workerd forbids generating random values in global scope, so the id
// materializes on first call (inside a handler) and stays stable afterwards.
let generated: string | undefined
export function runID(): string {
generated ??= crypto.randomUUID().slice(0, 8)
return generated
}
export const runID = crypto.randomUUID().slice(0, 8)
@@ -19,9 +19,8 @@ V2 loads:
1. The global file at `$XDG_CONFIG_HOME/opencode/AGENTS.md`, normally
`~/.config/opencode/AGENTS.md`.
2. Every `AGENTS.md` from the current Location up to and including the home
directory when the Location is inside it. For Locations outside home, the
scan stops at the project root.
2. Every `AGENTS.md` from the current Location up to and including the project
root.
For example, when the Location is `packages/web`, OpenCode can load all three
project files below:
@@ -36,9 +35,9 @@ my-project/
```
The files are combined rather than selecting a single winner. They are rendered
in this order: global, then files from the Location toward home or the project
in this order: global, then project files from the Location toward the project
root. OpenCode does not resolve conflicts between their contents, so keep broad
guidance global and put scoped guidance in the relevant directory.
guidance global and put scoped guidance in the relevant project directory.
If the Location is outside the project root, only the global file is loaded.
Setting `OPENCODE_DISABLE_PROJECT_CONFIG=1` also skips project `AGENTS.md`
+2 -2
View File
@@ -504,8 +504,8 @@ require a V2 rewrite. See [Skills](/skills).
### Instruction files
Existing `AGENTS.md` files stay in place. V2 discovers the global `~/.config/opencode/AGENTS.md` and ambient `AGENTS.md`
files from the current directory up to home. For projects outside home, discovery stops at the project root.
Existing `AGENTS.md` files stay in place. V2 discovers the global `~/.config/opencode/AGENTS.md` and project `AGENTS.md`
files from the current directory up to the project root.
If a V1 setup relied on a `CLAUDE.md` fallback, move that guidance into the applicable `AGENTS.md`. V2 currently only
discovers `AGENTS.md`; because non-API V1 behavior is intended to remain compatible, also run `/report` with the affected