chore: sync native provider core stack

This commit is contained in:
Shoubhit Dash 2026-06-25 20:37:23 +05:30
commit 5e12dbdbfb
446 changed files with 22760 additions and 7855 deletions

View file

@ -1,6 +1,7 @@
export * as Catalog from "./catalog"
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
import { Catalog } from "@opencode-ai/schema/catalog"
import { ModelV2 } from "./model"
import { ProviderV2 } from "./provider"
import { EventV2 } from "./event"
@ -18,9 +19,7 @@ export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
export const PolicyActions = Schema.Literals(["provider.use"])
export const Event = {
Updated: EventV2.define({ type: "catalog.updated", schema: {} }),
}
export const Event = Catalog.Event
type Data = {
providers: Map<ProviderV2.ID, ProviderRecord>

View file

@ -84,15 +84,20 @@ export const layer = Layer.effect(
return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id })
}
const patch =
input.moveChanges && source.directory !== destination.directory
? yield* git
.patch(current.location.directory)
.pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message })))
: ""
const moveChanges = input.moveChanges && source.directory !== destination.directory
const sourceRepository = moveChanges ? yield* git.repo.discover(current.location.directory) : undefined
if (moveChanges && !sourceRepository)
return yield* new CaptureChangesError({ message: "Source is not a Git repository" })
const patch = sourceRepository
? yield* git.change
.capture({ repository: sourceRepository, path: current.location.directory })
.pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message })))
: Git.ChangeSet.make("")
if (patch) {
yield* git
.applyPatch({ directory, patch })
const repository = yield* git.repo.discover(directory)
if (!repository) return yield* new ApplyChangesError({ message: "Destination is not a Git repository" })
yield* git.change
.apply({ repository, path: directory, changes: patch })
.pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message })))
}
@ -104,16 +109,29 @@ export const layer = Layer.effect(
})
if (patch) {
yield* git.softResetChanges(current.location.directory).pipe(
Effect.mapError(
(error) =>
new ResetSourceChangesError({
directory: current.location.directory,
message: error.message,
cause: error.cause,
}),
),
)
const repository = yield* git.repo.discover(current.location.directory)
if (!repository)
return yield* new ResetSourceChangesError({
directory: current.location.directory,
message: "Source is not a Git repository",
})
yield* git.change
.discard({
repository,
path: current.location.directory,
index: "preserve",
untracked: "remove",
})
.pipe(
Effect.mapError(
(error) =>
new ResetSourceChangesError({
directory: current.location.directory,
message: error.message,
cause: error.cause,
}),
),
)
}
})

View file

@ -1,46 +1,19 @@
export * as EventV2 from "./event"
import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Event } from "@opencode-ai/schema/event"
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
import { and, asc, eq, gt } from "drizzle-orm"
import { Database } from "./database/database"
import { EventSequenceTable, EventTable } from "./event/sql"
import { Location } from "./location"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
import { LayerNode } from "./effect/layer-node"
import { isDeepStrictEqual } from "node:util"
import { Durable } from "@opencode-ai/schema/durable-event-manifest"
export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
Schema.brand("Event.ID"),
withStatics((schema) => ({
create: () => schema.make("evt_" + Identifier.ascending()),
})),
)
export type ID = typeof ID.Type
export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
readonly type: Type
readonly durable?: {
readonly version: number
readonly aggregate: string
}
readonly data: DataSchema
}
export type Data<D extends Definition> = Schema.Schema.Type<D["data"]>
export type Payload<D extends Definition = Definition> = {
readonly id: ID
readonly type: D["type"]
readonly data: Data<D>
readonly durable?: {
readonly aggregateID: string
readonly seq: number
readonly version: number
}
readonly location?: Location.Ref
readonly metadata?: Record<string, unknown>
}
export const ID = Event.ID
export type ID = import("@opencode-ai/schema/event").ID
export type { Data, Definition, Payload } from "@opencode-ai/schema/event"
export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
export type Unsubscribe = Effect.Effect<void>
@ -74,52 +47,8 @@ export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDur
},
) {}
export function versionedType(type: string, version: number) {
return `${type}.${version}`
}
export const registry = new Map<string, Definition>()
const durableRegistry = new Map<string, Definition>()
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
readonly type: Type
readonly durable?: {
readonly version: number
readonly aggregate: string
}
readonly schema: Fields
}): Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & Definition<Type, Schema.Struct<Fields>> {
const Data = Schema.Struct(input.schema)
const Payload = Schema.Struct({
id: ID,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.Literal(input.type),
durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })),
location: Schema.optional(Location.Ref),
data: Data,
}).annotate({ identifier: input.type })
const definition = Object.assign(Payload, {
type: input.type,
...(input.durable === undefined ? {} : { durable: input.durable }),
data: Data,
})
const existing = registry.get(input.type)
if (
input.durable === undefined ||
existing?.durable === undefined ||
input.durable.version >= existing.durable.version
) {
registry.set(input.type, definition)
}
if (input.durable) durableRegistry.set(versionedType(input.type, input.durable.version), definition)
return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> &
Definition<Type, Schema.Struct<Fields>>
}
export function definitions() {
return registry.values().toArray()
}
export const define = Event.define
export const versionedType = Event.versionedType
export interface PublishOptions {
readonly id?: ID
@ -169,6 +98,7 @@ export const layerWith = (options?: LayerOptions) =>
typed: new Map<string, PubSub.PubSub<Payload>>(),
}
const projectors = new Map<string, Subscriber[]>()
// TODO: Bind durable projectors to exact type+version before supporting incompatible historical payloads.
const listeners = new Array<Subscriber>()
const { db } = yield* Database.Service
@ -194,6 +124,7 @@ export const layerWith = (options?: LayerOptions) =>
)
function commitDurableEvent(
definition: Definition,
event: Payload,
input?: {
readonly seq: number
@ -204,7 +135,6 @@ export const layerWith = (options?: LayerOptions) =>
commit?: (seq: number) => Effect.Effect<void>,
) {
return Effect.gen(function* () {
const definition = registry.get(event.type)
const durable = definition?.durable
if (durable) {
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
@ -238,9 +168,10 @@ export const layerWith = (options?: LayerOptions) =>
.get()
.pipe(Effect.orDie)
const latest = row?.seq ?? -1
const encoded = Schema.encodeUnknownSync(
definition.data as Schema.Codec<unknown, unknown, never, never>,
)(event.data) as Record<string, unknown>
const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record<
string,
unknown
>
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
yield* Effect.die(
new InvalidDurableEventError({
@ -356,9 +287,8 @@ export const layerWith = (options?: LayerOptions) =>
})
}
function publishEvent<D extends Definition>(event: Payload<D>, commit?: PublishOptions["commit"]) {
function publishEvent<D extends Definition>(definition: D, event: Payload<D>, commit?: PublishOptions["commit"]) {
return Effect.gen(function* () {
const definition = registry.get(event.type)
if (!definition?.durable && commit)
return yield* Effect.die(
new InvalidDurableEventError({
@ -367,7 +297,7 @@ export const layerWith = (options?: LayerOptions) =>
}),
)
if (definition?.durable) {
const committed = yield* commitDurableEvent(event as Payload, undefined, commit)
const committed = yield* commitDurableEvent(definition, event as Payload, undefined, commit)
if (committed) {
event = {
...event,
@ -416,6 +346,7 @@ export const layerWith = (options?: LayerOptions) =>
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined)
return yield* publishEvent(
definition,
{
id: options?.id ?? ID.create(),
...(options?.metadata ? { metadata: options.metadata } : {}),
@ -433,7 +364,7 @@ export const layerWith = (options?: LayerOptions) =>
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
) {
return Effect.gen(function* () {
const definition = durableRegistry.get(event.type)
const definition = Durable.get(event.type)
if (!definition?.durable) {
yield* Effect.die(
new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
@ -442,11 +373,9 @@ export const layerWith = (options?: LayerOptions) =>
const payload = {
id: event.id,
type: definition.type,
data: Schema.decodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(
event.data,
),
data: Schema.decodeUnknownSync(definition.data)(event.data),
} as Payload
const committed = yield* commitDurableEvent(payload, {
const committed = yield* commitDurableEvent(definition, payload, {
seq: event.seq,
aggregateID: event.aggregateID,
ownerID: options?.ownerID,
@ -530,8 +459,8 @@ export const layerWith = (options?: LayerOptions) =>
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
const decodeSerializedEvent = (event: SerializedEvent): Payload => {
const definition = durableRegistry.get(event.type)
const decodeSerializedEvent = (event: SerializedEvent) => {
const definition = Durable.get(event.type)
if (!definition?.durable) {
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
}
@ -539,7 +468,7 @@ export const layerWith = (options?: LayerOptions) =>
id: event.id,
type: definition.type,
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
data: Schema.decodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(event.data),
data: Schema.decodeUnknownSync(definition.data)(event.data),
}
}

View file

@ -0,0 +1,6 @@
export * as File from "./file"
import { Revert } from "@opencode-ai/schema/revert"
export const Diff = Revert.FileDiff
export type Diff = typeof Diff.Type

View file

@ -2,12 +2,11 @@ export * as FileSystem from "./filesystem"
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { EventV2 } from "./event"
import { FSUtil } from "./fs-util"
import { Location } from "./location"
import { PositiveInt, RelativePath } from "./schema"
import { FileSystemSearch } from "./filesystem/search"
import { Entry, Match } from "@opencode-ai/schema/filesystem"
import { Entry, FileSystem, FindInput, Match } from "@opencode-ai/schema/filesystem"
export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem"
export const ReadInput = Schema.Struct({
@ -29,11 +28,7 @@ export const ListInput = Schema.Struct({
})
export type ListInput = typeof ListInput.Type
export class FindInput extends Schema.Class<FindInput>("FileSystem.FindInput")({
query: Schema.String,
type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
}) {}
export { FindInput }
export class GlobInput extends Schema.Class<GlobInput>("FileSystem.GlobInput")({
pattern: Schema.String,
@ -48,14 +43,7 @@ export class GrepInput extends Schema.Class<GrepInput>("FileSystem.GrepInput")({
limit: PositiveInt.pipe(Schema.optional),
}) {}
export const Event = {
Edited: EventV2.define({
type: "file.edited",
schema: {
file: Schema.String,
},
}),
}
export const Event = FileSystem.Event
export interface Interface {
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>

View file

@ -127,12 +127,19 @@ export const fffLayer = Layer.effect(
Fff.create({
basePath: location.directory,
aiMode: true,
enableFsRootScanning: true,
enableHomeDirScanning: true,
}),
catch: (cause) => cause,
}).pipe(Effect.orDie)
if (!result.ok) return yield* Effect.die(result.error)
}).pipe(
Effect.catch((error) => Effect.logWarning("failed to initialize fff", { error }).pipe(Effect.as(undefined))),
)
if (!result?.ok) {
if (result) yield* Effect.logWarning("failed to initialize fff", { error: result.error })
return Service.of({
find: () => Effect.succeed([]),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
})
}
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
return Service.of({
glob: (input) =>

View file

@ -3,7 +3,8 @@ export * as Watcher from "./watcher"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher"
import { Cause, Context, Effect, Layer, Schema } from "effect"
import { Cause, Context, Effect, Layer } from "effect"
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
import path from "path"
import { Config } from "../config"
import { EventV2 } from "../event"
@ -19,15 +20,7 @@ declare const OPENCODE_LIBC: string | undefined
const SUBSCRIBE_TIMEOUT_MS = 10_000
export const Event = {
Updated: EventV2.define({
type: "file.watcher.updated",
schema: {
file: Schema.String,
event: Schema.Literals(["add", "change", "unlink"]),
},
}),
}
export const Event = FileSystemWatcher.Event
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
try {
@ -119,7 +112,7 @@ export const layer = Layer.effect(
}
if (location.vcs?.type === "git") {
const resolved = yield* git.dir(location.directory)
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(

File diff suppressed because it is too large Load diff

View file

@ -16,9 +16,7 @@ import {
} from "effect"
import { Integration } from "@opencode-ai/schema/integration"
import { Credential } from "./credential"
import { withStatics } from "./schema"
import { State } from "./state"
import { Identifier } from "./util/identifier"
import { EventV2 } from "./event"
import { IntegrationConnection } from "./integration/connection"
@ -28,10 +26,7 @@ export type ID = Integration.ID
export const MethodID = Integration.MethodID
export type MethodID = Integration.MethodID
export const AttemptID = Schema.String.pipe(
Schema.brand("Integration.AttemptID"),
withStatics((schema) => ({ create: () => schema.make("con_" + Identifier.ascending()) })),
)
export const AttemptID = Integration.AttemptID
export type AttemptID = typeof AttemptID.Type
export const When = Integration.When
@ -58,12 +53,8 @@ export type EnvMethod = Integration.EnvMethod
export const Method = Integration.Method
export type Method = Integration.Method
export class Info extends Schema.Class<Info>("Integration.Info")({
id: ID,
name: Schema.String,
methods: Schema.mutable(Schema.Array(Method)),
connections: Schema.mutable(Schema.Array(IntegrationConnection.Info)),
}) {}
export const Info = Integration.Info
export type Info = Integration.Info
export const Inputs = Integration.Inputs
export type Inputs = Integration.Inputs
@ -102,28 +93,10 @@ export interface EnvImplementation {
export type Implementation = OAuthImplementation | KeyImplementation | EnvImplementation
export class Attempt extends Schema.Class<Attempt>("Integration.Attempt")({
attemptID: AttemptID,
url: Schema.String,
instructions: Schema.String,
mode: Schema.Literals(["auto", "code"]),
time: Schema.Struct({
created: Schema.Number,
expires: Schema.Number,
}),
}) {}
export const Attempt = Integration.Attempt
export type Attempt = Integration.Attempt
const Time = Schema.Struct({
created: Schema.Number,
expires: Schema.Number,
})
export const AttemptStatus = Schema.Union([
Schema.Struct({ status: Schema.Literal("pending"), time: Time }),
Schema.Struct({ status: Schema.Literal("complete"), time: Time }),
Schema.Struct({ status: Schema.Literal("failed"), message: Schema.String, time: Time }),
Schema.Struct({ status: Schema.Literal("expired"), time: Time }),
]).pipe(Schema.toTaggedUnion("status"))
export const AttemptStatus = Integration.AttemptStatus
export type AttemptStatus = typeof AttemptStatus.Type
export class CodeRequiredError extends Schema.TaggedErrorClass<CodeRequiredError>()("Integration.CodeRequired", {
@ -136,16 +109,7 @@ export class AuthorizationError extends Schema.TaggedErrorClass<AuthorizationErr
export type Error = CodeRequiredError | AuthorizationError
export const Event = {
Updated: EventV2.define({
type: "integration.updated",
schema: {},
}),
ConnectionUpdated: EventV2.define({
type: "integration.connection.updated",
schema: { integrationID: ID },
}),
}
export const Event = Integration.Event
export const Ref = Integration.Ref
export type Ref = Integration.Ref

View file

@ -47,6 +47,7 @@ import * as SessionRunnerLLM from "./session/runner/llm"
import { SessionRunnerModel } from "./session/runner/model"
import { SystemContextBuiltIns } from "./system-context/builtins"
import { FetchHttpClient } from "effect/unstable/http"
import { Snapshot } from "./snapshot"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => {
@ -96,11 +97,13 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Layer.provide(image),
)
const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services))
const snapshot = Snapshot.locationLayer.pipe(Layer.provide(services))
const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(services),
Layer.provide(model),
Layer.provide(skillGuidance),
Layer.provide(referenceGuidance),
Layer.provide(snapshot),
)
// Kick off a background project copy refresh to update locations now that we
@ -116,6 +119,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
todos,
questions,
model,
snapshot,
runner,
builtInTools,
referenceGuidance,

View file

@ -1,30 +1,15 @@
import { Context, Effect, Layer, Schema } from "effect"
import { Ref } from "@opencode-ai/schema/location"
import { Context, Effect, Layer } from "effect"
import { Info, Ref, response } from "@opencode-ai/schema/location"
import { Project } from "./project"
import { AbsolutePath, optionalOmitUndefined } from "./schema"
import { WorkspaceV2 } from "./workspace"
export * as Location from "./location"
export { Ref }
export class Info extends Schema.Class<Info>("Location.Info")({
directory: AbsolutePath,
workspaceID: optionalOmitUndefined(WorkspaceV2.ID),
project: Schema.Struct({
id: Project.ID,
directory: AbsolutePath,
}),
}) {}
export { Info, Ref, response }
export interface Interface extends Info {
readonly vcs?: Project.Vcs
}
export function response<S extends Schema.Top>(data: S) {
return Schema.Struct({ location: Info, data })
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
export const layer = (ref: Ref) =>

View file

@ -1,6 +1,7 @@
import path from "path"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ModelsDev } from "@opencode-ai/schema/models-dev"
import { Global } from "./global"
import { Flag } from "./flag/flag"
import { Flock } from "./util/flock"
@ -108,12 +109,7 @@ export const Provider = Schema.Struct({
export type Provider = Schema.Schema.Type<typeof Provider>
export const Event = {
Refreshed: EventV2.define({
type: "models-dev.refreshed",
schema: {},
}),
}
export const Event = ModelsDev.Event
declare const OPENCODE_MODELS_DEV: Record<string, Provider> | undefined

View file

@ -7,45 +7,31 @@ import { Location } from "./location"
import { AgentV2 } from "./agent"
import { SessionV2 } from "./session"
import { SessionStore } from "./session/store"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
import { Wildcard } from "./util/wildcard"
import { PermissionSaved } from "./permission/saved"
export { Effect, Rule, Ruleset } from "@opencode-ai/schema/permission"
const missingAgentPermissions: Permission.Ruleset = [{ action: "*", resource: "*", effect: "deny" }]
export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe(
Schema.brand("PermissionV2.ID"),
withStatics((schema) => ({ create: (id?: string) => schema.make(id ?? "per_" + Identifier.ascending()) })),
)
export const ID = Permission.ID
export type ID = typeof ID.Type
export const Source = Schema.Union([
Schema.Struct({
type: Schema.Literal("tool"),
messageID: Schema.String,
callID: Schema.String,
}),
]).annotate({ identifier: "PermissionV2.Source" })
export const Source = Permission.Source
export type Source = typeof Source.Type
const RequestFields = {
sessionID: SessionV2.ID,
action: Schema.String,
resources: Schema.Array(Schema.String),
save: Schema.Array(Schema.String).pipe(Schema.optional),
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
source: Source.pipe(Schema.optional),
sessionID: Permission.Request.fields.sessionID,
action: Permission.Request.fields.action,
resources: Permission.Request.fields.resources,
save: Permission.Request.fields.save,
metadata: Permission.Request.fields.metadata,
source: Permission.Request.fields.source,
}
export const Request = Schema.Struct({
id: ID,
...RequestFields,
}).annotate({ identifier: "PermissionV2.Request" })
export const Request = Permission.Request
export type Request = typeof Request.Type
export const Reply = Schema.Literals(["once", "always", "reject"]).annotate({ identifier: "PermissionV2.Reply" })
export const Reply = Permission.Reply
export type Reply = typeof Reply.Type
export const AssertInput = Schema.Struct({
@ -68,17 +54,7 @@ export const AskResult = Schema.Struct({
}).annotate({ identifier: "PermissionV2.AskResult" })
export type AskResult = typeof AskResult.Type
export const Event = {
Asked: EventV2.define({ type: "permission.v2.asked", schema: Request.fields }),
Replied: EventV2.define({
type: "permission.v2.replied",
schema: {
sessionID: SessionV2.ID,
requestID: ID,
reply: Reply,
},
}),
}
export const Event = Permission.Event
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionV2.RejectedError", {}) {}

View file

@ -4,22 +4,13 @@ import { eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { ProjectV2 } from "../project"
import { withStatics } from "../schema"
import { Identifier } from "../util/identifier"
import { PermissionTable } from "./sql"
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
export const ID = Schema.String.pipe(
Schema.brand("PermissionSaved.ID"),
withStatics((schema) => ({ create: () => schema.make("psv_" + Identifier.ascending()) })),
)
export const ID = PermissionSaved.ID
export type ID = typeof ID.Type
export const Info = Schema.Struct({
id: ID,
projectID: ProjectV2.ID,
action: Schema.String,
resource: Schema.String,
}).annotate({ identifier: "PermissionSaved.Info" })
export const Info = PermissionSaved.Info
export type Info = typeof Info.Type
export const ListInput = Schema.Struct({

View file

@ -1,7 +1,8 @@
export * as PluginV2 from "./plugin"
import { Context, Deferred, Effect, Exit, Layer, Schema, Scope } from "effect"
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import { PluginEvent, PluginID } from "@opencode-ai/schema/plugin"
import { AgentV2 } from "./agent"
import { AISDK } from "./aisdk"
import { Catalog } from "./catalog"
@ -14,17 +15,10 @@ import { Reference } from "./reference"
import { SkillV2 } from "./skill"
import { State } from "./state"
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
export const ID = PluginID
export type ID = typeof ID.Type
export const Event = {
Added: EventV2.define({
type: "plugin.added",
schema: {
id: ID,
},
}),
}
export const Event = PluginEvent
export interface Interface {
readonly add: (id: ID, effect: Plugin["effect"]) => Effect.Effect<void>

View file

@ -120,11 +120,6 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
provider.headers = { ...provider.headers, ...item.options?.headers }
})
const modelIDs = new Set(Object.keys(item.models ?? {}))
for (const model of catalog.provider.get(providerID)?.models.values() ?? []) {
if (!modelIDs.has(model.id)) catalog.model.remove(providerID, model.id)
}
for (const [modelID, config] of Object.entries(item.models ?? {})) {
catalog.model.update(providerID, modelID, (model) => {
if (config.family !== undefined) model.family = config.family

View file

@ -70,8 +70,8 @@ export const layer = Layer.effect(
)
})
const remote = Effect.fnUntraced(function* (repo: Git.Repo) {
const origin = yield* git.remote(repo)
const remote = Effect.fnUntraced(function* (repo: Git.Repository) {
const origin = yield* git.remote.get(repo)
if (!origin) return undefined
const normalized = url(origin)
if (!normalized) return undefined
@ -102,22 +102,22 @@ export const layer = Layer.effect(
return `${host.toLowerCase()}/${pathname}`
}
const root = Effect.fnUntraced(function* (repo: Git.Repo) {
const root = (yield* git.roots(repo))[0]
const root = Effect.fnUntraced(function* (repo: Git.Repository) {
const root = (yield* git.history.rootCommits(repo))[0]
return root ? ID.make(root) : undefined
})
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
const repo = yield* git.find(input)
const repo = yield* git.repo.discover(input)
if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
const previous = yield* cached(repo.store)
const previous = yield* cached(repo.commonDirectory)
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
return {
previous,
id: id ?? ID.global,
directory: repo.directory,
vcs: { type: "git" as const, store: repo.store },
directory: repo.worktree,
vcs: { type: "git" as const, store: repo.commonDirectory },
}
})

View file

@ -1,4 +1,3 @@
import path from "path"
import { Effect } from "effect"
import { AbsolutePath } from "../schema"
import { Git } from "../git"
@ -8,28 +7,26 @@ export function makeGitWorktreeStrategy(input: {
git: Git.Interface
canonical: (directory: AbsolutePath) => Effect.Effect<AbsolutePath, DirectoryUnavailableError>
}) {
const repo = (sourceDirectory: AbsolutePath) =>
({ directory: sourceDirectory, store: sourceDirectory }) satisfies Git.Repo
return {
id: StrategyID.make("git_worktree"),
create: Effect.fn("ProjectCopy.GitWorktree.create")(function* (options) {
yield* input.git.worktreeCreate({ repo: repo(options.sourceDirectory), directory: options.directory })
const repository = yield* input.git.repo.discover(options.sourceDirectory)
if (!repository) return yield* new DirectoryUnavailableError({ directory: options.sourceDirectory })
yield* input.git.worktree.create({ repository, directory: options.directory })
return { directory: yield* input.canonical(options.directory) }
}),
remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (options) {
const found = yield* input.git.find(options.directory)
const found = yield* input.git.repo.discover(options.directory)
if (!found) return yield* new DirectoryUnavailableError({ directory: options.directory })
yield* input.git.worktreeRemove({ repo: found, directory: options.directory, force: options.force })
yield* input.git.worktree.remove({ repository: found, directory: options.directory, force: options.force })
}),
list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) {
const found = yield* input.git.find(directory)
const found = yield* input.git.repo.discover(directory)
if (!found) return yield* new DirectoryUnavailableError({ directory })
const core = path.basename(found.store) === ".git" ? path.dirname(found.store) : found.store
const entries = yield* input.git.worktreeList(found)
const entries = yield* input.git.worktree.list(found)
return yield* Effect.forEach(entries, (entry) =>
input.canonical(entry).pipe(
Effect.map((directory) => ({ directory, type: entry === core ? "root" : "copy" }) as const),
input.canonical(entry.directory).pipe(
Effect.map((directory) => ({ directory, type: entry.kind === "main" ? "root" : "copy" }) as const),
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)),
),
).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined)))

View file

@ -13,24 +13,16 @@ import { Slug } from "../util/slug"
import { EventV2 } from "../event"
import { Database } from "../database/database"
import { Location } from "../location"
import { ProjectDirectoriesEvent } from "@opencode-ai/schema/project-directories"
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID"))
export const StrategyID = ProjectCopy.StrategyID
export type StrategyID = typeof StrategyID.Type
export const CreateInput = Schema.Struct({
projectID: Project.ID,
strategy: StrategyID,
sourceDirectory: AbsolutePath,
directory: AbsolutePath,
name: Schema.optional(Schema.String),
}).annotate({ identifier: "ProjectCopy.CreateInput" })
export const CreateInput = ProjectCopy.CreateInput
export type CreateInput = typeof CreateInput.Type
export const RemoveInput = Schema.Struct({
projectID: Project.ID,
directory: AbsolutePath,
force: Schema.Boolean,
}).annotate({ identifier: "ProjectCopy.RemoveInput" })
export const RemoveInput = ProjectCopy.RemoveInput
export type RemoveInput = typeof RemoveInput.Type
export const RefreshInput = Schema.Struct({
@ -44,9 +36,7 @@ export const RefreshResult = Schema.Struct({
}).annotate({ identifier: "ProjectCopy.RefreshResult" })
export type RefreshResult = typeof RefreshResult.Type
export const Copy = Schema.Struct({
directory: AbsolutePath,
}).annotate({ identifier: "ProjectCopy.Copy" })
export const Copy = ProjectCopy.Copy
export type Copy = typeof Copy.Type
export const ListEntry = Schema.Struct({
@ -106,12 +96,7 @@ export interface Strategy {
readonly list: (directory: AbsolutePath) => Effect.Effect<ListEntry[], Git.WorktreeError | DirectoryUnavailableError>
}
export const Event = {
Updated: EventV2.define({
type: "project.directories.updated",
schema: { projectID: Project.ID },
}),
}
export const Event = ProjectDirectoriesEvent
export interface Interface {
readonly register: (strategy: Strategy) => Effect.Effect<void, DuplicateStrategyError>

View file

@ -2,10 +2,10 @@ export * as Pty from "./pty"
import type { Disp, Proc } from "#pty"
import { Context, Effect, Layer, Schema, Types } from "effect"
import { PtyEvent, PtyInfo, Pty } from "@opencode-ai/schema/pty"
import { Config } from "./config"
import { EventV2 } from "./event"
import { Location } from "./location"
import { NonNegativeInt, PositiveInt } from "./schema"
import { PtyID } from "./pty/schema"
import { Shell } from "./shell"
import { lazy } from "./util/lazy"
@ -35,40 +35,15 @@ type Active = {
listeners: Disp[]
}
export const Info = Schema.Struct({
id: PtyID,
title: Schema.String,
command: Schema.String,
args: Schema.Array(Schema.String),
cwd: Schema.String,
status: Schema.Literals(["running", "exited"]),
// Windows ConPTY assigns the child pid asynchronously, so 0 is valid at spawn time.
pid: NonNegativeInt,
// Present once status is "exited".
exitCode: Schema.optional(NonNegativeInt),
}).annotate({ identifier: "Pty" })
export const Info = PtyInfo
export type Info = Types.DeepMutable<typeof Info.Type>
export const CreateInput = Schema.Struct({
command: Schema.optional(Schema.String),
args: Schema.optional(Schema.Array(Schema.String)),
cwd: Schema.optional(Schema.String),
title: Schema.optional(Schema.String),
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
})
export const CreateInput = Pty.CreateInput
export type CreateInput = Types.DeepMutable<typeof CreateInput.Type>
export const UpdateInput = Schema.Struct({
title: Schema.optional(Schema.String),
size: Schema.optional(
Schema.Struct({
rows: PositiveInt,
cols: PositiveInt,
}),
),
})
export const UpdateInput = Pty.UpdateInput
export type UpdateInput = Types.DeepMutable<typeof UpdateInput.Type>
@ -100,12 +75,7 @@ export class ExitedError extends Schema.TaggedErrorClass<ExitedError>()("Pty.Exi
ptyID: PtyID,
}) {}
export const Event = {
Created: EventV2.define({ type: "pty.created", schema: { info: Info } }),
Updated: EventV2.define({ type: "pty.updated", schema: { info: Info } }),
Exited: EventV2.define({ type: "pty.exited", schema: { id: PtyID, exitCode: NonNegativeInt } }),
Deleted: EventV2.define({ type: "pty.deleted", schema: { id: PtyID } }),
}
export const Event = PtyEvent
export interface Interface {
readonly list: () => Effect.Effect<Info[]>

View file

@ -1,13 +1 @@
import { Schema } from "effect"
import { Identifier } from "../id/id"
import { withStatics } from "../schema"
const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID"))
export type PtyID = typeof ptyIdSchema.Type
export const PtyID = ptyIdSchema.pipe(
withStatics((schema: typeof ptyIdSchema) => ({
ascending: (id?: string) => schema.make(Identifier.ascending("pty", id)),
})),
)
export { ID as PtyID } from "@opencode-ai/schema/pty"

View file

@ -1,18 +1,15 @@
export * as PtyTicket from "./ticket"
import { WorkspaceV2 } from "../workspace"
import { PositiveInt } from "../schema"
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
import { PtyID } from "./schema"
import { Cache, Context, Duration, Effect, Layer, Schema } from "effect"
import { Cache, Context, Duration, Effect, Layer } from "effect"
import { LayerNode } from "../effect/layer-node"
const DEFAULT_TTL = Duration.seconds(60)
const CAPACITY = 10_000
export const ConnectToken = Schema.Struct({
ticket: Schema.String,
expires_in: PositiveInt,
})
export const ConnectToken = PtyTicket.ConnectToken
export type Scope = {
readonly ptyID: PtyID

View file

@ -0,0 +1,3 @@
export * as PublicEventManifest from "./public-event-manifest"
export { ServerDefinitions as Definitions } from "@opencode-ai/schema/event-manifest"

View file

@ -1,6 +0,0 @@
export * as Agent from "./agent"
import { AgentV2 } from "../agent"
export const ID = AgentV2.ID
export type ID = AgentV2.ID

View file

@ -1,9 +0,0 @@
/** Intentional supported native API. Other core subpaths remain internal implementation surfaces. */
export { Agent } from "./agent"
export { Model } from "./model"
export { OpenCode } from "./opencode"
export { Session } from "./session"
export { Tool } from "./tool"
export { Location } from "./location"
export { Prompt } from "../session/prompt"
export { AbsolutePath } from "../schema"

View file

@ -1,6 +0,0 @@
export * as Location from "./location"
import { Location } from "../location"
export const Ref = Location.Ref
export type Ref = Location.Ref

View file

@ -1,9 +0,0 @@
export * as Model from "./model"
import { ModelV2 } from "../model"
export const ID = ModelV2.ID
export type ID = ModelV2.ID
export const Ref = ModelV2.Ref
export type Ref = ModelV2.Ref

View file

@ -1,76 +0,0 @@
export * as OpenCode from "./opencode"
import { Context, Effect, Layer } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { LocationServiceMap } from "../location-layer"
import { ProjectV2 } from "../project"
import { SessionV2 } from "../session"
import * as SessionExecutionLocal from "../session/execution/local"
import { SessionProjector } from "../session/projector"
import { SessionStore } from "../session/store"
import { ApplicationTools } from "../tool/application-tools"
import { Session } from "./session"
import { Tool } from "./tool"
export interface Interface {
readonly sessions: Session.Interface
readonly tools: Tool.Interface
}
/** Intentional public native API for Effect applications embedding OpenCode. */
export class Service extends Context.Service<Service, Interface>()("@opencode/public/OpenCode") {}
const SessionsLayer = SessionV2.layer.pipe(
Layer.provide(SessionProjector.layer),
Layer.provide(SessionExecutionLocal.layer),
Layer.provide(SessionStore.layer),
Layer.provide(EventV2.layer),
Layer.provide(Database.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(LocationServiceMap.layer.pipe(Layer.provide(ApplicationTools.layer))),
Layer.orDie,
)
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const sessions = yield* SessionV2.Service
const tools = yield* ApplicationTools.Service
return Service.of({
tools: { register: tools.register },
sessions: {
create: (input) =>
sessions.create({
id: input.id,
agent: input.agent,
model: input.model,
location: input.location,
}),
get: sessions.get,
list: sessions.list,
switchModel: sessions.switchModel,
interrupt: sessions.interrupt,
prompt: (input) =>
sessions.prompt({
id: input.id,
sessionID: input.sessionID,
prompt: input.prompt,
delivery: input.delivery,
}),
messages: (input) =>
sessions.messages({
sessionID: input.sessionID,
limit: input.limit,
order: input.order,
cursor: input.cursor,
}),
message: (input) => sessions.message({ sessionID: input.sessionID, messageID: input.messageID }),
context: sessions.context,
events: (input) => sessions.events({ sessionID: input.sessionID, after: input.after }),
},
})
}),
).pipe(Layer.provide(Layer.merge(ApplicationTools.layer, SessionsLayer)))
// TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics.

View file

@ -1,96 +0,0 @@
export * as Session from "./session"
import { Effect, Stream } from "effect"
import { SessionV2 } from "../session"
import { MessageDecodeError } from "../session/error"
import { SessionEvent } from "../session/event"
import { SessionInput } from "../session/input"
import { SessionMessage } from "../session/message"
import { Prompt } from "../session/prompt"
import { Agent } from "./agent"
import { Location } from "./location"
import { Model } from "./model"
export const ID = SessionV2.ID
export type ID = SessionV2.ID
export const Info = SessionV2.Info
export type Info = SessionV2.Info
export const MessageID = SessionMessage.ID
export type MessageID = SessionMessage.ID
export const Message = SessionMessage.Message
export type Message = SessionMessage.Message
export const Admission = SessionInput.Admitted
export type Admission = SessionInput.Admitted
export const Delivery = SessionInput.Delivery
export type Delivery = SessionInput.Delivery
export const ListInput = SessionV2.ListInput
export type ListInput = SessionV2.ListInput
export type Event = SessionEvent.DurableEvent
export const NotFoundError = SessionV2.NotFoundError
export type NotFoundError = SessionV2.NotFoundError
export const PromptConflictError = SessionV2.PromptConflictError
export type PromptConflictError = SessionV2.PromptConflictError
export { MessageDecodeError }
export interface CreateInput {
readonly id?: ID
readonly agent?: Agent.ID
readonly model?: Model.Ref
readonly location: Location.Ref
}
export interface PromptInput {
readonly id?: MessageID
readonly sessionID: ID
readonly prompt: Prompt
readonly delivery?: Delivery
}
export interface SwitchModelInput {
readonly sessionID: ID
readonly model: Model.Ref
}
export interface MessagesInput {
readonly sessionID: ID
readonly limit?: number
readonly order?: "asc" | "desc"
readonly cursor?: {
readonly id: MessageID
readonly direction: "previous" | "next"
}
}
export interface MessageInput {
readonly sessionID: ID
readonly messageID: MessageID
}
export interface EventsInput {
readonly sessionID: ID
readonly after?: number
}
export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<Info>
readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError>
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
readonly prompt: (input: PromptInput) => Effect.Effect<Admission, NotFoundError | PromptConflictError>
readonly switchModel: (input: SwitchModelInput) => Effect.Effect<void, NotFoundError>
/** Interrupt the active V2 execution chain for one Session on this process. Interrupting an idle or missing Session is a no-op. */
readonly interrupt: (sessionID: ID) => Effect.Effect<void>
readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
readonly message: (input: MessageInput) => Effect.Effect<Message | undefined>
readonly context: (sessionID: ID) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
readonly events: (input: EventsInput) => Stream.Stream<Event, NotFoundError>
}

View file

@ -1,17 +0,0 @@
export * as Tool from "./tool"
import { Effect, Scope } from "effect"
import type { AnyTool, RegistrationError } from "../tool/tool"
export { Failure, RegistrationError, make } from "../tool/tool"
export type { AnyTool, Content, Context, Definition } from "../tool/tool"
export interface Interface {
/**
* Register same-process tools on this OpenCode instance for the current Scope.
* Location tools with the same name take precedence where they are installed.
* Closing the Scope removes the tools immediately, so calls that have not
* started settling may fail because the tool is no longer available.
*/
readonly register: (tools: Readonly<Record<string, AnyTool>>) => Effect.Effect<void, RegistrationError, Scope.Scope>
}

View file

@ -1,83 +1,35 @@
export * as QuestionV2 from "./question"
import { Context, Deferred, Effect, Layer, Schema } from "effect"
import { Question } from "@opencode-ai/schema/question"
import { EventV2 } from "./event"
import { Identifier } from "./id/id"
import { withStatics } from "./schema"
import { SessionSchema } from "./session/schema"
export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe(
Schema.brand("QuestionV2.ID"),
withStatics((schema) => ({ ascending: (id?: string) => schema.make(Identifier.ascending("question", id)) })),
)
export const ID = Question.ID
export type ID = typeof ID.Type
export const Option = Schema.Struct({
label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }),
description: Schema.String.annotate({ description: "Explanation of choice" }),
}).annotate({ identifier: "QuestionV2.Option" })
export const Option = Question.Option
export type Option = typeof Option.Type
const base = {
question: Schema.String.annotate({ description: "Complete question" }),
header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }),
options: Schema.Array(Option).annotate({ description: "Available choices" }),
multiple: Schema.Boolean.pipe(Schema.optional).annotate({ description: "Allow selecting multiple choices" }),
}
export const Info = Schema.Struct({
...base,
custom: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Allow typing a custom answer (default: true)",
}),
}).annotate({ identifier: "QuestionV2.Info" })
export const Info = Question.Info
export type Info = typeof Info.Type
export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionV2.Prompt" })
export const Prompt = Question.Prompt
export type Prompt = typeof Prompt.Type
export const Tool = Schema.Struct({
messageID: Schema.String,
callID: Schema.String,
}).annotate({ identifier: "QuestionV2.Tool" })
export const Tool = Question.Tool
export type Tool = typeof Tool.Type
export const Request = Schema.Struct({
id: ID,
sessionID: SessionSchema.ID,
questions: Schema.Array(Info).annotate({ description: "Questions to ask" }),
tool: Tool.pipe(Schema.optional),
}).annotate({ identifier: "QuestionV2.Request" })
export const Request = Question.Request
export type Request = typeof Request.Type
export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionV2.Answer" })
export const Answer = Question.Answer
export type Answer = typeof Answer.Type
export const Reply = Schema.Struct({
answers: Schema.Array(Answer).annotate({
description: "User answers in order of questions (each answer is an array of selected labels)",
}),
}).annotate({ identifier: "QuestionV2.Reply" })
export const Reply = Question.Reply
export type Reply = typeof Reply.Type
export const Event = {
Asked: EventV2.define({ type: "question.v2.asked", schema: Request.fields }),
Replied: EventV2.define({
type: "question.v2.replied",
schema: {
sessionID: SessionSchema.ID,
requestID: ID,
answers: Schema.Array(Answer),
},
}),
Rejected: EventV2.define({
type: "question.v2.rejected",
schema: {
sessionID: SessionSchema.ID,
requestID: ID,
},
}),
}
export const Event = Question.Event
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionV2.RejectedError", {}) {
override get message() {

View file

@ -1,6 +1,6 @@
export * as Reference from "./reference"
import { Context, Effect, Layer, Schema, Scope, Types } from "effect"
import { Context, Effect, Layer, Scope, Types } from "effect"
import { Reference } from "@opencode-ai/schema/reference"
import { Global } from "./global"
import { EventV2 } from "./event"
@ -18,17 +18,10 @@ export type GitSource = Reference.GitSource
export const Source = Reference.Source
export type Source = Reference.Source
export const Event = {
Updated: EventV2.define({ type: "reference.updated", schema: {} }),
}
export const Event = Reference.Event
export class Info extends Schema.Class<Info>("Reference.Info")({
name: Schema.String,
path: AbsolutePath,
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
source: Source,
}) {}
export const Info = Reference.Info
export type Info = Reference.Info
type Data = {
sources: Map<string, Types.DeepMutable<Source>>

View file

@ -4,6 +4,7 @@ import { FSUtil } from "./fs-util"
import { Git } from "./git"
import { Global } from "./global"
import { Repository } from "./repository"
import { AbsolutePath } from "./schema"
import { EffectFlock } from "./util/effect-flock"
export type Result = {
@ -142,15 +143,15 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service | E
yield* cacheOperation(fs.ensureDir(path.dirname(localPath)), "ensure cache directory", localPath)
const exists = yield* fs.existsSafe(localPath)
const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git"))
const origin = hasGitDir ? yield* git.origin(localPath) : undefined
const existing = yield* git.repo.discover(AbsolutePath.make(localPath))
const origin = existing ? yield* git.remote.get(existing) : undefined
const originReference = origin ? Repository.parse(origin) : undefined
const reuse = hasGitDir && Boolean(originReference && Repository.same(originReference, cloneTarget))
const reuse = Boolean(existing && originReference && Repository.same(originReference, cloneTarget))
if (exists && !reuse) {
yield* cacheOperation(fs.remove(localPath, { recursive: true }), "remove stale cache", localPath)
}
const currentBranch = reuse ? yield* git.branch(localPath) : undefined
const currentBranch = reuse && existing ? yield* git.history.branch(existing) : undefined
const status = statusForRepository({
reuse,
refresh: input.refresh,
@ -158,86 +159,55 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service | E
})
if (status === "cloned") {
const result = yield* git
.clone({ remote: input.reference.remote, target: localPath, branch: input.branch })
.pipe(
Effect.mapError((error) => new CloneFailedError({ repository, message: errorMessage(error) })),
)
if (result.exitCode !== 0) {
return yield* new CloneFailedError({
repository,
message: resultMessage(result, `Failed to clone ${repository}`),
yield* git.repo
.clone({
remote: input.reference.remote,
directory: AbsolutePath.make(localPath),
branch: input.branch,
})
}
.pipe(Effect.mapError((error) => new CloneFailedError({ repository, message: error.message })))
}
if (status === "refreshed") {
const fetch = yield* git
.fetch(localPath)
.pipe(
Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })),
)
if (fetch.exitCode !== 0) {
return yield* new FetchFailedError({
repository,
message: resultMessage(fetch, `Failed to refresh ${repository}`),
})
}
if (!existing)
return yield* new FetchFailedError({ repository, message: "Repository is unavailable" })
yield* git.sync
.fetchRemotes(existing)
.pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message })))
if (input.branch) {
const requestedBranch = input.branch
const fetchBranch = yield* git
.fetchBranch(localPath, requestedBranch)
.pipe(
Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })),
)
if (fetchBranch.exitCode !== 0) {
return yield* new FetchFailedError({
repository,
message: resultMessage(fetchBranch, `Failed to fetch ${requestedBranch}`),
})
}
yield* git.sync
.fetchBranch(existing, { branch: requestedBranch })
.pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message })))
const checkout = yield* git.checkout(localPath, requestedBranch).pipe(
yield* git.sync.checkoutRemoteBranch(existing, { branch: requestedBranch }).pipe(
Effect.mapError(
(error) =>
new CheckoutFailedError({
repository,
branch: requestedBranch,
message: errorMessage(error),
message: error.message,
}),
),
)
if (checkout.exitCode !== 0) {
return yield* new CheckoutFailedError({
repository,
branch: requestedBranch,
message: resultMessage(checkout, `Failed to checkout ${requestedBranch}`),
})
}
}
const reset = yield* git
.reset(localPath, yield* resetTarget(git, localPath, input.branch))
.pipe(
Effect.mapError((error) => new ResetFailedError({ repository, message: errorMessage(error) })),
)
if (reset.exitCode !== 0) {
return yield* new ResetFailedError({
repository,
message: resultMessage(reset, `Failed to reset ${repository}`),
})
}
yield* git.sync
.resetHard(existing, yield* resetTarget(git, existing, input.branch))
.pipe(Effect.mapError((error) => new ResetFailedError({ repository, message: error.message })))
}
const checkout = yield* git.repo.discover(AbsolutePath.make(localPath))
return {
repository,
host: input.reference.host,
remote: input.reference.remote,
localPath,
status,
head: yield* git.head(localPath),
branch: yield* git.branch(localPath),
head: checkout ? yield* git.history.head(checkout) : undefined,
branch: checkout ? yield* git.history.branch(checkout) : undefined,
} satisfies Result
}),
`repository-cache:${localPath}`,
@ -275,17 +245,17 @@ function cacheOperation<A, E, R>(effect: Effect.Effect<A, E, R>, operation: stri
)
}
const resetTarget = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, requestedBranch?: string) {
const resetTarget = Effect.fnUntraced(function* (
git: Git.Interface,
repository: Git.Repository,
requestedBranch?: string,
) {
if (requestedBranch) return `origin/${requestedBranch}`
const remoteHead = yield* git.remoteHead(cwd)
if (remoteHead) return remoteHead
const currentBranch = yield* git.branch(cwd)
const remoteHead = yield* git.history.defaultRemoteBranch(repository)
if (remoteHead) return `origin/${remoteHead}`
const currentBranch = yield* git.history.branch(repository)
if (currentBranch) return `origin/${currentBranch}`
return "HEAD"
})
function resultMessage(result: Git.Result, fallback: string) {
return result.stderr.trim() || result.text.trim() || fallback
}
export * as RepositoryCache from "./repository-cache"

View file

@ -29,6 +29,12 @@ import { SessionExecution } from "./session/execution"
import { MessageDecodeError } from "./session/error"
import { SessionEvent } from "./session/event"
import { SessionInput } from "./session/input"
import { Snapshot } from "./snapshot"
import { SessionRevert } from "./session/revert"
import { Revert } from "@opencode-ai/schema/revert"
export const RevertState = Revert.State
export type RevertState = Revert.State
// get project -> project.locations
//
@ -94,6 +100,8 @@ export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictE
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
}) {}
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError | PromptConflictError
@ -149,248 +157,292 @@ export interface Interface {
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
readonly revert: {
readonly stage: (input: {
sessionID: SessionSchema.ID
messageID: SessionMessage.ID
files?: boolean
}) => Effect.Effect<Revert.State, NotFoundError | MessageNotFoundError | Snapshot.Error>
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | Snapshot.Error>
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
}
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const db = (yield* Database.Service).db
const events = yield* EventV2.Service
const projects = yield* ProjectV2.Service
const execution = yield* SessionExecution.Service
const store = yield* SessionStore.Service
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.mapError(
() =>
new MessageDecodeError({
sessionID: SessionSchema.ID.make(row.session_id),
messageID: SessionMessage.ID.make(row.id),
}),
),
)
const result = Service.of({
create: Effect.fn("V2Session.create")(function* (input) {
const sessionID = input.id ?? SessionSchema.ID.create()
const recorded = yield* store.get(sessionID)
if (recorded) return recorded
const project = yield* projects.resolve(input.location.directory)
yield* db
.insert(ProjectTable)
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
const now = Date.now()
const info = SessionV1.SessionInfo.make({
id: sessionID,
slug: Slug.create(),
version: InstallationVersion,
projectID: project.id,
directory: input.location.directory,
path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"),
workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined,
title: `New session - ${new Date(now).toISOString()}`,
agent: input.agent,
model: input.model
? {
id: ModelV2.ID.make(input.model.id),
providerID: input.model.providerID,
variant: input.model.variant,
}
: undefined,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: now, updated: now },
})
const projected = yield* events
.publish(SessionV1.Event.Created, { sessionID, info }, { location: input.location })
.pipe(
Effect.as({ type: "created" } as const),
Effect.catchDefect((defect) => {
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
return Effect.die(defect)
}
// Concurrent creation lost the projection race. The existing Session identity wins.
return store
.get(sessionID)
.pipe(
Effect.flatMap((session) =>
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
),
)
}),
)
if (projected.type === "existing") return projected.session
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
return yield* result.get(sessionID).pipe(Effect.orDie)
}),
get: Effect.fn("V2Session.get")(function* (sessionID) {
const session = yield* store.get(sessionID)
if (!session) return yield* new NotFoundError({ sessionID })
return session
}),
list: Effect.fn("V2Session.list")(function* (input = {}) {
const direction = input.anchor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const sortColumn = SessionTable.time_created
const conditions: SQL[] = []
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
if (input.anchor) {
conditions.push(
order === "asc"
? or(
gt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
)!
: or(
lt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
)!,
)
}
const query = db
.select()
.from(SessionTable)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(
order === "asc" ? asc(sortColumn) : desc(sortColumn),
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
)
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
}),
messages: Effect.fn("V2Session.messages")(function* (input) {
yield* result.get(input.sessionID)
const direction = input.cursor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const anchor = input.cursor
? yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
)
.get()
.pipe(Effect.orDie)
: undefined
if (input.cursor && !anchor) return []
const boundary = anchor
? order === "asc"
? gt(SessionMessageTable.seq, anchor.seq)
: lt(SessionMessageTable.seq, anchor.seq)
: undefined
const where = boundary
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
: eq(SessionMessageTable.session_id, input.sessionID)
const query = db
.select()
.from(SessionMessageTable)
.where(where)
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode)
}),
message: Effect.fn("V2Session.message")(function* (input) {
const stored = yield* store.message(input.messageID)
return stored?.sessionID === input.sessionID ? stored.message : undefined
}),
context: Effect.fn("V2Session.context")(function* (sessionID) {
yield* result.get(sessionID)
return yield* store.context(sessionID)
}),
events: (input) =>
Stream.unwrap(
result
.get(input.sessionID)
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
prompt: Effect.fn("V2Session.prompt")((input) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* result.get(input.sessionID)
const messageID = input.id ?? SessionMessage.ID.create()
const delivery = input.delivery ?? "steer"
const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery }
const admitted = yield* SessionInput.admit(db, events, {
id: messageID,
sessionID: input.sessionID,
prompt: input.prompt,
delivery,
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInput.LifecycleConflict
? new PromptConflictError({ sessionID: input.sessionID, messageID })
: Effect.die(defect),
export const layer = Layer.unwrap(
Effect.promise(() => import("./location-layer")).pipe(
Effect.map(({ LocationServiceMap }) =>
Layer.effect(
Service,
Effect.gen(function* () {
const database = yield* Database.Service
const db = database.db
const events = yield* EventV2.Service
const projects = yield* ProjectV2.Service
const execution = yield* SessionExecution.Service
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.mapError(
() =>
new MessageDecodeError({
sessionID: SessionSchema.ID.make(row.session_id),
messageID: SessionMessage.ID.make(row.id),
}),
),
)
if (!SessionInput.equivalent(admitted, expected))
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
return admitted
}),
),
),
shell: Effect.fn("V2Session.shell")(function* () {
return yield* new OperationUnavailableError({ operation: "shell" })
}),
skill: Effect.fn("V2Session.skill")(function* () {
return yield* new OperationUnavailableError({ operation: "skill" })
}),
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
yield* result.get(input.sessionID)
yield* events.publish(SessionEvent.AgentSwitched, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
agent: input.agent,
})
}),
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
yield* result.get(input.sessionID)
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
model: input.model,
})
}),
compact: Effect.fn("V2Session.compact")(function* (input) {
yield* result.get(input.sessionID)
return yield* new OperationUnavailableError({ operation: "compact" })
}),
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
yield* result.get(sessionID)
return yield* new OperationUnavailableError({ operation: "wait" })
}),
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
yield* result.get(sessionID)
yield* execution.resume(sessionID)
}),
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
Effect.uninterruptible(execution.interrupt(sessionID)),
),
})
return result
}),
const result = Service.of({
create: Effect.fn("V2Session.create")(function* (input) {
const sessionID = input.id ?? SessionSchema.ID.create()
const recorded = yield* store.get(sessionID)
if (recorded) return recorded
const project = yield* projects.resolve(input.location.directory)
yield* db
.insert(ProjectTable)
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
const now = Date.now()
const info = SessionV1.SessionInfo.make({
id: sessionID,
slug: Slug.create(),
version: InstallationVersion,
projectID: project.id,
directory: input.location.directory,
path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"),
workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined,
title: `New session - ${new Date(now).toISOString()}`,
agent: input.agent,
model: input.model
? {
id: ModelV2.ID.make(input.model.id),
providerID: input.model.providerID,
variant: input.model.variant,
}
: undefined,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: now, updated: now },
})
const projected = yield* events
.publish(SessionV1.Event.Created, { sessionID, info }, { location: input.location })
.pipe(
Effect.as({ type: "created" } as const),
Effect.catchDefect((defect) => {
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
return Effect.die(defect)
}
// Concurrent creation lost the projection race. The existing Session identity wins.
return store
.get(sessionID)
.pipe(
Effect.flatMap((session) =>
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
),
)
}),
)
if (projected.type === "existing") return projected.session
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
return yield* result.get(sessionID).pipe(Effect.orDie)
}),
get: Effect.fn("V2Session.get")(function* (sessionID) {
const session = yield* store.get(sessionID)
if (!session) return yield* new NotFoundError({ sessionID })
return session
}),
list: Effect.fn("V2Session.list")(function* (input = {}) {
const direction = input.anchor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const sortColumn = SessionTable.time_created
const conditions: SQL[] = []
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
if (input.anchor) {
conditions.push(
order === "asc"
? or(
gt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
)!
: or(
lt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
)!,
)
}
const query = db
.select()
.from(SessionTable)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(
order === "asc" ? asc(sortColumn) : desc(sortColumn),
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
)
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
}),
messages: Effect.fn("V2Session.messages")(function* (input) {
yield* result.get(input.sessionID)
const direction = input.cursor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const anchor = input.cursor
? yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, input.sessionID),
eq(SessionMessageTable.id, input.cursor.id),
),
)
.get()
.pipe(Effect.orDie)
: undefined
if (input.cursor && !anchor) return []
const boundary = anchor
? order === "asc"
? gt(SessionMessageTable.seq, anchor.seq)
: lt(SessionMessageTable.seq, anchor.seq)
: undefined
const where = boundary
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
: eq(SessionMessageTable.session_id, input.sessionID)
const query = db
.select()
.from(SessionMessageTable)
.where(where)
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode)
}),
message: Effect.fn("V2Session.message")(function* (input) {
const stored = yield* store.message(input.messageID)
return stored?.sessionID === input.sessionID ? stored.message : undefined
}),
context: Effect.fn("V2Session.context")(function* (sessionID) {
yield* result.get(sessionID)
return yield* store.context(sessionID)
}),
events: (input) =>
Stream.unwrap(
result
.get(input.sessionID)
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
prompt: Effect.fn("V2Session.prompt")((input) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* result.get(input.sessionID)
const messageID = input.id ?? SessionMessage.ID.create()
const delivery = input.delivery ?? "steer"
const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery }
const admitted = yield* SessionInput.admit(db, events, {
id: messageID,
sessionID: input.sessionID,
prompt: input.prompt,
delivery,
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInput.LifecycleConflict
? new PromptConflictError({ sessionID: input.sessionID, messageID })
: Effect.die(defect),
),
)
if (!SessionInput.equivalent(admitted, expected))
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
return admitted
}),
),
),
shell: Effect.fn("V2Session.shell")(function* () {
return yield* new OperationUnavailableError({ operation: "shell" })
}),
skill: Effect.fn("V2Session.skill")(function* () {
return yield* new OperationUnavailableError({ operation: "skill" })
}),
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
yield* result.get(input.sessionID)
yield* events.publish(SessionEvent.AgentSwitched, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
agent: input.agent,
})
}),
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
yield* result.get(input.sessionID)
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
model: input.model,
})
}),
compact: Effect.fn("V2Session.compact")(function* (input) {
yield* result.get(input.sessionID)
return yield* new OperationUnavailableError({ operation: "compact" })
}),
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
yield* result.get(sessionID)
return yield* new OperationUnavailableError({ operation: "wait" })
}),
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
yield* result.get(sessionID)
yield* execution.resume(sessionID)
}),
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
Effect.uninterruptible(execution.interrupt(sessionID)),
),
revert: {
stage: Effect.fn("V2Session.revert.stage")(function* (input) {
const session = yield* result.get(input.sessionID)
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
Effect.provideService(Database.Service, database),
Effect.provideService(EventV2.Service, events),
Effect.provide(locations.get(session.location)),
)
}),
clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
const session = yield* result.get(sessionID)
yield* SessionRevert.clear(session).pipe(
Effect.provideService(EventV2.Service, events),
Effect.provide(locations.get(session.location)),
)
}),
commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) {
const session = yield* result.get(sessionID)
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
}),
},
})
return result
}),
),
),
),
)
export const defaultLayer = layer.pipe(
Layer.provide(
Layer.unwrap(Effect.promise(() => import("./location-layer")).pipe(Effect.map((m) => m.LocationServiceMap.layer))),
),
Layer.provide(SessionExecution.noopLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(SessionProjector.defaultLayer),

View file

@ -1,468 +1,2 @@
import { Schema } from "effect"
import { ProviderMetadata, ToolContent } from "@opencode-ai/schema/llm"
import { Delivery } from "@opencode-ai/schema/session-delivery"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { DateTimeUtcFromMillis, NonNegativeInt, RelativePath } from "../schema"
import { FileAttachment, Prompt } from "./prompt"
import { SessionSchema } from "./schema"
import { Location } from "../location"
import { SessionMessageID } from "./message-id"
import { SessionMessage } from "./message"
export { FileAttachment }
export const Source = Schema.Struct({
start: NonNegativeInt,
end: NonNegativeInt,
text: Schema.String,
}).annotate({
identifier: "session.next.event.source",
})
export type Source = typeof Source.Type
const Base = {
timestamp: DateTimeUtcFromMillis,
sessionID: SessionSchema.ID,
}
const PromptFields = {
...Base,
messageID: SessionMessageID.ID,
prompt: Prompt,
delivery: Delivery,
}
const options = {
durable: {
aggregate: "sessionID",
version: 1,
},
} as const
const stepSettlementOptions = {
durable: {
aggregate: "sessionID",
version: 2,
},
} as const
export const UnknownError = SessionMessage.UnknownError
export type UnknownError = SessionMessage.UnknownError
export const AgentSwitched = EventV2.define({
type: "session.next.agent.switched",
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
agent: Schema.String,
},
})
export type AgentSwitched = typeof AgentSwitched.Type
export const ModelSwitched = EventV2.define({
type: "session.next.model.switched",
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
model: ModelV2.Ref,
},
})
export type ModelSwitched = typeof ModelSwitched.Type
export const Moved = EventV2.define({
type: "session.next.moved",
...options,
schema: {
...Base,
location: Location.Ref,
subdirectory: RelativePath.pipe(Schema.optional),
},
})
export type Moved = typeof Moved.Type
export const Prompted = EventV2.define({
type: "session.next.prompted",
...options,
schema: PromptFields,
})
export type Prompted = typeof Prompted.Type
export const PromptAdmitted = EventV2.define({
type: "session.next.prompt.admitted",
...options,
schema: PromptFields,
})
export type PromptAdmitted = typeof PromptAdmitted.Type
export const ContextUpdated = EventV2.define({
type: "session.next.context.updated",
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
text: Schema.String,
},
})
export type ContextUpdated = typeof ContextUpdated.Type
export const Synthetic = EventV2.define({
type: "session.next.synthetic",
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
text: Schema.String,
},
})
export type Synthetic = typeof Synthetic.Type
export namespace Shell {
export const Started = EventV2.define({
type: "session.next.shell.started",
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
callID: Schema.String,
command: Schema.String,
},
})
export type Started = typeof Started.Type
export const Ended = EventV2.define({
type: "session.next.shell.ended",
...options,
schema: {
...Base,
callID: Schema.String,
output: Schema.String,
},
})
export type Ended = typeof Ended.Type
}
export namespace Step {
export const Started = EventV2.define({
type: "session.next.step.started",
...options,
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
agent: Schema.String,
model: ModelV2.Ref,
snapshot: Schema.String.pipe(Schema.optional),
},
})
export type Started = typeof Started.Type
export const Ended = EventV2.define({
type: "session.next.step.ended",
...stepSettlementOptions,
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
finish: Schema.String,
cost: Schema.Finite,
tokens: Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
}),
snapshot: Schema.String.pipe(Schema.optional),
},
})
export type Ended = typeof Ended.Type
export const Failed = EventV2.define({
type: "session.next.step.failed",
...stepSettlementOptions,
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
error: UnknownError,
},
})
export type Failed = typeof Failed.Type
}
export namespace Text {
export const Started = EventV2.define({
type: "session.next.text.started",
...options,
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
textID: Schema.String,
},
})
export type Started = typeof Started.Type
// Stream fragments are live-only; Text.Ended is the replayable full-value boundary.
export const Delta = EventV2.define({
type: "session.next.text.delta",
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
textID: Schema.String,
delta: Schema.String,
},
})
export type Delta = typeof Delta.Type
export const Ended = EventV2.define({
type: "session.next.text.ended",
...options,
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
textID: Schema.String,
text: Schema.String,
},
})
export type Ended = typeof Ended.Type
}
export namespace Reasoning {
export const Started = EventV2.define({
type: "session.next.reasoning.started",
...options,
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
reasoningID: Schema.String,
providerMetadata: ProviderMetadata.pipe(Schema.optional),
},
})
export type Started = typeof Started.Type
// Stream fragments are live-only; Reasoning.Ended is the replayable full-value boundary.
export const Delta = EventV2.define({
type: "session.next.reasoning.delta",
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
reasoningID: Schema.String,
delta: Schema.String,
},
})
export type Delta = typeof Delta.Type
export const Ended = EventV2.define({
type: "session.next.reasoning.ended",
...options,
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
reasoningID: Schema.String,
text: Schema.String,
providerMetadata: ProviderMetadata.pipe(Schema.optional),
},
})
export type Ended = typeof Ended.Type
}
export namespace Tool {
const ToolBase = {
...Base,
assistantMessageID: SessionMessageID.ID,
callID: Schema.String,
}
export namespace Input {
export const Started = EventV2.define({
type: "session.next.tool.input.started",
...options,
schema: {
...ToolBase,
name: Schema.String,
},
})
export type Started = typeof Started.Type
// Stream fragments are live-only; Input.Ended is the replayable raw-input boundary.
export const Delta = EventV2.define({
type: "session.next.tool.input.delta",
schema: {
...ToolBase,
delta: Schema.String,
},
})
export type Delta = typeof Delta.Type
export const Ended = EventV2.define({
type: "session.next.tool.input.ended",
...options,
schema: {
...ToolBase,
text: Schema.String,
},
})
export type Ended = typeof Ended.Type
}
export const Called = EventV2.define({
type: "session.next.tool.called",
...options,
schema: {
...ToolBase,
tool: Schema.String,
input: Schema.Record(Schema.String, Schema.Unknown),
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: ProviderMetadata.pipe(Schema.optional),
}),
},
})
export type Called = typeof Called.Type
/**
* Replayable bounded running-tool state. Tools should checkpoint semantic
* transitions or at a bounded cadence, not persist every stdout/stderr chunk.
*/
export const Progress = EventV2.define({
type: "session.next.tool.progress",
...options,
schema: {
...ToolBase,
structured: Schema.Record(Schema.String, Schema.Any),
content: Schema.Array(ToolContent),
},
})
export type Progress = typeof Progress.Type
export const Success = EventV2.define({
type: "session.next.tool.success",
...options,
schema: {
...ToolBase,
structured: Schema.Record(Schema.String, Schema.Any),
content: Schema.Array(ToolContent),
outputPaths: Schema.Array(Schema.String).pipe(Schema.optional),
result: Schema.Unknown.pipe(Schema.optional),
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: ProviderMetadata.pipe(Schema.optional),
}),
},
})
export type Success = typeof Success.Type
export const Failed = EventV2.define({
type: "session.next.tool.failed",
...options,
schema: {
...ToolBase,
error: UnknownError,
result: Schema.Unknown.pipe(Schema.optional),
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: ProviderMetadata.pipe(Schema.optional),
}),
},
})
export type Failed = typeof Failed.Type
}
export const RetryError = Schema.Struct({
message: Schema.String,
statusCode: Schema.Finite.pipe(Schema.optional),
isRetryable: Schema.Boolean,
responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
responseBody: Schema.String.pipe(Schema.optional),
metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
}).annotate({
identifier: "session.next.retry_error",
})
export type RetryError = typeof RetryError.Type
export const Retried = EventV2.define({
type: "session.next.retried",
...options,
schema: {
...Base,
attempt: Schema.Finite,
error: RetryError,
},
})
export type Retried = typeof Retried.Type
export namespace Compaction {
export const Started = EventV2.define({
type: "session.next.compaction.started",
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]),
},
})
export type Started = typeof Started.Type
export const Delta = EventV2.define({
type: "session.next.compaction.delta",
schema: {
...Base,
messageID: SessionMessageID.ID,
text: Schema.String,
},
})
export type Delta = typeof Delta.Type
export const Ended = EventV2.define({
type: "session.next.compaction.ended",
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
reason: Started.data.fields.reason,
text: Schema.String,
recent: Schema.String,
},
})
export type Ended = typeof Ended.Type
}
const DurableDefinitions = [
AgentSwitched,
ModelSwitched,
Moved,
Prompted,
PromptAdmitted,
ContextUpdated,
Synthetic,
Shell.Started,
Shell.Ended,
Step.Started,
Step.Ended,
Step.Failed,
Text.Started,
Text.Ended,
Tool.Input.Started,
Tool.Input.Ended,
Tool.Called,
Tool.Progress,
Tool.Success,
Tool.Failed,
Reasoning.Started,
Reasoning.Ended,
Retried,
Compaction.Started,
Compaction.Ended,
] as const
const EphemeralDefinitions = [Text.Delta, Tool.Input.Delta, Reasoning.Delta, Compaction.Delta] as const
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
export type DurableEvent = typeof Durable.Type
export const All = Schema.Union([...DurableDefinitions, ...EphemeralDefinitions], { mode: "oneOf" }).pipe(
Schema.toTaggedUnion("type"),
)
export type Event = typeof All.Type
export type Type = Event["type"]
export * as SessionEvent from "./event"
export * from "@opencode-ai/schema/session-event"
export * as SessionEvent from "@opencode-ai/schema/session-event"

View file

@ -8,6 +8,8 @@ import { AbsolutePath, RelativePath } from "../schema"
import { WorkspaceV2 } from "../workspace"
import { SessionSchema } from "./schema"
import { SessionTable } from "./sql"
import { SessionMessageID } from "./message-id"
import { Snapshot } from "../snapshot"
export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info {
return SessionSchema.Info.make({
@ -38,6 +40,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined,
}),
subpath: row.path ? RelativePath.make(row.path) : undefined,
revert: row.revert ? { ...row.revert, messageID: SessionMessageID.ID.make(row.revert.messageID) } : undefined,
time: {
created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated),

View file

@ -212,7 +212,12 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
draft.finish = event.data.finish
draft.cost = event.data.cost
draft.tokens = event.data.tokens
if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, end: event.data.snapshot }
if (event.data.snapshot || event.data.files)
draft.snapshot = {
...draft.snapshot,
end: event.data.snapshot,
files: event.data.files ? Array.from(event.data.files) : undefined,
}
})
},
"session.next.step.failed": (event) => {
@ -380,6 +385,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}),
)
},
"session.next.revert.staged": () => Effect.void,
"session.next.revert.cleared": () => Effect.void,
"session.next.revert.committed": () => Effect.void,
})
})
}

View file

@ -1,6 +1,6 @@
export * as SessionProjector from "./projector"
import { and, desc, eq, sql } from "drizzle-orm"
import { and, desc, eq, gt, or, sql } from "drizzle-orm"
import { DateTime, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
@ -13,8 +13,9 @@ import { SessionMessageUpdater } from "./message-updater"
import { SessionInput } from "./input"
import { WorkspaceV2 } from "../workspace"
import { SessionContextEpoch } from "./context-epoch"
import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql"
import { MessageTable, PartTable, SessionInputTable, SessionMessageTable, SessionTable } from "./sql"
import type { DeepMutable } from "../schema"
import { SessionMessageID } from "./message-id"
type DatabaseService = Database.Interface["db"]
@ -66,7 +67,7 @@ function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInse
tokens_reasoning: (info.tokens ?? { reasoning: 0 }).reasoning,
tokens_cache_read: (info.tokens ?? { cache: { read: 0 } }).cache.read,
tokens_cache_write: (info.tokens ?? { cache: { write: 0 } }).cache.write,
revert: info.revert ?? null,
revert: info.revert ? { ...info.revert, messageID: SessionMessageID.ID.make(info.revert.messageID) } : null,
permission: info.permission ? [...info.permission] : undefined,
time_created: info.time.created,
time_updated: info.time.updated,
@ -393,6 +394,65 @@ export const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.RevertEvent.Staged, (event) =>
db
.update(SessionTable)
.set({
revert: { ...event.data.revert, files: event.data.revert.files ? [...event.data.revert.files] : undefined },
time_updated: DateTime.toEpochMillis(event.data.timestamp),
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie, Effect.asVoid),
)
yield* events.project(SessionEvent.RevertEvent.Cleared, (event) =>
db
.update(SessionTable)
.set({ revert: null, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie, Effect.asVoid),
)
yield* events.project(SessionEvent.RevertEvent.Committed, (event) =>
Effect.gen(function* () {
const boundary = yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.id, event.data.messageID),
),
)
.get()
.pipe(Effect.orDie)
if (!boundary) return yield* Effect.die(`Revert boundary message not found: ${event.data.messageID}`)
yield* db
.delete(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, event.data.sessionID), gt(SessionMessageTable.seq, boundary.seq)),
)
.run()
.pipe(Effect.orDie)
yield* db
.delete(SessionInputTable)
.where(
and(
eq(SessionInputTable.session_id, event.data.sessionID),
or(gt(SessionInputTable.admitted_seq, boundary.seq), gt(SessionInputTable.promoted_seq, boundary.seq)),
),
)
.run()
.pipe(Effect.orDie)
yield* db
.update(SessionTable)
.set({ revert: null, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
yield* SessionContextEpoch.reset(db, event.data.sessionID)
}),
)
}),
)

View file

@ -0,0 +1,121 @@
export * as SessionRevert from "./revert"
import { and, asc, eq, gt } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { RelativePath } from "../schema"
import { Snapshot } from "../snapshot"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
import { SessionMessageTable } from "./sql"
export class MessageNotFoundError extends Schema.TaggedErrorClass<MessageNotFoundError>()(
"Session.MessageNotFoundError",
{
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
},
) {}
interface BoundaryInput {
readonly sessionID: SessionSchema.ID
readonly messageID: SessionMessage.ID
}
const plan = Effect.fn("SessionRevert.plan")(function* (input: BoundaryInput) {
const db = (yield* Database.Service).db
const boundary = yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.messageID)))
.get()
.pipe(Effect.orDie)
if (!boundary) return yield* new MessageNotFoundError(input)
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, input.sessionID),
eq(SessionMessageTable.type, "assistant"),
gt(SessionMessageTable.seq, boundary.seq),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
const files = new Map<RelativePath, Snapshot.ID>()
for (const row of rows) {
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))
}
return files
})
export const stage = Effect.fn("SessionRevert.stage")(function* (input: {
readonly session: SessionSchema.Info
readonly messageID: SessionMessage.ID
readonly files?: boolean
}) {
const snapshot = yield* Snapshot.Service
const events = yield* EventV2.Service
const original = input.session.revert?.snapshot
? Snapshot.ID.make(input.session.revert.snapshot)
: yield* snapshot.capture()
const next = yield* plan({ sessionID: input.session.id, messageID: input.messageID })
const restore = new Map<RelativePath, Snapshot.ID>()
if (original) {
for (const file of input.session.revert?.files ?? []) restore.set(file.path, original)
}
if (input.files !== false) for (const [file, tree] of next) restore.set(file, tree)
if (restore.size) yield* snapshot.restore({ files: restore })
const paths = input.files === false ? [] : Array.from(next.keys())
const files = original
? yield* snapshot.diff({ from: original, to: (yield* snapshot.capture()) ?? original, paths })
: []
const revert = {
messageID: input.messageID,
snapshot: original,
diff: files
.map((file) => file.patch)
.join("")
.trim(),
files,
} satisfies SessionSchema.Info["revert"]
yield* events.publish(SessionEvent.RevertEvent.Staged, {
sessionID: input.session.id,
timestamp: yield* DateTime.now,
revert,
})
return revert
})
export const clear = Effect.fn("SessionRevert.clear")(function* (session: SessionSchema.Info) {
if (!session.revert) return
const snapshot = yield* Snapshot.Service
const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined
if (original)
yield* snapshot.restore({
files: new Map((session.revert.files ?? []).map((file) => [file.path, original])),
})
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.RevertEvent.Cleared, {
sessionID: session.id,
timestamp: yield* DateTime.now,
})
})
export const commit = Effect.fn("SessionRevert.commit")(function* (session: SessionSchema.Info) {
if (!session.revert) return
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.RevertEvent.Committed, {
sessionID: session.id,
messageID: session.revert.messageID,
timestamp: yield* DateTime.now,
})
})

View file

@ -35,6 +35,7 @@ import { SessionRunnerModel } from "./model"
import { createLLMEventPublisher } from "./publish-llm-event"
import { toLLMMessages } from "./to-llm-message"
import { MAX_STEPS_PROMPT } from "./max-steps"
import { Snapshot } from "../../snapshot"
/**
* Runs one durable coding-agent Session until it settles.
@ -100,6 +101,7 @@ export const layer = Layer.effect(
const skillGuidance = yield* SkillGuidance.Service
const referenceGuidance = yield* ReferenceGuidance.Service
const config = yield* Config.Service
const snapshots = yield* Snapshot.Service
const db = (yield* Database.Service).db
const compaction = SessionCompaction.make({ events, llm, config: yield* config.entries() })
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
@ -205,6 +207,7 @@ export const layer = Layer.effect(
})
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
return yield* Effect.die(continueAfterCompaction(currentStep))
const startSnapshot = yield* snapshots.capture()
const publisher = createLLMEventPublisher(events, {
sessionID: session.id,
agent: agent.id,
@ -213,6 +216,7 @@ export const layer = Layer.effect(
providerID: ProviderV2.ID.make(model.provider),
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
},
snapshot: startSnapshot,
})
const withPublication = Semaphore.makeUnsafe(1).withPermit
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
@ -302,6 +306,28 @@ export const layer = Layer.effect(
const message = failure instanceof Error ? failure.message : String(failure)
yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
}
const stepSettlement = publisher.stepSettlement()
if (stepSettlement && !publisher.hasProviderError()) {
const endSnapshot = yield* snapshots.capture()
const files =
startSnapshot && endSnapshot
? yield* snapshots
.files({ from: startSnapshot, to: endSnapshot })
.pipe(Effect.catch(() => Effect.succeed(undefined)))
: undefined
yield* withPublication(
events.publish(SessionEvent.Step.Ended, {
sessionID: session.id,
timestamp: yield* DateTime.now,
assistantMessageID: yield* publisher.startAssistant(),
finish: stepSettlement.finish,
cost: 0,
tokens: stepSettlement.tokens,
snapshot: endSnapshot,
files,
}),
)
}
if (publisher.hasProviderError())
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
if (stream._tag === "Success" && !publisher.hasProviderError())

View file

@ -10,6 +10,7 @@ type Input = {
readonly sessionID: SessionSchema.ID
readonly agent: string
readonly model: ModelV2.Ref
readonly snapshot?: string
}
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
@ -68,6 +69,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
let assistantActive = false
let assistantFailed = false
let providerFailed = false
let stepSettlement: { readonly finish: string; readonly tokens: ReturnType<typeof tokens> } | undefined
const startAssistant = Effect.fnUntraced(function* () {
if (assistantMessageID !== undefined) return assistantMessageID
@ -77,6 +79,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
...input,
assistantMessageID,
timestamp: yield* timestamp,
snapshot: input.snapshot,
})
return assistantMessageID
})
@ -393,14 +396,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
case "step-finish":
yield* flush()
assistantActive = false
yield* events.publish(SessionEvent.Step.Ended, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: yield* startAssistant(),
finish: event.reason,
cost: 0,
tokens: tokens(event.usage),
})
if (stepSettlement) return yield* Effect.die("Duplicate step finish")
stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
return
case "finish":
return
@ -419,6 +416,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
hasActiveAssistant: () => assistantActive,
hasAssistantStarted: () => assistantMessageID !== undefined,
hasProviderError: () => providerFailed,
stepSettlement: () => stepSettlement,
startAssistant,
assistantMessageID: assistantMessageIDForTool,
}
}

View file

@ -13,6 +13,7 @@ import { WorkspaceV2 } from "../workspace"
import { Timestamps } from "../database/schema.sql"
import type { SystemContext } from "../system-context/index"
import { AgentV2 } from "../agent"
import type { Revert } from "@opencode-ai/schema/revert"
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
@ -37,7 +38,7 @@ export const SessionTable = sqliteTable(
summary_additions: integer(),
summary_deletions: integer(),
summary_files: integer(),
summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
summary_diffs: text({ mode: "json" }).$type<Snapshot.LegacyFileDiff[]>(),
metadata: text({ mode: "json" }).$type<Record<string, unknown>>(),
cost: real().notNull().default(0),
tokens_input: integer().notNull().default(0),
@ -45,7 +46,7 @@ export const SessionTable = sqliteTable(
tokens_reasoning: integer().notNull().default(0),
tokens_cache_read: integer().notNull().default(0),
tokens_cache_write: integer().notNull().default(0),
revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
revert: text({ mode: "json" }).$type<Revert.State>(),
permission: text({ mode: "json" }).$type<PermissionV1.Ruleset>(),
agent: text(),
model: text({ mode: "json" }).$type<{

View file

@ -1,30 +1,17 @@
export * as SessionTodo from "./todo"
import { asc, eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Context, Effect, Layer } from "effect"
import { SessionTodo, SessionTodoInfo } from "@opencode-ai/schema/session-todo"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { SessionSchema } from "./schema"
import { TodoTable } from "./sql"
export const Info = Schema.Struct({
content: Schema.String.annotate({ description: "Brief description of the task" }),
status: Schema.String.annotate({
description: "Current status of the task: pending, in_progress, completed, cancelled",
}),
priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }),
}).annotate({ identifier: "SessionTodo.Info" })
export const Info = SessionTodoInfo
export type Info = typeof Info.Type
export const Event = {
Updated: EventV2.define({
type: "todo.updated",
schema: {
sessionID: SessionSchema.ID,
todos: Schema.Array(Info),
},
}),
}
export const Event = SessionTodo.Event
export interface Interface {
readonly update: (input: {

View file

@ -1,9 +1,259 @@
export namespace Snapshot {
export type FileDiff = {
file?: string
patch?: string
additions: number
deletions: number
status?: "added" | "deleted" | "modified"
}
export * as Snapshot from "./snapshot"
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { Config } from "./config"
import { File } from "./file"
import { FSUtil } from "./fs-util"
import { Git } from "./git"
import { Global } from "./global"
import { Location } from "./location"
import { AbsolutePath, RelativePath } from "./schema"
import { Hash } from "./util/hash"
export const ID = Schema.String.pipe(Schema.brand("Snapshot.ID"))
export type ID = typeof ID.Type
export class Error extends Schema.TaggedErrorClass<Error>()("Snapshot.Error", {
operation: Schema.Literals(["capture", "files", "diff", "preview", "restore"]),
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}
export interface CompareInput {
readonly from: ID
readonly to: ID
}
export interface DiffInput extends CompareInput {
readonly context?: number
readonly paths?: readonly RelativePath[]
}
export interface RestoreInput {
/** Paths are relative to the project root. */
readonly files: ReadonlyMap<RelativePath, ID>
}
export interface PreviewInput extends RestoreInput {
readonly context?: number
}
export interface Interface {
/**
* Capture the current Location-scoped filesystem state as a content-addressed
* tree. Returns `undefined` when snapshots are disabled, unsupported, or the
* best-effort capture fails.
*/
readonly capture: () => Effect.Effect<ID | undefined>
/**
* List project-relative paths changed between two captured trees without
* loading file contents or generating patches.
*/
readonly files: (input: CompareInput) => Effect.Effect<readonly RelativePath[], Error>
/**
* Generate structured per-file diffs between two captured trees. `context`
* controls unchanged lines around each unified diff hunk.
*/
readonly diff: (input: DiffInput) => Effect.Effect<readonly File.Diff[], Error>
/**
* Preview the filesystem result of a selective restore without modifying the
* worktree. Each project-relative path maps to the tree it would be restored
* from.
*/
readonly preview: (input: PreviewInput) => Effect.Effect<readonly File.Diff[], Error>
/**
* Restore selected project-relative paths from their associated trees. A path
* absent from its selected tree is removed; paths outside the map are untouched.
*/
readonly restore: (input: RestoreInput) => Effect.Effect<void, Error>
/**
* Replace the snapshot index with a captured tree and check out all its entries.
* Files absent from the tree remain untouched. Prefer selective `restore` when
* only known paths should change.
*/
readonly checkout: (snapshot: ID) => Effect.Effect<void, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Snapshot") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const global = yield* Global.Service
const location = yield* Location.Service
const source = yield* git.repo.discover(location.project.directory)
const worktree = source
? AbsolutePath.make(yield* fs.realPath(source.worktree).pipe(Effect.orDie))
: location.project.directory
const gitDirectory = AbsolutePath.make(path.join(global.data, "snapshot", location.project.id, Hash.fast(worktree)))
const scope = Effect.fnUntraced(function* () {
const relative = path.relative(worktree, location.directory)
if (relative.startsWith("..") || path.isAbsolute(relative))
return yield* new Error({ operation: "capture", message: "Location is outside the project" })
return RelativePath.make(relative.replaceAll("\\", "/") || ".")
})
const repository = Effect.fnUntraced(function* () {
if (!source) return yield* new Error({ operation: "capture", message: "Project is not a Git repository" })
if (yield* fs.existsSafe(path.join(gitDirectory, "HEAD")))
return new Git.Repository({
worktree,
gitDirectory,
commonDirectory: gitDirectory,
})
return yield* git.repo
.create({
worktree,
gitDirectory,
seed: source,
})
.pipe(Effect.mapError((cause) => failure("capture", cause)))
})
const enabled = Effect.fnUntraced(function* () {
if (location.vcs?.type !== "git") return false
return Config.latest(yield* config.entries(), "snapshots") !== false
})
const capture = Effect.fn("Snapshot.capture")(function* () {
if (!(yield* enabled())) return undefined
return yield* Effect.gen(function* () {
const repo = yield* repository()
return ID.make(
yield* git.tree.capture({
repository: repo,
scopes: [yield* scope()],
ignores: source,
maximumUntrackedFileBytes: 2 * 1024 * 1024,
}),
)
}).pipe(
Effect.catch((cause) => Effect.logWarning("failed to capture snapshot", { cause }).pipe(Effect.as(undefined))),
)
})
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const repo = yield* repository().pipe(Effect.mapError((cause) => failure(operation, cause)))
return { repository: repo, from: Git.TreeID.make(input.from), to: Git.TreeID.make(input.to) }
})
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
const comparison = yield* compare("files", input)
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure("files", cause)))
if (!source) return files
const ignored = yield* git.index
.ignored({ repository: source, paths: files })
.pipe(Effect.mapError((cause) => failure("files", cause)))
return files.filter((file) => !ignored.has(file))
})
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
const comparison = yield* compare("diff", input)
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure("diff", cause)))
const ignored = source
? yield* git.index
.ignored({ repository: source, paths: files })
.pipe(Effect.mapError((cause) => failure("diff", cause)))
: new Set<RelativePath>()
return yield* git.tree
.diff({
...comparison,
context: input.context,
paths: (input.paths ?? files).filter((file) => !ignored.has(file)),
})
.pipe(Effect.mapError((cause) => failure("diff", cause)))
})
const plan = Effect.fnUntraced(function* (operation: "preview" | "restore", input: RestoreInput) {
const files = new Map<RelativePath, Git.TreeID>()
for (const [file, snapshot] of input.files) {
const absolute = path.resolve(worktree, file)
if (!FSUtil.contains(worktree, absolute))
return yield* new Error({ operation, message: `Path escapes the project: ${file}` })
files.set(file, Git.TreeID.make(snapshot))
}
return files
})
const preview = Effect.fn("Snapshot.preview")(function* (input: PreviewInput) {
if (!(yield* enabled())) return yield* new Error({ operation: "preview", message: "Snapshots are disabled" })
const repo = yield* repository().pipe(Effect.mapError((cause) => failure("preview", cause)))
const files = yield* plan("preview", input)
const current = yield* git.tree
.capture({
repository: repo,
scopes: Array.from(files.keys()),
ignores: source,
maximumUntrackedFileBytes: 2 * 1024 * 1024,
})
.pipe(Effect.mapError((cause) => failure("preview", cause)))
return yield* git.tree
.preview({
repository: repo,
current,
files,
context: input.context,
})
.pipe(Effect.mapError((cause) => failure("preview", cause)))
})
const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) {
if (!(yield* enabled())) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" })
const repo = yield* repository().pipe(Effect.mapError((cause) => failure("restore", cause)))
yield* git.tree
.restore({ repository: repo, files: yield* plan("restore", input) })
.pipe(Effect.mapError((cause) => failure("restore", cause)))
})
const checkout = Effect.fn("Snapshot.checkout")(function* (snapshot: ID) {
const repo = yield* repository().pipe(Effect.mapError((cause) => failure("restore", cause)))
yield* git.tree
.checkout({ repository: repo, tree: Git.TreeID.make(snapshot) })
.pipe(Effect.mapError((cause) => failure("restore", cause)))
})
return Service.of({ capture, files, diff, preview, restore, checkout })
}),
)
export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer))
export const noopLayer = Layer.succeed(
Service,
Service.of({
capture: () => Effect.succeed(undefined),
files: () => Effect.succeed([]),
diff: () => Effect.succeed([]),
preview: () => Effect.succeed([]),
restore: () => Effect.void,
checkout: () => Effect.void,
}),
)
function failure(operation: Error["operation"], cause: unknown) {
if (cause instanceof Error && cause.operation === operation) return cause
return new Error({
operation,
message: cause instanceof globalThis.Error ? cause.message : String(cause),
cause,
})
}
/** Legacy persisted session diff shape. */
export type LegacyFileDiff = {
file?: string
patch?: string
additions: number
deletions: number
status?: "added" | "deleted" | "modified"
}

View file

@ -1,71 +1,8 @@
export * as PermissionV1 from "./permission"
import { Schema } from "effect"
import { ProjectV2 } from "../project"
import { withStatics } from "../schema"
import { SessionSchema } from "../session/schema"
import { Identifier } from "../util/identifier"
export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe(
Schema.brand("PermissionID"),
withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "per_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionAction" })
export type Action = typeof Action.Type
export const Rule = Schema.Struct({
permission: Schema.String,
pattern: Schema.String,
action: Action,
}).annotate({ identifier: "PermissionRule" })
export type Rule = typeof Rule.Type
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" })
export type Ruleset = typeof Ruleset.Type
export const Request = Schema.Struct({
id: ID,
sessionID: SessionSchema.ID,
permission: Schema.String,
patterns: Schema.Array(Schema.String),
metadata: Schema.Record(Schema.String, Schema.Unknown),
always: Schema.Array(Schema.String),
tool: Schema.Struct({
messageID: Schema.String,
callID: Schema.String,
}).pipe(Schema.optional),
}).annotate({ identifier: "PermissionRequest" })
export type Request = typeof Request.Type
export const Reply = Schema.Literals(["once", "always", "reject"])
export type Reply = typeof Reply.Type
export const ReplyBody = Schema.Struct({
reply: Reply,
message: Schema.String.pipe(Schema.optional),
}).annotate({ identifier: "PermissionReplyBody" })
export type ReplyBody = typeof ReplyBody.Type
export const Approval = Schema.Struct({
projectID: ProjectV2.ID,
patterns: Schema.Array(Schema.String),
}).annotate({ identifier: "PermissionApproval" })
export type Approval = typeof Approval.Type
export const AskInput = Schema.Struct({
...Request.fields,
id: ID.pipe(Schema.optional),
ruleset: Ruleset,
}).annotate({ identifier: "PermissionAskInput" })
export type AskInput = typeof AskInput.Type
export const ReplyInput = Schema.Struct({
requestID: ID,
...ReplyBody.fields,
}).annotate({ identifier: "PermissionReplyInput" })
export type ReplyInput = typeof ReplyInput.Type
export * from "@opencode-ai/schema/permission-v1"
import { ID } from "@opencode-ai/schema/permission-v1"
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
override get message() {

View file

@ -1,39 +1,52 @@
export * as SessionV1 from "./session"
import { Effect, Schema, Types } from "effect"
import { EventV2 } from "../event"
import { PermissionV1 } from "./permission"
import { ProjectV2 } from "../project"
import { ProviderV2 } from "../provider"
import { ModelV2 } from "../model"
import { optionalOmitUndefined, withStatics } from "../schema"
import { Identifier } from "../util/identifier"
import { Schema } from "effect"
import { NonNegativeInt } from "../schema"
import { NamedError } from "../util/error"
import { SessionSchema } from "../session/schema"
import { WorkspaceV2 } from "../workspace"
const Timestamp = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0))
export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe(
Schema.brand("MessageID"),
withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "msg_" + Identifier.ascending()) })),
)
export type MessageID = typeof MessageID.Type
export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe(
Schema.brand("PartID"),
withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "prt_" + Identifier.ascending()) })),
)
export type PartID = typeof PartID.Type
export {
AgentPart,
AgentPartInput,
Assistant,
CompactionPart,
Event,
FilePart,
FilePartInput,
FilePartSource,
FileSource,
Format,
Info,
MessageID,
OutputFormatJsonSchema,
OutputFormatText,
Part,
PartID,
PatchPart,
Range,
ReasoningPart,
ResourceSource,
RetryPart,
SessionInfo,
SnapshotPart,
StepFinishPart,
StepStartPart,
SubtaskPart,
SubtaskPartInput,
SymbolSource,
TextPart,
TextPartInput,
ToolPart,
ToolState,
ToolStateCompleted,
ToolStateError,
ToolStatePending,
ToolStateRunning,
User,
WithParts,
} from "@opencode-ai/schema/session-v1"
export const OutputLengthError = NamedError.create("MessageOutputLengthError", {})
export const AuthError = NamedError.create("ProviderAuthError", {
providerID: Schema.String,
message: Schema.String,
})
export const AuthError = NamedError.create("ProviderAuthError", { providerID: Schema.String, message: Schema.String })
export const AbortedError = NamedError.create("MessageAbortedError", { message: Schema.String })
export const StructuredOutputError = NamedError.create("StructuredOutputError", {
message: Schema.String,
@ -52,581 +65,4 @@ export const ContextOverflowError = NamedError.create("ContextOverflowError", {
message: Schema.String,
responseBody: Schema.optional(Schema.String),
})
export const ContentFilterError = NamedError.create("ContentFilterError", {
message: Schema.String,
})
export class OutputFormatText extends Schema.Class<OutputFormatText>("OutputFormatText")({
type: Schema.Literal("text"),
}) {}
export class OutputFormatJsonSchema extends Schema.Class<OutputFormatJsonSchema>("OutputFormatJsonSchema")({
type: Schema.Literal("json_schema"),
schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }),
retryCount: NonNegativeInt.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))),
}) {}
export const Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({
discriminator: "type",
identifier: "OutputFormat",
})
export type OutputFormat = Schema.Schema.Type<typeof Format>
const partBase = {
id: PartID,
sessionID: SessionSchema.ID,
messageID: MessageID,
}
export const SnapshotPart = Schema.Struct({
...partBase,
type: Schema.Literal("snapshot"),
snapshot: Schema.String,
}).annotate({ identifier: "SnapshotPart" })
export type SnapshotPart = Types.DeepMutable<Schema.Schema.Type<typeof SnapshotPart>>
export const PatchPart = Schema.Struct({
...partBase,
type: Schema.Literal("patch"),
hash: Schema.String,
files: Schema.Array(Schema.String),
}).annotate({ identifier: "PatchPart" })
export type PatchPart = Types.DeepMutable<Schema.Schema.Type<typeof PatchPart>>
export const TextPart = Schema.Struct({
...partBase,
type: Schema.Literal("text"),
text: Schema.String,
synthetic: Schema.optional(Schema.Boolean),
ignored: Schema.optional(Schema.Boolean),
time: Schema.optional(
Schema.Struct({
start: NonNegativeInt,
end: Schema.optional(NonNegativeInt),
}),
),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
}).annotate({ identifier: "TextPart" })
export type TextPart = Types.DeepMutable<Schema.Schema.Type<typeof TextPart>>
export const ReasoningPart = Schema.Struct({
...partBase,
type: Schema.Literal("reasoning"),
text: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
time: Schema.Struct({
start: NonNegativeInt,
end: Schema.optional(NonNegativeInt),
}),
}).annotate({ identifier: "ReasoningPart" })
export type ReasoningPart = Types.DeepMutable<Schema.Schema.Type<typeof ReasoningPart>>
const filePartSourceBase = {
text: Schema.Struct({
value: Schema.String,
start: Schema.Finite,
end: Schema.Finite,
}).annotate({ identifier: "FilePartSourceText" }),
}
export const Range = Schema.Struct({
start: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }),
end: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }),
}).annotate({ identifier: "Range" })
export type Range = typeof Range.Type
export const FileSource = Schema.Struct({
...filePartSourceBase,
type: Schema.Literal("file"),
path: Schema.String,
}).annotate({ identifier: "FileSource" })
export const SymbolSource = Schema.Struct({
...filePartSourceBase,
type: Schema.Literal("symbol"),
path: Schema.String,
range: Range,
name: Schema.String,
kind: NonNegativeInt,
}).annotate({ identifier: "SymbolSource" })
export const ResourceSource = Schema.Struct({
...filePartSourceBase,
type: Schema.Literal("resource"),
clientName: Schema.String,
uri: Schema.String,
}).annotate({ identifier: "ResourceSource" })
export const FilePartSource = Schema.Union([FileSource, SymbolSource, ResourceSource]).annotate({
discriminator: "type",
identifier: "FilePartSource",
})
export const FilePart = Schema.Struct({
...partBase,
type: Schema.Literal("file"),
mime: Schema.String,
filename: Schema.optional(Schema.String),
url: Schema.String,
source: Schema.optional(FilePartSource),
}).annotate({ identifier: "FilePart" })
export type FilePart = Types.DeepMutable<Schema.Schema.Type<typeof FilePart>>
export const AgentPart = Schema.Struct({
...partBase,
type: Schema.Literal("agent"),
name: Schema.String,
source: Schema.optional(
Schema.Struct({
value: Schema.String,
start: NonNegativeInt,
end: NonNegativeInt,
}),
),
}).annotate({ identifier: "AgentPart" })
export type AgentPart = Types.DeepMutable<Schema.Schema.Type<typeof AgentPart>>
export const CompactionPart = Schema.Struct({
...partBase,
type: Schema.Literal("compaction"),
auto: Schema.Boolean,
overflow: Schema.optional(Schema.Boolean),
tail_start_id: Schema.optional(MessageID),
}).annotate({ identifier: "CompactionPart" })
export type CompactionPart = Types.DeepMutable<Schema.Schema.Type<typeof CompactionPart>>
export const SubtaskPart = Schema.Struct({
...partBase,
type: Schema.Literal("subtask"),
prompt: Schema.String,
description: Schema.String,
agent: Schema.String,
model: Schema.optional(
Schema.Struct({
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
}),
),
command: Schema.optional(Schema.String),
}).annotate({ identifier: "SubtaskPart" })
export type SubtaskPart = Types.DeepMutable<Schema.Schema.Type<typeof SubtaskPart>>
export const RetryPart = Schema.Struct({
...partBase,
type: Schema.Literal("retry"),
attempt: NonNegativeInt,
error: APIError.EffectSchema,
time: Schema.Struct({
created: NonNegativeInt,
}),
}).annotate({ identifier: "RetryPart" })
export type RetryPart = Omit<Types.DeepMutable<Schema.Schema.Type<typeof RetryPart>>, "error"> & {
error: APIError
}
export const StepStartPart = Schema.Struct({
...partBase,
type: Schema.Literal("step-start"),
snapshot: Schema.optional(Schema.String),
}).annotate({ identifier: "StepStartPart" })
export type StepStartPart = Types.DeepMutable<Schema.Schema.Type<typeof StepStartPart>>
export const StepFinishPart = Schema.Struct({
...partBase,
type: Schema.Literal("step-finish"),
reason: Schema.String,
snapshot: Schema.optional(Schema.String),
cost: Schema.Finite,
tokens: Schema.Struct({
total: Schema.optional(Schema.Finite),
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
}),
}).annotate({ identifier: "StepFinishPart" })
export type StepFinishPart = Types.DeepMutable<Schema.Schema.Type<typeof StepFinishPart>>
export const ToolStatePending = Schema.Struct({
status: Schema.Literal("pending"),
input: Schema.Record(Schema.String, Schema.Any),
raw: Schema.String,
}).annotate({ identifier: "ToolStatePending" })
export type ToolStatePending = Types.DeepMutable<Schema.Schema.Type<typeof ToolStatePending>>
export const ToolStateRunning = Schema.Struct({
status: Schema.Literal("running"),
input: Schema.Record(Schema.String, Schema.Any),
title: Schema.optional(Schema.String),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
time: Schema.Struct({
start: NonNegativeInt,
}),
}).annotate({ identifier: "ToolStateRunning" })
export type ToolStateRunning = Types.DeepMutable<Schema.Schema.Type<typeof ToolStateRunning>>
export const ToolStateCompleted = Schema.Struct({
status: Schema.Literal("completed"),
input: Schema.Record(Schema.String, Schema.Any),
output: Schema.String,
title: Schema.String,
metadata: Schema.Record(Schema.String, Schema.Any),
time: Schema.Struct({
start: NonNegativeInt,
end: NonNegativeInt,
compacted: Schema.optional(NonNegativeInt),
}),
attachments: Schema.optional(Schema.Array(FilePart)),
}).annotate({ identifier: "ToolStateCompleted" })
export type ToolStateCompleted = Types.DeepMutable<Schema.Schema.Type<typeof ToolStateCompleted>>
export const ToolStateError = Schema.Struct({
status: Schema.Literal("error"),
input: Schema.Record(Schema.String, Schema.Any),
error: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
time: Schema.Struct({
start: NonNegativeInt,
end: NonNegativeInt,
}),
}).annotate({ identifier: "ToolStateError" })
export type ToolStateError = Types.DeepMutable<Schema.Schema.Type<typeof ToolStateError>>
export const ToolState = Schema.Union([
ToolStatePending,
ToolStateRunning,
ToolStateCompleted,
ToolStateError,
]).annotate({
discriminator: "status",
identifier: "ToolState",
})
export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError
export const ToolPart = Schema.Struct({
...partBase,
type: Schema.Literal("tool"),
callID: Schema.String,
tool: Schema.String,
state: ToolState,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
}).annotate({ identifier: "ToolPart" })
export type ToolPart = Omit<Types.DeepMutable<Schema.Schema.Type<typeof ToolPart>>, "state"> & {
state: ToolState
}
const messageBase = {
id: MessageID,
sessionID: partBase.sessionID,
}
const FileDiff = Schema.Struct({
file: Schema.optional(Schema.String),
patch: Schema.optional(Schema.String),
additions: Schema.Finite,
deletions: Schema.Finite,
status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
}).annotate({ identifier: "SnapshotFileDiff" })
export const User = Schema.Struct({
...messageBase,
role: Schema.Literal("user"),
time: Schema.Struct({
created: Timestamp,
}),
format: Schema.optional(Format),
summary: Schema.optional(
Schema.Struct({
title: Schema.optional(Schema.String),
body: Schema.optional(Schema.String),
diffs: Schema.Array(FileDiff),
}),
),
agent: Schema.String,
model: Schema.Struct({
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
variant: Schema.optional(Schema.String),
}),
system: Schema.optional(Schema.String),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
}).annotate({ identifier: "UserMessage" })
export type User = Types.DeepMutable<Schema.Schema.Type<typeof User>>
export const Part = Schema.Union([
TextPart,
SubtaskPart,
ReasoningPart,
FilePart,
ToolPart,
StepStartPart,
StepFinishPart,
SnapshotPart,
PatchPart,
AgentPart,
RetryPart,
CompactionPart,
]).annotate({ discriminator: "type", identifier: "Part" })
export type Part =
| TextPart
| SubtaskPart
| ReasoningPart
| FilePart
| ToolPart
| StepStartPart
| StepFinishPart
| SnapshotPart
| PatchPart
| AgentPart
| RetryPart
| CompactionPart
const AssistantErrorSchema = Schema.Union([
AuthError.EffectSchema,
NamedError.Unknown.EffectSchema,
OutputLengthError.EffectSchema,
AbortedError.EffectSchema,
StructuredOutputError.EffectSchema,
ContextOverflowError.EffectSchema,
ContentFilterError.EffectSchema,
APIError.EffectSchema,
]).annotate({ discriminator: "name" })
type AssistantError = Schema.Schema.Type<typeof AssistantErrorSchema>
export const TextPartInput = Schema.Struct({
id: Schema.optional(PartID),
type: Schema.Literal("text"),
text: Schema.String,
synthetic: Schema.optional(Schema.Boolean),
ignored: Schema.optional(Schema.Boolean),
time: Schema.optional(
Schema.Struct({
start: NonNegativeInt,
end: Schema.optional(NonNegativeInt),
}),
),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
}).annotate({ identifier: "TextPartInput" })
export type TextPartInput = Types.DeepMutable<Schema.Schema.Type<typeof TextPartInput>>
export const FilePartInput = Schema.Struct({
id: Schema.optional(PartID),
type: Schema.Literal("file"),
mime: Schema.String,
filename: Schema.optional(Schema.String),
url: Schema.String,
source: Schema.optional(FilePartSource),
}).annotate({ identifier: "FilePartInput" })
export type FilePartInput = Types.DeepMutable<Schema.Schema.Type<typeof FilePartInput>>
export const AgentPartInput = Schema.Struct({
id: Schema.optional(PartID),
type: Schema.Literal("agent"),
name: Schema.String,
source: Schema.optional(
Schema.Struct({
value: Schema.String,
start: NonNegativeInt,
end: NonNegativeInt,
}),
),
}).annotate({ identifier: "AgentPartInput" })
export type AgentPartInput = Types.DeepMutable<Schema.Schema.Type<typeof AgentPartInput>>
export const SubtaskPartInput = Schema.Struct({
id: Schema.optional(PartID),
type: Schema.Literal("subtask"),
prompt: Schema.String,
description: Schema.String,
agent: Schema.String,
model: Schema.optional(
Schema.Struct({
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
}),
),
command: Schema.optional(Schema.String),
}).annotate({ identifier: "SubtaskPartInput" })
export type SubtaskPartInput = Types.DeepMutable<Schema.Schema.Type<typeof SubtaskPartInput>>
export const Assistant = Schema.Struct({
...messageBase,
role: Schema.Literal("assistant"),
time: Schema.Struct({
created: NonNegativeInt,
completed: Schema.optional(NonNegativeInt),
}),
error: Schema.optional(AssistantErrorSchema),
parentID: MessageID,
modelID: ModelV2.ID,
providerID: ProviderV2.ID,
mode: Schema.String,
agent: Schema.String,
path: Schema.Struct({
cwd: Schema.String,
root: Schema.String,
}),
summary: Schema.optional(Schema.Boolean),
cost: Schema.Finite,
tokens: Schema.Struct({
total: Schema.optional(Schema.Finite),
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
}),
structured: Schema.optional(Schema.Any),
variant: Schema.optional(Schema.String),
finish: Schema.optional(Schema.String),
}).annotate({ identifier: "AssistantMessage" })
export type Assistant = Omit<Types.DeepMutable<Schema.Schema.Type<typeof Assistant>>, "error"> & {
error?: AssistantError
}
export const Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" })
export type Info = User | Assistant
export const WithParts = Schema.Struct({
info: Info,
parts: Schema.Array(Part),
})
export type WithParts = {
info: Info
parts: Part[]
}
const options = {
durable: {
aggregate: "sessionID",
version: 1,
},
} as const
const SessionSummary = Schema.Struct({
additions: Schema.Finite,
deletions: Schema.Finite,
files: Schema.Finite,
diffs: optionalOmitUndefined(Schema.Array(FileDiff)),
})
const SessionTokens = Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
})
const SessionShare = Schema.Struct({
url: Schema.String,
})
const SessionRevert = Schema.Struct({
messageID: MessageID,
partID: optionalOmitUndefined(PartID),
snapshot: optionalOmitUndefined(Schema.String),
diff: optionalOmitUndefined(Schema.String),
})
const SessionModel = Schema.Struct({
id: ModelV2.ID,
providerID: ProviderV2.ID,
variant: optionalOmitUndefined(Schema.String),
})
export const SessionInfo = Schema.Struct({
id: SessionSchema.ID,
slug: Schema.String,
projectID: ProjectV2.ID,
workspaceID: optionalOmitUndefined(WorkspaceV2.ID),
directory: Schema.String,
path: optionalOmitUndefined(Schema.String),
parentID: optionalOmitUndefined(SessionSchema.ID),
summary: optionalOmitUndefined(SessionSummary),
cost: optionalOmitUndefined(Schema.Finite),
tokens: optionalOmitUndefined(SessionTokens),
share: optionalOmitUndefined(SessionShare),
title: Schema.String,
agent: optionalOmitUndefined(Schema.String),
model: optionalOmitUndefined(SessionModel),
version: Schema.String,
metadata: optionalOmitUndefined(Schema.Record(Schema.String, Schema.Any)),
time: Schema.Struct({
created: NonNegativeInt,
updated: NonNegativeInt,
compacting: optionalOmitUndefined(NonNegativeInt),
archived: optionalOmitUndefined(Schema.Finite),
}),
permission: optionalOmitUndefined(PermissionV1.Ruleset),
revert: optionalOmitUndefined(SessionRevert),
}).annotate({ identifier: "Session" })
export type SessionInfo = typeof SessionInfo.Type
export const Event = {
Created: EventV2.define({
type: "session.created",
...options,
schema: {
sessionID: SessionSchema.ID,
info: SessionInfo,
},
}),
Updated: EventV2.define({
type: "session.updated",
...options,
schema: {
sessionID: SessionSchema.ID,
info: SessionInfo,
},
}),
Deleted: EventV2.define({
type: "session.deleted",
...options,
schema: {
sessionID: SessionSchema.ID,
info: SessionInfo,
},
}),
MessageUpdated: EventV2.define({
type: "message.updated",
...options,
schema: {
sessionID: SessionSchema.ID,
info: Info,
},
}),
MessageRemoved: EventV2.define({
type: "message.removed",
...options,
schema: {
sessionID: SessionSchema.ID,
messageID: MessageID,
},
}),
PartUpdated: EventV2.define({
type: "message.part.updated",
...options,
schema: {
sessionID: SessionSchema.ID,
part: Part,
time: Schema.Finite,
},
}),
PartRemoved: EventV2.define({
type: "message.part.removed",
...options,
schema: {
sessionID: SessionSchema.ID,
messageID: MessageID,
partID: PartID,
},
}),
}
export const ContentFilterError = NamedError.create("ContentFilterError", { message: Schema.String })