mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-22 03:45:23 +00:00
Merge remote-tracking branch 'upstream/dev' into desktop-wsl-onboarding
# Conflicts: # packages/desktop-electron/src/renderer/webview-zoom.ts
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Config } from "effect"
|
||||
import { InstallationChannel } from "../installation/version"
|
||||
|
||||
function truthy(key: string) {
|
||||
const value = process.env[key]?.toLowerCase()
|
||||
@@ -10,6 +11,10 @@ function falsy(key: string) {
|
||||
return value === "false" || value === "0"
|
||||
}
|
||||
|
||||
// Channels that default to the new effect-httpapi server backend. The legacy
|
||||
// hono backend remains the default for stable (`prod`/`latest`) installs.
|
||||
const HTTPAPI_DEFAULT_ON_CHANNELS = new Set(["dev", "beta", "local"])
|
||||
|
||||
function number(key: string) {
|
||||
const value = process.env[key]
|
||||
if (!value) return undefined
|
||||
@@ -81,8 +86,16 @@ export const Flag = {
|
||||
OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"),
|
||||
|
||||
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
|
||||
OPENCODE_EXPERIMENTAL_HTTPAPI: truthy("OPENCODE_EXPERIMENTAL_HTTPAPI"),
|
||||
// Defaults to true on dev/beta/local channels so internal users exercise the
|
||||
// new effect-httpapi server backend. Stable (`prod`/`latest`) installs stay
|
||||
// on the legacy hono backend until the rollout is complete. An explicit env
|
||||
// var ("true"/"1" or "false"/"0") always wins, providing an opt-in for
|
||||
// stable users and an escape hatch for dev/beta users.
|
||||
OPENCODE_EXPERIMENTAL_HTTPAPI:
|
||||
truthy("OPENCODE_EXPERIMENTAL_HTTPAPI") ||
|
||||
(!falsy("OPENCODE_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)),
|
||||
OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
||||
OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
|
||||
|
||||
// Evaluated at access time (not module load) because tests, the CLI, and
|
||||
// external tooling set these env vars at runtime.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * as Log from "./log"
|
||||
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { createWriteStream } from "fs"
|
||||
|
||||
@@ -75,9 +75,9 @@ export function createMenu(deps: Deps) {
|
||||
{ role: "reload" },
|
||||
{ role: "toggleDevTools" },
|
||||
{ type: "separator" },
|
||||
{ label: "Actual Size", accelerator: "Cmd+0", click: () => deps.trigger("zoom.reset") },
|
||||
{ label: "Zoom In", accelerator: "Cmd+=", click: () => deps.trigger("zoom.in") },
|
||||
{ label: "Zoom Out", accelerator: "Cmd+-", click: () => deps.trigger("zoom.out") },
|
||||
{ role: "resetZoom" },
|
||||
{ role: "zoomIn" },
|
||||
{ role: "zoomOut" },
|
||||
{ type: "separator" },
|
||||
{ role: "togglefullscreen" },
|
||||
],
|
||||
|
||||
@@ -188,11 +188,9 @@ function loadWindow(win: BrowserWindow, html: string) {
|
||||
}
|
||||
function wireZoom(win: BrowserWindow) {
|
||||
win.webContents.setZoomFactor(1)
|
||||
// Disable Chromium's touch/pinch zoom. Keyboard and wheel zoom are handled
|
||||
// in the renderer so the Solid `webviewZoom` signal stays the single source
|
||||
// of truth; a stray `zoom-changed` handler here would race with the renderer
|
||||
// and intermittently snap the factor back to 1.
|
||||
void win.webContents.setVisualZoomLevelLimits(1, 1).catch(() => undefined)
|
||||
win.webContents.on("zoom-changed", () => {
|
||||
win.webContents.setZoomFactor(1)
|
||||
})
|
||||
}
|
||||
|
||||
function upsertKeyValue(obj: Record<string, any>, keyToChange: string, value: any) {
|
||||
|
||||
@@ -74,7 +74,7 @@ import { createEffect, createMemo, createResource, onCleanup, onMount } from "so
|
||||
import { render } from "solid-js/web"
|
||||
import pkg from "../../package.json"
|
||||
import { initI18n, t } from "./i18n"
|
||||
import { webviewZoom, zoomIn, zoomOut, zoomReset } from "./webview-zoom"
|
||||
import { webviewZoom } from "./webview-zoom"
|
||||
import "./styles.css"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
@@ -331,9 +331,6 @@ const createPlatform = (): Platform => {
|
||||
|
||||
let menuTrigger = null as null | ((id: string) => void)
|
||||
window.api.onMenuCommand((id) => {
|
||||
if (id === "zoom.in") return zoomIn()
|
||||
if (id === "zoom.out") return zoomOut()
|
||||
if (id === "zoom.reset") return zoomReset()
|
||||
menuTrigger?.(id)
|
||||
})
|
||||
listenForDeepLinks()
|
||||
|
||||
@@ -11,73 +11,35 @@ const OS_NAME = (() => {
|
||||
return "unknown"
|
||||
})()
|
||||
|
||||
const MIN_ZOOM = 0.2
|
||||
const MAX_ZOOM = 10
|
||||
const KEY_STEP = 0.2
|
||||
const WHEEL_STEP = 0.1
|
||||
|
||||
const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM), MAX_ZOOM)
|
||||
|
||||
const [webviewZoom, setWebviewZoom] = createSignal(1)
|
||||
|
||||
const apply = (next: number) => {
|
||||
const clamped = clamp(next)
|
||||
if (Math.abs(clamped - webviewZoom()) < 1e-6) return
|
||||
setWebviewZoom(clamped)
|
||||
void window.api.setZoomFactor(clamped).catch(() => undefined)
|
||||
const MAX_ZOOM_LEVEL = 10
|
||||
const MIN_ZOOM_LEVEL = 0.2
|
||||
|
||||
const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
|
||||
|
||||
const applyZoom = (next: number) => {
|
||||
setWebviewZoom(next)
|
||||
void window.api.setZoomFactor(next)
|
||||
}
|
||||
|
||||
export const zoomIn = () => apply(webviewZoom() + KEY_STEP)
|
||||
export const zoomOut = () => apply(webviewZoom() - KEY_STEP)
|
||||
export const zoomReset = () => apply(1)
|
||||
|
||||
// Seed the signal from the main process so renderer and webContents agree
|
||||
// across cold starts, reloads, and HMR refreshes (which would otherwise
|
||||
// reinitialize the signal to 1 while webContents kept its prior factor).
|
||||
void window.api
|
||||
.getZoomFactor()
|
||||
.then((initial) => {
|
||||
if (typeof initial === "number" && Number.isFinite(initial)) {
|
||||
setWebviewZoom(clamp(initial))
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
||||
// Keyboard accelerators. preventDefault stops Chromium's built-in zoom
|
||||
// accelerators from firing in parallel (which previously caused races).
|
||||
window.addEventListener("keydown", (event) => {
|
||||
const mod = OS_NAME === "macos" ? event.metaKey : event.ctrlKey
|
||||
if (!mod || event.altKey) return
|
||||
if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return
|
||||
|
||||
if (event.key === "-" || event.key === "_") {
|
||||
if (event.key === "-") {
|
||||
event.preventDefault()
|
||||
zoomOut()
|
||||
applyZoom(clamp(webviewZoom() - 0.2))
|
||||
return
|
||||
}
|
||||
if (event.key === "=" || event.key === "+") {
|
||||
event.preventDefault()
|
||||
zoomIn()
|
||||
applyZoom(clamp(webviewZoom() + 0.2))
|
||||
return
|
||||
}
|
||||
if (event.key === "0") {
|
||||
event.preventDefault()
|
||||
zoomReset()
|
||||
return
|
||||
applyZoom(1)
|
||||
}
|
||||
})
|
||||
|
||||
// Wheel zoom. Chromium synthesizes `wheel` with `ctrlKey: true` for trackpad
|
||||
// pinch on every platform, so checking ctrlKey uniformly covers pinch-to-zoom
|
||||
// as well as real ctrl+scroll / cmd+scroll.
|
||||
window.addEventListener(
|
||||
"wheel",
|
||||
(event) => {
|
||||
if (!event.ctrlKey && !event.metaKey) return
|
||||
event.preventDefault()
|
||||
const step = event.deltaY > 0 ? -WHEEL_STEP : WHEEL_STEP
|
||||
apply(webviewZoom() + step)
|
||||
},
|
||||
{ passive: false },
|
||||
)
|
||||
|
||||
export { webviewZoom }
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE `session_message` (
|
||||
`id` text PRIMARY KEY,
|
||||
`session_id` text NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`time_created` integer NOT NULL,
|
||||
`time_updated` integer NOT NULL,
|
||||
`data` text NOT NULL,
|
||||
CONSTRAINT `fk_session_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS `session_entry_session_idx`;--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS `session_entry_session_type_idx`;--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS `session_entry_time_created_idx`;--> statement-breakpoint
|
||||
CREATE INDEX `session_message_session_idx` ON `session_message` (`session_id`);--> statement-breakpoint
|
||||
CREATE INDEX `session_message_session_type_idx` ON `session_message` (`session_id`,`type`);--> statement-breakpoint
|
||||
CREATE INDEX `session_message_time_created_idx` ON `session_message` (`time_created`);--> statement-breakpoint
|
||||
DROP TABLE `session_entry`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "aaa2ebeb-caa4-478d-8365-4fc595d16856",
|
||||
"prevIds": ["66cbe0d7-def0-451b-b88a-7608513a9b44"],
|
||||
"prevIds": ["61f807f9-6398-4067-be05-804acc2561bc"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "account_state",
|
||||
@@ -37,7 +37,7 @@
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "session_entry",
|
||||
"name": "session_message",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
@@ -598,7 +598,7 @@
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "session_entry"
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -608,7 +608,7 @@
|
||||
"generated": null,
|
||||
"name": "session_id",
|
||||
"entityType": "columns",
|
||||
"table": "session_entry"
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -618,7 +618,7 @@
|
||||
"generated": null,
|
||||
"name": "type",
|
||||
"entityType": "columns",
|
||||
"table": "session_entry"
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -628,7 +628,7 @@
|
||||
"generated": null,
|
||||
"name": "time_created",
|
||||
"entityType": "columns",
|
||||
"table": "session_entry"
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -638,7 +638,7 @@
|
||||
"generated": null,
|
||||
"name": "time_updated",
|
||||
"entityType": "columns",
|
||||
"table": "session_entry"
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -648,7 +648,7 @@
|
||||
"generated": null,
|
||||
"name": "data",
|
||||
"entityType": "columns",
|
||||
"table": "session_entry"
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1112,9 +1112,9 @@
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_session_entry_session_id_session_id_fk",
|
||||
"name": "fk_session_message_session_id_session_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "session_entry"
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"columns": ["project_id"],
|
||||
@@ -1226,8 +1226,8 @@
|
||||
{
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "session_entry_pk",
|
||||
"table": "session_entry",
|
||||
"name": "session_message_pk",
|
||||
"table": "session_message",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
@@ -1322,9 +1322,9 @@
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_entry_session_idx",
|
||||
"name": "session_message_session_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session_entry"
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
@@ -1340,9 +1340,9 @@
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_entry_session_type_idx",
|
||||
"name": "session_message_session_type_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session_entry"
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
@@ -1354,9 +1354,9 @@
|
||||
"isUnique": false,
|
||||
"where": null,
|
||||
"origin": "manual",
|
||||
"name": "session_entry_time_created_idx",
|
||||
"name": "session_message_time_created_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session_entry"
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `session` ADD `agent` text;--> statement-breakpoint
|
||||
ALTER TABLE `session` ADD `model` text;
|
||||
1439
packages/opencode/migration/20260501142318_next_venus/snapshot.json
Normal file
1439
packages/opencode/migration/20260501142318_next_venus/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2014
packages/opencode/script/httpapi-exercise.ts
Normal file
2014
packages/opencode/script/httpapi-exercise.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -130,7 +130,7 @@ async function sendUsageUpdate(
|
||||
})
|
||||
}
|
||||
|
||||
export async function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
|
||||
export function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
|
||||
return {
|
||||
create: (connection: AgentSideConnection, fullConfig: ACPConfig) => {
|
||||
return new Agent(connection, fullConfig)
|
||||
|
||||
@@ -24,6 +24,7 @@ export function payloads() {
|
||||
.map(([type, def]) => {
|
||||
return z
|
||||
.object({
|
||||
id: z.string(),
|
||||
type: z.literal(type),
|
||||
properties: zodObject(def.properties),
|
||||
})
|
||||
@@ -39,6 +40,7 @@ export function effectPayloads() {
|
||||
.entries()
|
||||
.map(([type, def]) =>
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
type: Schema.Literal(type),
|
||||
properties: def.properties,
|
||||
}).annotate({ identifier: `Event.${type}` }),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventEmitter } from "events"
|
||||
import { Identifier } from "@/id/id"
|
||||
|
||||
export type GlobalEvent = {
|
||||
directory?: string
|
||||
@@ -7,6 +8,15 @@ export type GlobalEvent = {
|
||||
payload: any
|
||||
}
|
||||
|
||||
export const GlobalBus = new EventEmitter<{
|
||||
class GlobalBusEmitter extends EventEmitter<{
|
||||
event: [GlobalEvent]
|
||||
}>()
|
||||
}> {
|
||||
override emit(eventName: "event", event: GlobalEvent): boolean {
|
||||
if (event.payload && typeof event.payload === "object" && !("id" in event.payload)) {
|
||||
event.payload.id = event.payload.syncEvent?.id ?? Identifier.create("evt", "ascending")
|
||||
}
|
||||
return super.emit(eventName, event)
|
||||
}
|
||||
}
|
||||
|
||||
export const GlobalBus = new GlobalBusEmitter()
|
||||
|
||||
@@ -5,6 +5,7 @@ import { BusEvent } from "./bus-event"
|
||||
import { GlobalBus } from "./global"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Identifier } from "@/id/id"
|
||||
|
||||
const log = Log.create({ service: "bus" })
|
||||
|
||||
@@ -18,6 +19,7 @@ export const InstanceDisposed = BusEvent.define(
|
||||
)
|
||||
|
||||
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
|
||||
id: string
|
||||
type: D["type"]
|
||||
properties: BusProperties<D>
|
||||
}
|
||||
@@ -28,7 +30,11 @@ type State = {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly publish: <D extends BusEvent.Definition>(def: D, properties: BusProperties<D>) => Effect.Effect<void>
|
||||
readonly publish: <D extends BusEvent.Definition>(
|
||||
def: D,
|
||||
properties: BusProperties<D>,
|
||||
options?: { id?: string },
|
||||
) => Effect.Effect<void>
|
||||
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
|
||||
readonly subscribeAll: () => Stream.Stream<Payload>
|
||||
readonly subscribeCallback: <D extends BusEvent.Definition>(
|
||||
@@ -53,6 +59,7 @@ export const layer = Layer.effect(
|
||||
// Publish InstanceDisposed before shutting down so subscribers see it
|
||||
yield* PubSub.publish(wildcard, {
|
||||
type: InstanceDisposed.type,
|
||||
id: createID(),
|
||||
properties: { directory: ctx.directory },
|
||||
})
|
||||
yield* PubSub.shutdown(wildcard)
|
||||
@@ -77,10 +84,10 @@ export const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
|
||||
function publish<D extends BusEvent.Definition>(def: D, properties: BusProperties<D>) {
|
||||
function publish<D extends BusEvent.Definition>(def: D, properties: BusProperties<D>, options?: { id?: string }) {
|
||||
return Effect.gen(function* () {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const payload: Payload = { type: def.type, properties }
|
||||
const payload: Payload = { id: options?.id ?? createID(), type: def.type, properties }
|
||||
log.info("publishing", { type: def.type })
|
||||
|
||||
const ps = s.typed.get(def.type)
|
||||
@@ -173,8 +180,16 @@ const { runPromise, runSync } = makeRuntime(Service, layer)
|
||||
|
||||
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
|
||||
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
|
||||
export async function publish<D extends BusEvent.Definition>(def: D, properties: BusProperties<D>) {
|
||||
return runPromise((svc) => svc.publish(def, properties))
|
||||
export function createID() {
|
||||
return Identifier.create("evt", "ascending")
|
||||
}
|
||||
|
||||
export async function publish<D extends BusEvent.Definition>(
|
||||
def: D,
|
||||
properties: BusProperties<D>,
|
||||
options?: { id?: string },
|
||||
) {
|
||||
return runPromise((svc) => svc.publish(def, properties, options))
|
||||
}
|
||||
|
||||
export function subscribe<D extends BusEvent.Definition>(def: D, callback: (event: Payload<D>) => unknown) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Instance } from "../project/instance"
|
||||
import { InstanceRuntime } from "../project/instance-runtime"
|
||||
import { WithInstance } from "../project/with-instance"
|
||||
|
||||
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
|
||||
return Instance.provide({
|
||||
return WithInstance.provide({
|
||||
directory,
|
||||
fn: async () => {
|
||||
try {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Duration, Effect, Match, Option } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { Account } from "@/account/account"
|
||||
import { AccountID, OrgID, PollExpired, type PollResult, type AccountError } from "@/account/schema"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import * as Prompt from "../effect/prompt"
|
||||
import open from "open"
|
||||
|
||||
@@ -172,60 +172,65 @@ const openEffect = Effect.fn("open")(function* () {
|
||||
yield* Prompt.outro("Opened " + url)
|
||||
})
|
||||
|
||||
export const LoginCommand = cmd({
|
||||
export const LoginCommand = effectCmd({
|
||||
command: "login <url>",
|
||||
describe: false,
|
||||
instance: false,
|
||||
builder: (yargs) =>
|
||||
yargs.positional("url", {
|
||||
describe: "server URL",
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
}),
|
||||
async handler(args) {
|
||||
handler: Effect.fn("Cli.account.login")(function* (args) {
|
||||
UI.empty()
|
||||
await AppRuntime.runPromise(loginEffect(args.url))
|
||||
},
|
||||
yield* Effect.orDie(loginEffect(args.url))
|
||||
}),
|
||||
})
|
||||
|
||||
export const LogoutCommand = cmd({
|
||||
export const LogoutCommand = effectCmd({
|
||||
command: "logout [email]",
|
||||
describe: false,
|
||||
instance: false,
|
||||
builder: (yargs) =>
|
||||
yargs.positional("email", {
|
||||
describe: "account email to log out from",
|
||||
type: "string",
|
||||
}),
|
||||
async handler(args) {
|
||||
handler: Effect.fn("Cli.account.logout")(function* (args) {
|
||||
UI.empty()
|
||||
await AppRuntime.runPromise(logoutEffect(args.email))
|
||||
},
|
||||
yield* Effect.orDie(logoutEffect(args.email))
|
||||
}),
|
||||
})
|
||||
|
||||
export const SwitchCommand = cmd({
|
||||
export const SwitchCommand = effectCmd({
|
||||
command: "switch",
|
||||
describe: false,
|
||||
async handler() {
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.account.switch")(function* () {
|
||||
UI.empty()
|
||||
await AppRuntime.runPromise(switchEffect())
|
||||
},
|
||||
yield* Effect.orDie(switchEffect())
|
||||
}),
|
||||
})
|
||||
|
||||
export const OrgsCommand = cmd({
|
||||
export const OrgsCommand = effectCmd({
|
||||
command: "orgs",
|
||||
describe: false,
|
||||
async handler() {
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.account.orgs")(function* () {
|
||||
UI.empty()
|
||||
await AppRuntime.runPromise(orgsEffect())
|
||||
},
|
||||
yield* Effect.orDie(orgsEffect())
|
||||
}),
|
||||
})
|
||||
|
||||
export const OpenCommand = cmd({
|
||||
export const OpenCommand = effectCmd({
|
||||
command: "open",
|
||||
describe: false,
|
||||
async handler() {
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.account.open")(function* () {
|
||||
UI.empty()
|
||||
await AppRuntime.runPromise(openEffect())
|
||||
},
|
||||
yield* Effect.orDie(openEffect())
|
||||
}),
|
||||
})
|
||||
|
||||
export const ConsoleCommand = cmd({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { cmd } from "./cmd"
|
||||
import { Effect } from "effect"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
|
||||
import { ACP } from "@/acp/agent"
|
||||
import { Server } from "@/server/server"
|
||||
@@ -9,7 +9,7 @@ import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
|
||||
const log = Log.create({ service: "acp-command" })
|
||||
|
||||
export const AcpCommand = cmd({
|
||||
export const AcpCommand = effectCmd({
|
||||
command: "acp",
|
||||
describe: "start ACP (Agent Client Protocol) server",
|
||||
builder: (yargs) => {
|
||||
@@ -19,52 +19,53 @@ export const AcpCommand = cmd({
|
||||
default: process.cwd(),
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
handler: Effect.fn("Cli.acp")(function* (args) {
|
||||
process.env.OPENCODE_CLIENT = "acp"
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = await Server.listen(opts)
|
||||
const opts = yield* Effect.promise(() => resolveNetworkOptions(args))
|
||||
const server = yield* Effect.promise(() => Server.listen(opts))
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: `http://${server.hostname}:${server.port}`,
|
||||
})
|
||||
|
||||
const input = new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
process.stdout.write(chunk, (err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
const output = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
process.stdin.on("data", (chunk: Buffer) => {
|
||||
controller.enqueue(new Uint8Array(chunk))
|
||||
})
|
||||
process.stdin.on("end", () => controller.close())
|
||||
process.stdin.on("error", (err) => controller.error(err))
|
||||
},
|
||||
})
|
||||
|
||||
const stream = ndJsonStream(input, output)
|
||||
const agent = await ACP.init({ sdk })
|
||||
|
||||
new AgentSideConnection((conn) => {
|
||||
return agent.create(conn, { sdk })
|
||||
}, stream)
|
||||
|
||||
log.info("setup connection")
|
||||
process.stdin.resume()
|
||||
await new Promise((resolve, reject) => {
|
||||
process.stdin.on("end", resolve)
|
||||
process.stdin.on("error", reject)
|
||||
})
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: `http://${server.hostname}:${server.port}`,
|
||||
})
|
||||
},
|
||||
|
||||
const input = new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
process.stdout.write(chunk, (err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
const output = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
process.stdin.on("data", (chunk: Buffer) => {
|
||||
controller.enqueue(new Uint8Array(chunk))
|
||||
})
|
||||
process.stdin.on("end", () => controller.close())
|
||||
process.stdin.on("error", (err) => controller.error(err))
|
||||
},
|
||||
})
|
||||
|
||||
const stream = ndJsonStream(input, output)
|
||||
const agent = ACP.init({ sdk })
|
||||
|
||||
new AgentSideConnection((conn) => {
|
||||
return agent.create(conn, { sdk })
|
||||
}, stream)
|
||||
|
||||
log.info("setup connection")
|
||||
process.stdin.resume()
|
||||
yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
process.stdin.on("end", () => resolve())
|
||||
process.stdin.on("error", reject)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -10,8 +10,11 @@ import fs from "fs/promises"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import matter from "gray-matter"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { WithInstance } from "../../project/with-instance"
|
||||
import { EOL } from "os"
|
||||
import type { Argv } from "yargs"
|
||||
import { Effect } from "effect"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
|
||||
type AgentMode = "all" | "primary" | "subagent"
|
||||
|
||||
@@ -61,7 +64,7 @@ const AgentCreateCommand = cmd({
|
||||
describe: "model to use in the format of provider/model",
|
||||
}),
|
||||
async handler(args) {
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
const cliPath = args.path
|
||||
@@ -232,28 +235,23 @@ const AgentCreateCommand = cmd({
|
||||
},
|
||||
})
|
||||
|
||||
const AgentListCommand = cmd({
|
||||
const AgentListCommand = effectCmd({
|
||||
command: "list",
|
||||
describe: "list all available agents",
|
||||
async handler() {
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
|
||||
const sortedAgents = agents.sort((a, b) => {
|
||||
if (a.native !== b.native) {
|
||||
return a.native ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
for (const agent of sortedAgents) {
|
||||
process.stdout.write(`${agent.name} (${agent.mode})` + EOL)
|
||||
process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL)
|
||||
}
|
||||
},
|
||||
handler: Effect.fn("Cli.agent.list")(function* () {
|
||||
const agents = yield* Agent.Service.use((svc) => svc.list())
|
||||
const sortedAgents = agents.sort((a, b) => {
|
||||
if (a.native !== b.native) {
|
||||
return a.native ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
},
|
||||
|
||||
for (const agent of sortedAgents) {
|
||||
process.stdout.write(`${agent.name} (${agent.mode})` + EOL)
|
||||
process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL)
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export const AgentCommand = cmd({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CommandModule } from "yargs"
|
||||
|
||||
type WithDoubleDash<T> = T & { "--"?: string[] }
|
||||
export type WithDoubleDash<T> = T & { "--"?: string[] }
|
||||
|
||||
export function cmd<T, U>(input: CommandModule<T, WithDoubleDash<U>>) {
|
||||
return input
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Permission } from "../../../permission"
|
||||
import { iife } from "../../../util/iife"
|
||||
import { effectCmd, fail } from "../../effect-cmd"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
|
||||
export const AgentCommand = effectCmd({
|
||||
@@ -35,8 +34,7 @@ export const AgentCommand = effectCmd({
|
||||
handler: Effect.fn("Cli.debug.agent")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const store = yield* InstanceStore.Service
|
||||
return yield* run(args, ctx).pipe(Effect.ensuring(store.dispose(ctx)))
|
||||
return yield* run(args, ctx)
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -2,20 +2,13 @@ import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "@/config/config"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
|
||||
export const ConfigCommand = effectCmd({
|
||||
command: "config",
|
||||
describe: "show resolved configuration",
|
||||
builder: (yargs) => yargs,
|
||||
handler: Effect.fn("Cli.debug.config")(function* () {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const store = yield* InstanceStore.Service
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service.use((cfg) => cfg.get())
|
||||
process.stdout.write(JSON.stringify(config, null, 2) + EOL)
|
||||
}).pipe(Effect.ensuring(store.dispose(ctx)))
|
||||
const config = yield* Config.Service.use((cfg) => cfg.get())
|
||||
process.stdout.write(JSON.stringify(config, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -4,8 +4,6 @@ import { File } from "../../../file"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { cmd } from "../cmd"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
|
||||
const FileSearchCommand = effectCmd({
|
||||
command: "search <query>",
|
||||
@@ -17,13 +15,8 @@ const FileSearchCommand = effectCmd({
|
||||
description: "Search query",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.file.search")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const store = yield* InstanceStore.Service
|
||||
return yield* Effect.gen(function* () {
|
||||
const results = yield* File.Service.use((svc) => svc.search({ query: args.query }))
|
||||
process.stdout.write(results.join(EOL) + EOL)
|
||||
}).pipe(Effect.ensuring(store.dispose(ctx)))
|
||||
const results = yield* File.Service.use((svc) => svc.search({ query: args.query }))
|
||||
process.stdout.write(results.join(EOL) + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -37,13 +30,8 @@ const FileReadCommand = effectCmd({
|
||||
description: "File path to read",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.file.read")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const store = yield* InstanceStore.Service
|
||||
return yield* Effect.gen(function* () {
|
||||
const content = yield* File.Service.use((svc) => svc.read(args.path))
|
||||
process.stdout.write(JSON.stringify(content, null, 2) + EOL)
|
||||
}).pipe(Effect.ensuring(store.dispose(ctx)))
|
||||
const content = yield* File.Service.use((svc) => svc.read(args.path))
|
||||
process.stdout.write(JSON.stringify(content, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -52,13 +40,8 @@ const FileStatusCommand = effectCmd({
|
||||
describe: "show file status information",
|
||||
builder: (yargs) => yargs,
|
||||
handler: Effect.fn("Cli.debug.file.status")(function* () {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const store = yield* InstanceStore.Service
|
||||
return yield* Effect.gen(function* () {
|
||||
const status = yield* File.Service.use((svc) => svc.status())
|
||||
process.stdout.write(JSON.stringify(status, null, 2) + EOL)
|
||||
}).pipe(Effect.ensuring(store.dispose(ctx)))
|
||||
const status = yield* File.Service.use((svc) => svc.status())
|
||||
process.stdout.write(JSON.stringify(status, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -72,13 +55,8 @@ const FileListCommand = effectCmd({
|
||||
description: "File path to list",
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.file.list")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const store = yield* InstanceStore.Service
|
||||
return yield* Effect.gen(function* () {
|
||||
const files = yield* File.Service.use((svc) => svc.list(args.path))
|
||||
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
|
||||
}).pipe(Effect.ensuring(store.dispose(ctx)))
|
||||
const files = yield* File.Service.use((svc) => svc.list(args.path))
|
||||
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -92,13 +70,8 @@ const FileTreeCommand = effectCmd({
|
||||
default: process.cwd(),
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.file.tree")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const store = yield* InstanceStore.Service
|
||||
return yield* Effect.gen(function* () {
|
||||
const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 })))
|
||||
console.log(JSON.stringify(tree, null, 2))
|
||||
}).pipe(Effect.ensuring(store.dispose(ctx)))
|
||||
const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 })))
|
||||
console.log(JSON.stringify(tree, null, 2))
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { Duration, Effect } from "effect"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { cmd } from "../cmd"
|
||||
import { ConfigCommand } from "./config"
|
||||
import { FileCommand } from "./file"
|
||||
@@ -26,19 +27,19 @@ export const DebugCommand = cmd({
|
||||
.command(StartupCommand)
|
||||
.command(AgentCommand)
|
||||
.command(PathsCommand)
|
||||
.command({
|
||||
command: "wait",
|
||||
describe: "wait indefinitely (for debugging)",
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000 * 60 * 60 * 24))
|
||||
})
|
||||
},
|
||||
})
|
||||
.command(WaitCommand)
|
||||
.demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
const WaitCommand = effectCmd({
|
||||
command: "wait",
|
||||
describe: "wait indefinitely (for debugging)",
|
||||
handler: Effect.fn("Cli.debug.wait")(function* () {
|
||||
yield* Effect.sleep(Duration.days(1))
|
||||
}),
|
||||
})
|
||||
|
||||
const PathsCommand = cmd({
|
||||
command: "paths",
|
||||
describe: "show global paths (data, config, cache, state)",
|
||||
|
||||
@@ -4,8 +4,6 @@ import { effectCmd } from "../../effect-cmd"
|
||||
import { cmd } from "../cmd"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { EOL } from "os"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
|
||||
export const LSPCommand = cmd({
|
||||
command: "lsp",
|
||||
@@ -20,18 +18,13 @@ const DiagnosticsCommand = effectCmd({
|
||||
describe: "get diagnostics for a file",
|
||||
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
|
||||
handler: Effect.fn("Cli.debug.lsp.diagnostics")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const store = yield* InstanceStore.Service
|
||||
return yield* Effect.gen(function* () {
|
||||
const out = yield* LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* lsp.touchFile(args.file, "full")
|
||||
return yield* lsp.diagnostics()
|
||||
}),
|
||||
)
|
||||
process.stdout.write(JSON.stringify(out, null, 2) + EOL)
|
||||
}).pipe(Effect.ensuring(store.dispose(ctx)))
|
||||
const out = yield* LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* lsp.touchFile(args.file, "full")
|
||||
return yield* lsp.diagnostics()
|
||||
}),
|
||||
)
|
||||
process.stdout.write(JSON.stringify(out, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -40,14 +33,9 @@ export const SymbolsCommand = effectCmd({
|
||||
describe: "search workspace symbols",
|
||||
builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }),
|
||||
handler: Effect.fn("Cli.debug.lsp.symbols")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const store = yield* InstanceStore.Service
|
||||
return yield* Effect.gen(function* () {
|
||||
using _ = Log.Default.time("symbols")
|
||||
const results = yield* LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query))
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
}).pipe(Effect.ensuring(store.dispose(ctx)))
|
||||
using _ = Log.Default.time("symbols")
|
||||
const results = yield* LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query))
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -56,13 +44,8 @@ export const DocumentSymbolsCommand = effectCmd({
|
||||
describe: "get symbols from a document",
|
||||
builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }),
|
||||
handler: Effect.fn("Cli.debug.lsp.documentSymbols")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const store = yield* InstanceStore.Service
|
||||
return yield* Effect.gen(function* () {
|
||||
using _ = Log.Default.time("document-symbols")
|
||||
const results = yield* LSP.Service.use((lsp) => lsp.documentSymbol(args.uri))
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
}).pipe(Effect.ensuring(store.dispose(ctx)))
|
||||
using _ = Log.Default.time("document-symbols")
|
||||
const results = yield* LSP.Service.use((lsp) => lsp.documentSymbol(args.uri))
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Ripgrep } from "../../../file/ripgrep"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { cmd } from "../cmd"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
|
||||
export const RipgrepCommand = cmd({
|
||||
command: "rg",
|
||||
@@ -23,13 +22,8 @@ const TreeCommand = effectCmd({
|
||||
handler: Effect.fn("Cli.debug.rg.tree")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const store = yield* InstanceStore.Service
|
||||
return yield* Effect.gen(function* () {
|
||||
const tree = yield* Effect.orDie(
|
||||
Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit })),
|
||||
)
|
||||
process.stdout.write(tree + EOL)
|
||||
}).pipe(Effect.ensuring(store.dispose(ctx)))
|
||||
const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit })))
|
||||
process.stdout.write(tree + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -53,22 +47,19 @@ const FilesCommand = effectCmd({
|
||||
handler: Effect.fn("Cli.debug.rg.files")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const store = yield* InstanceStore.Service
|
||||
return yield* Effect.gen(function* () {
|
||||
const rg = yield* Ripgrep.Service
|
||||
const files = yield* rg
|
||||
.files({
|
||||
cwd: ctx.directory,
|
||||
glob: args.glob ? [args.glob] : undefined,
|
||||
})
|
||||
.pipe(
|
||||
Stream.take(args.limit ?? Infinity),
|
||||
Stream.runCollect,
|
||||
Effect.map((c) => [...c]),
|
||||
Effect.orDie,
|
||||
)
|
||||
process.stdout.write(files.join(EOL) + EOL)
|
||||
}).pipe(Effect.ensuring(store.dispose(ctx)))
|
||||
const rg = yield* Ripgrep.Service
|
||||
const files = yield* rg
|
||||
.files({
|
||||
cwd: ctx.directory,
|
||||
glob: args.glob ? [args.glob] : undefined,
|
||||
})
|
||||
.pipe(
|
||||
Stream.take(args.limit ?? Infinity),
|
||||
Stream.runCollect,
|
||||
Effect.map((c) => [...c]),
|
||||
Effect.orDie,
|
||||
)
|
||||
process.stdout.write(files.join(EOL) + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -93,19 +84,16 @@ const SearchCommand = effectCmd({
|
||||
handler: Effect.fn("Cli.debug.rg.search")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const store = yield* InstanceStore.Service
|
||||
return yield* Effect.gen(function* () {
|
||||
const results = yield* Effect.orDie(
|
||||
Ripgrep.Service.use((svc) =>
|
||||
svc.search({
|
||||
cwd: ctx.directory,
|
||||
pattern: args.pattern,
|
||||
glob: args.glob as string[] | undefined,
|
||||
limit: args.limit,
|
||||
}),
|
||||
),
|
||||
)
|
||||
process.stdout.write(JSON.stringify(results.items, null, 2) + EOL)
|
||||
}).pipe(Effect.ensuring(store.dispose(ctx)))
|
||||
const results = yield* Effect.orDie(
|
||||
Ripgrep.Service.use((svc) =>
|
||||
svc.search({
|
||||
cwd: ctx.directory,
|
||||
pattern: args.pattern,
|
||||
glob: args.glob as string[] | undefined,
|
||||
limit: args.limit,
|
||||
}),
|
||||
),
|
||||
)
|
||||
process.stdout.write(JSON.stringify(results.items, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -2,21 +2,14 @@ import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { Skill } from "../../../skill"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
|
||||
export const SkillCommand = effectCmd({
|
||||
command: "skill",
|
||||
describe: "list all available skills",
|
||||
builder: (yargs) => yargs,
|
||||
handler: Effect.fn("Cli.debug.skill")(function* () {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const store = yield* InstanceStore.Service
|
||||
return yield* Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const skills = yield* skill.all()
|
||||
process.stdout.write(JSON.stringify(skills, null, 2) + EOL)
|
||||
}).pipe(Effect.ensuring(store.dispose(ctx)))
|
||||
const skill = yield* Skill.Service
|
||||
const skills = yield* skill.all()
|
||||
process.stdout.write(JSON.stringify(skills, null, 2) + EOL)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -2,8 +2,6 @@ import { Effect } from "effect"
|
||||
import { Snapshot } from "../../../snapshot"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
import { cmd } from "../cmd"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
|
||||
export const SnapshotCommand = cmd({
|
||||
command: "snapshot",
|
||||
@@ -16,13 +14,8 @@ const TrackCommand = effectCmd({
|
||||
command: "track",
|
||||
describe: "track current snapshot state",
|
||||
handler: Effect.fn("Cli.debug.snapshot.track")(function* () {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const store = yield* InstanceStore.Service
|
||||
return yield* Effect.gen(function* () {
|
||||
const out = yield* Snapshot.Service.use((svc) => svc.track())
|
||||
console.log(out)
|
||||
}).pipe(Effect.ensuring(store.dispose(ctx)))
|
||||
const out = yield* Snapshot.Service.use((svc) => svc.track())
|
||||
console.log(out)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -36,13 +29,8 @@ const PatchCommand = effectCmd({
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.snapshot.patch")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const store = yield* InstanceStore.Service
|
||||
return yield* Effect.gen(function* () {
|
||||
const out = yield* Snapshot.Service.use((svc) => svc.patch(args.hash))
|
||||
console.log(out)
|
||||
}).pipe(Effect.ensuring(store.dispose(ctx)))
|
||||
const out = yield* Snapshot.Service.use((svc) => svc.patch(args.hash))
|
||||
console.log(out)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -56,12 +44,7 @@ const DiffCommand = effectCmd({
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.debug.snapshot.diff")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const store = yield* InstanceStore.Service
|
||||
return yield* Effect.gen(function* () {
|
||||
const out = yield* Snapshot.Service.use((svc) => svc.diff(args.hash))
|
||||
console.log(out)
|
||||
}).pipe(Effect.ensuring(store.dispose(ctx)))
|
||||
const out = yield* Snapshot.Service.use((svc) => svc.diff(args.hash))
|
||||
console.log(out)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -6,8 +6,6 @@ import { UI } from "../ui"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
|
||||
function redact(kind: string, id: string, value: string) {
|
||||
return value.trim() ? `[redacted:${kind}:${id}]` : value
|
||||
@@ -234,10 +232,7 @@ export const ExportCommand = effectCmd({
|
||||
type: "boolean",
|
||||
}),
|
||||
handler: Effect.fn("Cli.export")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const store = yield* InstanceStore.Service
|
||||
return yield* run(args).pipe(Effect.ensuring(store.dispose(ctx)))
|
||||
return yield* run(args)
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { ModelsDev } from "@/provider/models"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { WithInstance } from "@/project/with-instance"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { SessionShare } from "@/share/session"
|
||||
import { Session } from "@/session/session"
|
||||
@@ -203,7 +204,7 @@ export const GithubInstallCommand = cmd({
|
||||
command: "install",
|
||||
describe: "install the GitHub agent",
|
||||
async handler() {
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
{
|
||||
|
||||
@@ -5,7 +5,6 @@ import { CliError, effectCmd } from "../effect-cmd"
|
||||
import { Database } from "@/storage/db"
|
||||
import { SessionTable, MessageTable, PartTable } from "../../session/session.sql"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { EOL } from "os"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
@@ -88,13 +87,9 @@ export const ImportCommand = effectCmd({
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.import")(function* (args) {
|
||||
// effectCmd always provides InstanceRef via InstanceStore.Service.provide; this is an invariant.
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return yield* Effect.die("InstanceRef not provided")
|
||||
const store = yield* InstanceStore.Service
|
||||
// Ensure store.dispose runs disposers and emits server.instance.disposed
|
||||
// on every exit path: success, early return, typed failure, defect, interrupt.
|
||||
return yield* runImport(args.file, ctx.project.id).pipe(Effect.ensuring(store.dispose(ctx)))
|
||||
return yield* runImport(args.file, ctx.project.id)
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { McpOAuthProvider } from "../../mcp/oauth-provider"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigMCP } from "../../config/mcp"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { WithInstance } from "../../project/with-instance"
|
||||
import { Installation } from "../../installation"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import path from "path"
|
||||
@@ -114,7 +115,7 @@ export const McpListCommand = cmd({
|
||||
aliases: ["ls"],
|
||||
describe: "list MCP servers and their status",
|
||||
async handler() {
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
UI.empty()
|
||||
@@ -186,7 +187,7 @@ export const McpAuthCommand = cmd({
|
||||
})
|
||||
.command(McpAuthListCommand),
|
||||
async handler(args) {
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
UI.empty()
|
||||
@@ -318,7 +319,7 @@ export const McpAuthListCommand = cmd({
|
||||
aliases: ["ls"],
|
||||
describe: "list OAuth-capable MCP servers and their auth status",
|
||||
async handler() {
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
UI.empty()
|
||||
@@ -357,7 +358,7 @@ export const McpLogoutCommand = cmd({
|
||||
type: "string",
|
||||
}),
|
||||
async handler(args) {
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
UI.empty()
|
||||
@@ -448,7 +449,7 @@ export const McpAddCommand = cmd({
|
||||
command: "add",
|
||||
describe: "add an MCP server",
|
||||
async handler() {
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
UI.empty()
|
||||
@@ -618,7 +619,7 @@ export const McpDebugCommand = cmd({
|
||||
demandOption: true,
|
||||
}),
|
||||
async handler(args) {
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
UI.empty()
|
||||
|
||||
@@ -13,7 +13,7 @@ import os from "os"
|
||||
import { Config } from "@/config/config"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Plugin } from "../../plugin"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { WithInstance } from "../../project/with-instance"
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
import { Process } from "@/util/process"
|
||||
import { text } from "node:stream/consumers"
|
||||
@@ -303,7 +303,7 @@ export const ProvidersLoginCommand = cmd({
|
||||
type: "string",
|
||||
}),
|
||||
async handler(args) {
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
UI.empty()
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Argv } from "yargs"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Effect } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { EOL } from "os"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
|
||||
@@ -203,11 +203,17 @@ function normalizePath(input?: string) {
|
||||
return input
|
||||
}
|
||||
|
||||
export const RunCommand = cmd({
|
||||
export const RunCommand = effectCmd({
|
||||
command: "run [message..]",
|
||||
describe: "run opencode with a message",
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs
|
||||
// --attach connects to a remote server (no local instance needed); the
|
||||
// default path runs an in-process server and needs the project instance.
|
||||
instance: (args) => !args.attach,
|
||||
// For --dir without --attach, load instance for the resolved target dir.
|
||||
// The handler also chdirs (preserving the legacy order: chdir → file resolution).
|
||||
directory: (args) => (args.dir && !args.attach ? path.resolve(process.cwd(), args.dir) : process.cwd()),
|
||||
builder: (yargs: Argv) =>
|
||||
yargs
|
||||
.positional("message", {
|
||||
describe: "message to send",
|
||||
type: "string",
|
||||
@@ -291,291 +297,313 @@ export const RunCommand = cmd({
|
||||
type: "boolean",
|
||||
describe: "auto-approve permissions that are not explicitly denied (dangerous!)",
|
||||
default: false,
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
let message = [...args.message, ...(args["--"] || [])]
|
||||
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
|
||||
.join(" ")
|
||||
}),
|
||||
handler: Effect.fn("Cli.run")(function* (args) {
|
||||
yield* Effect.promise(async () => {
|
||||
let message = [...args.message, ...(args["--"] || [])]
|
||||
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
|
||||
.join(" ")
|
||||
|
||||
const directory = (() => {
|
||||
if (!args.dir) return undefined
|
||||
if (args.attach) return args.dir
|
||||
try {
|
||||
process.chdir(args.dir)
|
||||
return process.cwd()
|
||||
} catch {
|
||||
UI.error("Failed to change directory to " + args.dir)
|
||||
process.exit(1)
|
||||
}
|
||||
})()
|
||||
|
||||
const files: { type: "file"; url: string; filename: string; mime: string }[] = []
|
||||
if (args.file) {
|
||||
const list = Array.isArray(args.file) ? args.file : [args.file]
|
||||
|
||||
for (const filePath of list) {
|
||||
const resolvedPath = path.resolve(process.cwd(), filePath)
|
||||
if (!(await Filesystem.exists(resolvedPath))) {
|
||||
UI.error(`File not found: ${filePath}`)
|
||||
const directory = (() => {
|
||||
if (!args.dir) return undefined
|
||||
if (args.attach) return args.dir
|
||||
try {
|
||||
process.chdir(args.dir)
|
||||
return process.cwd()
|
||||
} catch {
|
||||
UI.error("Failed to change directory to " + args.dir)
|
||||
process.exit(1)
|
||||
}
|
||||
})()
|
||||
|
||||
const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain"
|
||||
const files: { type: "file"; url: string; filename: string; mime: string }[] = []
|
||||
if (args.file) {
|
||||
const list = Array.isArray(args.file) ? args.file : [args.file]
|
||||
|
||||
files.push({
|
||||
type: "file",
|
||||
url: pathToFileURL(resolvedPath).href,
|
||||
filename: path.basename(resolvedPath),
|
||||
mime,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
|
||||
|
||||
if (message.trim().length === 0 && !args.command) {
|
||||
UI.error("You must provide a message or a command")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (args.fork && !args.continue && !args.session) {
|
||||
UI.error("--fork requires --continue or --session")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const rules: Permission.Ruleset = [
|
||||
{
|
||||
permission: "question",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
{
|
||||
permission: "plan_enter",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
{
|
||||
permission: "plan_exit",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
]
|
||||
|
||||
function title() {
|
||||
if (args.title === undefined) return
|
||||
if (args.title !== "") return args.title
|
||||
return message.slice(0, 50) + (message.length > 50 ? "..." : "")
|
||||
}
|
||||
|
||||
async function session(sdk: OpencodeClient) {
|
||||
const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session
|
||||
|
||||
if (baseID && args.fork) {
|
||||
const forked = await sdk.session.fork({ sessionID: baseID })
|
||||
return forked.data?.id
|
||||
}
|
||||
|
||||
if (baseID) return baseID
|
||||
|
||||
const name = title()
|
||||
const result = await sdk.session.create({ title: name, permission: rules })
|
||||
return result.data?.id
|
||||
}
|
||||
|
||||
async function share(sdk: OpencodeClient, sessionID: string) {
|
||||
const cfg = await sdk.config.get()
|
||||
if (!cfg.data) return
|
||||
if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return
|
||||
const res = await sdk.session.share({ sessionID }).catch((error) => {
|
||||
if (error instanceof Error && error.message.includes("disabled")) {
|
||||
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
|
||||
}
|
||||
return { error }
|
||||
})
|
||||
if (!res.error && "data" in res && res.data?.share?.url) {
|
||||
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url)
|
||||
}
|
||||
}
|
||||
|
||||
async function execute(sdk: OpencodeClient) {
|
||||
function tool(part: ToolPart) {
|
||||
try {
|
||||
if (part.tool === ShellID.ToolID) return shell(props<typeof ShellTool>(part))
|
||||
if (part.tool === "glob") return glob(props<typeof GlobTool>(part))
|
||||
if (part.tool === "grep") return grep(props<typeof GrepTool>(part))
|
||||
if (part.tool === "read") return read(props<typeof ReadTool>(part))
|
||||
if (part.tool === "write") return write(props<typeof WriteTool>(part))
|
||||
if (part.tool === "webfetch") return webfetch(props<typeof WebFetchTool>(part))
|
||||
if (part.tool === "edit") return edit(props<typeof EditTool>(part))
|
||||
if (part.tool === "websearch") return websearch(props<typeof WebSearchTool>(part))
|
||||
if (part.tool === "task") return task(props<typeof TaskTool>(part))
|
||||
if (part.tool === "todowrite") return todo(props<typeof TodoWriteTool>(part))
|
||||
if (part.tool === "skill") return skill(props<typeof SkillTool>(part))
|
||||
return fallback(part)
|
||||
} catch {
|
||||
return fallback(part)
|
||||
}
|
||||
}
|
||||
|
||||
function emit(type: string, data: Record<string, unknown>) {
|
||||
if (args.format === "json") {
|
||||
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const events = await sdk.event.subscribe()
|
||||
let error: string | undefined
|
||||
|
||||
async function loop() {
|
||||
const toggles = new Map<string, boolean>()
|
||||
|
||||
for await (const event of events.stream) {
|
||||
if (
|
||||
event.type === "message.updated" &&
|
||||
event.properties.info.role === "assistant" &&
|
||||
args.format !== "json" &&
|
||||
toggles.get("start") !== true
|
||||
) {
|
||||
UI.empty()
|
||||
UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`)
|
||||
UI.empty()
|
||||
toggles.set("start", true)
|
||||
for (const filePath of list) {
|
||||
const resolvedPath = path.resolve(process.cwd(), filePath)
|
||||
if (!(await Filesystem.exists(resolvedPath))) {
|
||||
UI.error(`File not found: ${filePath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
const part = event.properties.part
|
||||
if (part.sessionID !== sessionID) continue
|
||||
const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain"
|
||||
|
||||
if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) {
|
||||
if (emit("tool_use", { part })) continue
|
||||
if (part.state.status === "completed") {
|
||||
tool(part)
|
||||
continue
|
||||
files.push({
|
||||
type: "file",
|
||||
url: pathToFileURL(resolvedPath).href,
|
||||
filename: path.basename(resolvedPath),
|
||||
mime,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
|
||||
|
||||
if (message.trim().length === 0 && !args.command) {
|
||||
UI.error("You must provide a message or a command")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (args.fork && !args.continue && !args.session) {
|
||||
UI.error("--fork requires --continue or --session")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const rules: Permission.Ruleset = [
|
||||
{
|
||||
permission: "question",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
{
|
||||
permission: "plan_enter",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
{
|
||||
permission: "plan_exit",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
]
|
||||
|
||||
function title() {
|
||||
if (args.title === undefined) return
|
||||
if (args.title !== "") return args.title
|
||||
return message.slice(0, 50) + (message.length > 50 ? "..." : "")
|
||||
}
|
||||
|
||||
async function session(sdk: OpencodeClient) {
|
||||
const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session
|
||||
|
||||
if (baseID && args.fork) {
|
||||
const forked = await sdk.session.fork({ sessionID: baseID })
|
||||
return forked.data?.id
|
||||
}
|
||||
|
||||
if (baseID) return baseID
|
||||
|
||||
const name = title()
|
||||
const result = await sdk.session.create({ title: name, permission: rules })
|
||||
return result.data?.id
|
||||
}
|
||||
|
||||
async function share(sdk: OpencodeClient, sessionID: string) {
|
||||
const cfg = await sdk.config.get()
|
||||
if (!cfg.data) return
|
||||
if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return
|
||||
const res = await sdk.session.share({ sessionID }).catch((error) => {
|
||||
if (error instanceof Error && error.message.includes("disabled")) {
|
||||
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
|
||||
}
|
||||
return { error }
|
||||
})
|
||||
if (!res.error && "data" in res && res.data?.share?.url) {
|
||||
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url)
|
||||
}
|
||||
}
|
||||
|
||||
async function execute(sdk: OpencodeClient) {
|
||||
function tool(part: ToolPart) {
|
||||
try {
|
||||
if (part.tool === ShellID.ToolID) return shell(props<typeof ShellTool>(part))
|
||||
if (part.tool === "glob") return glob(props<typeof GlobTool>(part))
|
||||
if (part.tool === "grep") return grep(props<typeof GrepTool>(part))
|
||||
if (part.tool === "read") return read(props<typeof ReadTool>(part))
|
||||
if (part.tool === "write") return write(props<typeof WriteTool>(part))
|
||||
if (part.tool === "webfetch") return webfetch(props<typeof WebFetchTool>(part))
|
||||
if (part.tool === "edit") return edit(props<typeof EditTool>(part))
|
||||
if (part.tool === "websearch") return websearch(props<typeof WebSearchTool>(part))
|
||||
if (part.tool === "task") return task(props<typeof TaskTool>(part))
|
||||
if (part.tool === "todowrite") return todo(props<typeof TodoWriteTool>(part))
|
||||
if (part.tool === "skill") return skill(props<typeof SkillTool>(part))
|
||||
return fallback(part)
|
||||
} catch {
|
||||
return fallback(part)
|
||||
}
|
||||
}
|
||||
|
||||
function emit(type: string, data: Record<string, unknown>) {
|
||||
if (args.format === "json") {
|
||||
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const events = await sdk.event.subscribe()
|
||||
let error: string | undefined
|
||||
|
||||
async function loop() {
|
||||
const toggles = new Map<string, boolean>()
|
||||
|
||||
for await (const event of events.stream) {
|
||||
if (
|
||||
event.type === "message.updated" &&
|
||||
event.properties.info.role === "assistant" &&
|
||||
args.format !== "json" &&
|
||||
toggles.get("start") !== true
|
||||
) {
|
||||
UI.empty()
|
||||
UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`)
|
||||
UI.empty()
|
||||
toggles.set("start", true)
|
||||
}
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
const part = event.properties.part
|
||||
if (part.sessionID !== sessionID) continue
|
||||
|
||||
if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) {
|
||||
if (emit("tool_use", { part })) continue
|
||||
if (part.state.status === "completed") {
|
||||
tool(part)
|
||||
continue
|
||||
}
|
||||
inline({
|
||||
icon: "✗",
|
||||
title: `${part.tool} failed`,
|
||||
})
|
||||
UI.error(part.state.error)
|
||||
}
|
||||
inline({
|
||||
icon: "✗",
|
||||
title: `${part.tool} failed`,
|
||||
})
|
||||
UI.error(part.state.error)
|
||||
|
||||
if (
|
||||
part.type === "tool" &&
|
||||
part.tool === "task" &&
|
||||
part.state.status === "running" &&
|
||||
args.format !== "json"
|
||||
) {
|
||||
if (toggles.get(part.id) === true) continue
|
||||
task(props<typeof TaskTool>(part))
|
||||
toggles.set(part.id, true)
|
||||
}
|
||||
|
||||
if (part.type === "step-start") {
|
||||
if (emit("step_start", { part })) continue
|
||||
}
|
||||
|
||||
if (part.type === "step-finish") {
|
||||
if (emit("step_finish", { part })) continue
|
||||
}
|
||||
|
||||
if (part.type === "text" && part.time?.end) {
|
||||
if (emit("text", { part })) continue
|
||||
const text = part.text.trim()
|
||||
if (!text) continue
|
||||
if (!process.stdout.isTTY) {
|
||||
process.stdout.write(text + EOL)
|
||||
continue
|
||||
}
|
||||
UI.empty()
|
||||
UI.println(text)
|
||||
UI.empty()
|
||||
}
|
||||
|
||||
if (part.type === "reasoning" && part.time?.end && args.thinking) {
|
||||
if (emit("reasoning", { part })) continue
|
||||
const text = part.text.trim()
|
||||
if (!text) continue
|
||||
const line = `Thinking: ${text}`
|
||||
if (process.stdout.isTTY) {
|
||||
UI.empty()
|
||||
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
|
||||
UI.empty()
|
||||
continue
|
||||
}
|
||||
process.stdout.write(line + EOL)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const props = event.properties
|
||||
if (props.sessionID !== sessionID || !props.error) continue
|
||||
let err = String(props.error.name)
|
||||
if ("data" in props.error && props.error.data && "message" in props.error.data) {
|
||||
err = String(props.error.data.message)
|
||||
}
|
||||
error = error ? error + EOL + err : err
|
||||
if (emit("error", { error: props.error })) continue
|
||||
UI.error(err)
|
||||
}
|
||||
|
||||
if (
|
||||
part.type === "tool" &&
|
||||
part.tool === "task" &&
|
||||
part.state.status === "running" &&
|
||||
args.format !== "json"
|
||||
event.type === "session.status" &&
|
||||
event.properties.sessionID === sessionID &&
|
||||
event.properties.status.type === "idle"
|
||||
) {
|
||||
if (toggles.get(part.id) === true) continue
|
||||
task(props<typeof TaskTool>(part))
|
||||
toggles.set(part.id, true)
|
||||
break
|
||||
}
|
||||
|
||||
if (part.type === "step-start") {
|
||||
if (emit("step_start", { part })) continue
|
||||
}
|
||||
if (event.type === "permission.asked") {
|
||||
const permission = event.properties
|
||||
if (permission.sessionID !== sessionID) continue
|
||||
|
||||
if (part.type === "step-finish") {
|
||||
if (emit("step_finish", { part })) continue
|
||||
}
|
||||
|
||||
if (part.type === "text" && part.time?.end) {
|
||||
if (emit("text", { part })) continue
|
||||
const text = part.text.trim()
|
||||
if (!text) continue
|
||||
if (!process.stdout.isTTY) {
|
||||
process.stdout.write(text + EOL)
|
||||
continue
|
||||
if (args["dangerously-skip-permissions"]) {
|
||||
await sdk.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: "once",
|
||||
})
|
||||
} else {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL +
|
||||
`permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
|
||||
)
|
||||
await sdk.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: "reject",
|
||||
})
|
||||
}
|
||||
UI.empty()
|
||||
UI.println(text)
|
||||
UI.empty()
|
||||
}
|
||||
|
||||
if (part.type === "reasoning" && part.time?.end && args.thinking) {
|
||||
if (emit("reasoning", { part })) continue
|
||||
const text = part.text.trim()
|
||||
if (!text) continue
|
||||
const line = `Thinking: ${text}`
|
||||
if (process.stdout.isTTY) {
|
||||
UI.empty()
|
||||
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
|
||||
UI.empty()
|
||||
continue
|
||||
}
|
||||
process.stdout.write(line + EOL)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const props = event.properties
|
||||
if (props.sessionID !== sessionID || !props.error) continue
|
||||
let err = String(props.error.name)
|
||||
if ("data" in props.error && props.error.data && "message" in props.error.data) {
|
||||
err = String(props.error.data.message)
|
||||
}
|
||||
error = error ? error + EOL + err : err
|
||||
if (emit("error", { error: props.error })) continue
|
||||
UI.error(err)
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "session.status" &&
|
||||
event.properties.sessionID === sessionID &&
|
||||
event.properties.status.type === "idle"
|
||||
) {
|
||||
break
|
||||
}
|
||||
|
||||
if (event.type === "permission.asked") {
|
||||
const permission = event.properties
|
||||
if (permission.sessionID !== sessionID) continue
|
||||
|
||||
if (args["dangerously-skip-permissions"]) {
|
||||
await sdk.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: "once",
|
||||
})
|
||||
} else {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL +
|
||||
`permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
|
||||
)
|
||||
await sdk.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: "reject",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate agent if specified
|
||||
const agent = await (async () => {
|
||||
if (!args.agent) return undefined
|
||||
const name = args.agent
|
||||
// Validate agent if specified
|
||||
const agent = await (async () => {
|
||||
if (!args.agent) return undefined
|
||||
const name = args.agent
|
||||
|
||||
// When attaching, validate against the running server instead of local Instance state.
|
||||
if (args.attach) {
|
||||
const modes = await sdk.app
|
||||
.agents(undefined, { throwOnError: true })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => undefined)
|
||||
// When attaching, validate against the running server instead of local Instance state.
|
||||
if (args.attach) {
|
||||
const modes = await sdk.app
|
||||
.agents(undefined, { throwOnError: true })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => undefined)
|
||||
|
||||
if (!modes) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`failed to list agents from ${args.attach}. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
if (!modes) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`failed to list agents from ${args.attach}. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const agent = modes.find((a) => a.name === name)
|
||||
if (!agent) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`agent "${name}" not found. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (agent.mode === "subagent") {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
const agent = modes.find((a) => a.name === name)
|
||||
if (!agent) {
|
||||
const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name)))
|
||||
if (!entry) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
@@ -583,8 +611,7 @@ export const RunCommand = cmd({
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (agent.mode === "subagent") {
|
||||
if (entry.mode === "subagent") {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
@@ -592,76 +619,54 @@ export const RunCommand = cmd({
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return name
|
||||
})()
|
||||
|
||||
const sessionID = await session(sdk)
|
||||
if (!sessionID) {
|
||||
UI.error("Session not found")
|
||||
process.exit(1)
|
||||
}
|
||||
await share(sdk, sessionID)
|
||||
|
||||
const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name)))
|
||||
if (!entry) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`agent "${name}" not found. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
if (entry.mode === "subagent") {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
return name
|
||||
})()
|
||||
|
||||
const sessionID = await session(sdk)
|
||||
if (!sessionID) {
|
||||
UI.error("Session not found")
|
||||
process.exit(1)
|
||||
}
|
||||
await share(sdk, sessionID)
|
||||
|
||||
loop().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
if (args.command) {
|
||||
await sdk.session.command({
|
||||
sessionID,
|
||||
agent,
|
||||
model: args.model,
|
||||
command: args.command,
|
||||
arguments: message,
|
||||
variant: args.variant,
|
||||
})
|
||||
} else {
|
||||
const model = args.model ? Provider.parseModel(args.model) : undefined
|
||||
await sdk.session.prompt({
|
||||
sessionID,
|
||||
agent,
|
||||
model,
|
||||
variant: args.variant,
|
||||
parts: [...files, { type: "text", text: message }],
|
||||
loop().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
if (args.command) {
|
||||
await sdk.session.command({
|
||||
sessionID,
|
||||
agent,
|
||||
model: args.model,
|
||||
command: args.command,
|
||||
arguments: message,
|
||||
variant: args.variant,
|
||||
})
|
||||
} else {
|
||||
const model = args.model ? Provider.parseModel(args.model) : undefined
|
||||
await sdk.session.prompt({
|
||||
sessionID,
|
||||
agent,
|
||||
model,
|
||||
variant: args.variant,
|
||||
parts: [...files, { type: "text", text: message }],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (args.attach) {
|
||||
const headers = (() => {
|
||||
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
|
||||
if (!password) return undefined
|
||||
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||
const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
return { Authorization: auth }
|
||||
})()
|
||||
const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers })
|
||||
return await execute(sdk)
|
||||
}
|
||||
if (args.attach) {
|
||||
const headers = (() => {
|
||||
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
|
||||
if (!password) return undefined
|
||||
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||
const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
return { Authorization: auth }
|
||||
})()
|
||||
const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers })
|
||||
return await execute(sdk)
|
||||
}
|
||||
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init)
|
||||
return Server.Default().app.fetch(request)
|
||||
@@ -669,5 +674,5 @@ export const RunCommand = cmd({
|
||||
const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
|
||||
await execute(sdk)
|
||||
})
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import { Effect } from "effect"
|
||||
import { Server } from "../../server/server"
|
||||
import { cmd } from "./cmd"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
|
||||
export const ServeCommand = cmd({
|
||||
export const ServeCommand = effectCmd({
|
||||
command: "serve",
|
||||
builder: (yargs) => withNetworkOptions(yargs),
|
||||
describe: "starts a headless opencode server",
|
||||
handler: async (args) => {
|
||||
// Server loads instances per-request via x-opencode-directory header — no
|
||||
// need for an ambient project InstanceContext at startup.
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.serve")(function* (args) {
|
||||
if (!Flag.OPENCODE_SERVER_PASSWORD) {
|
||||
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = await Server.listen(opts)
|
||||
const opts = yield* Effect.promise(() => resolveNetworkOptions(args))
|
||||
const server = yield* Effect.promise(() => Server.listen(opts))
|
||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||
|
||||
await new Promise(() => {})
|
||||
await server.stop()
|
||||
},
|
||||
yield* Effect.never
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -12,8 +12,6 @@ import { Process } from "@/util/process"
|
||||
import { EOL } from "os"
|
||||
import path from "path"
|
||||
import { which } from "../../util/which"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
|
||||
function pagerCmd(): string[] {
|
||||
const lessOptions = ["-R", "-S"]
|
||||
@@ -59,17 +57,12 @@ export const SessionDeleteCommand = effectCmd({
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: Effect.fn("Cli.session.delete")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const store = yield* InstanceStore.Service
|
||||
return yield* Effect.gen(function* () {
|
||||
const svc = yield* Session.Service
|
||||
const sessionID = SessionID.make(args.sessionID)
|
||||
// Match legacy try/catch — Session.get surfaces NotFoundError as a defect.
|
||||
yield* svc.get(sessionID).pipe(Effect.catchCause(() => fail(`Session not found: ${args.sessionID}`)))
|
||||
yield* svc.remove(sessionID)
|
||||
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
|
||||
}).pipe(Effect.ensuring(store.dispose(ctx)))
|
||||
const svc = yield* Session.Service
|
||||
const sessionID = SessionID.make(args.sessionID)
|
||||
// Match legacy try/catch — Session.get surfaces NotFoundError as a defect.
|
||||
yield* svc.get(sessionID).pipe(Effect.catchCause(() => fail(`Session not found: ${args.sessionID}`)))
|
||||
yield* svc.remove(sessionID)
|
||||
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -90,39 +83,34 @@ export const SessionListCommand = effectCmd({
|
||||
default: "table",
|
||||
}),
|
||||
handler: Effect.fn("Cli.session.list")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const store = yield* InstanceStore.Service
|
||||
return yield* Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount }))
|
||||
const sessions = yield* Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount }))
|
||||
|
||||
if (sessions.length === 0) return
|
||||
if (sessions.length === 0) return
|
||||
|
||||
const output = args.format === "json" ? formatSessionJSON(sessions) : formatSessionTable(sessions)
|
||||
const output = args.format === "json" ? formatSessionJSON(sessions) : formatSessionTable(sessions)
|
||||
|
||||
const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"
|
||||
const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"
|
||||
|
||||
if (shouldPaginate) {
|
||||
yield* Effect.promise(async () => {
|
||||
const proc = Process.spawn(pagerCmd(), {
|
||||
stdin: "pipe",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
|
||||
if (!proc.stdin) {
|
||||
console.log(output)
|
||||
return
|
||||
}
|
||||
|
||||
proc.stdin.write(output)
|
||||
proc.stdin.end()
|
||||
await proc.exited
|
||||
if (shouldPaginate) {
|
||||
yield* Effect.promise(async () => {
|
||||
const proc = Process.spawn(pagerCmd(), {
|
||||
stdin: "pipe",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
} else {
|
||||
console.log(output)
|
||||
}
|
||||
}).pipe(Effect.ensuring(store.dispose(ctx)))
|
||||
|
||||
if (!proc.stdin) {
|
||||
console.log(output)
|
||||
return
|
||||
}
|
||||
|
||||
proc.stdin.write(output)
|
||||
proc.stdin.end()
|
||||
await proc.exited
|
||||
})
|
||||
} else {
|
||||
console.log(output)
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Database } from "@/storage/db"
|
||||
import { SessionTable } from "../../session/session.sql"
|
||||
import { Project } from "@/project/project"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
interface SessionStats {
|
||||
@@ -70,8 +69,7 @@ export const StatsCommand = effectCmd({
|
||||
handler: Effect.fn("Cli.stats")(function* (args) {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return
|
||||
const store = yield* InstanceStore.Service
|
||||
return yield* run(args, ctx.project).pipe(Effect.ensuring(store.dispose(ctx)))
|
||||
return yield* run(args, ctx.project)
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useEvent } from "@tui/context/event"
|
||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||
import { StartupLoading } from "@tui/component/startup-loading"
|
||||
import { SyncProvider, useSync } from "@tui/context/sync"
|
||||
import { SyncProviderV2 } from "@tui/context/sync-v2"
|
||||
import { LocalProvider, useLocal } from "@tui/context/local"
|
||||
import { DialogModel } from "@tui/component/dialog-model"
|
||||
import { useConnected } from "@tui/component/use-connected"
|
||||
@@ -168,27 +169,29 @@ export function tui(input: {
|
||||
>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<KeybindProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
<SyncProviderV2>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<KeybindProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProviderV2>
|
||||
</SyncProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
|
||||
@@ -750,9 +750,18 @@ export function Prompt(props: PromptProps) {
|
||||
return false
|
||||
}
|
||||
|
||||
const variant = local.model.variant.current()
|
||||
let sessionID = props.sessionID
|
||||
if (sessionID == null) {
|
||||
const res = await sdk.client.session.create({ workspace: props.workspaceID })
|
||||
const res = await sdk.client.session.create({
|
||||
workspace: props.workspaceID,
|
||||
agent: agent.name,
|
||||
model: {
|
||||
providerID: selectedModel.providerID,
|
||||
id: selectedModel.modelID,
|
||||
variant,
|
||||
},
|
||||
})
|
||||
|
||||
if (res.error) {
|
||||
console.log("Creating a session failed:", res.error)
|
||||
@@ -792,7 +801,6 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
// Capture mode before it gets reset
|
||||
const currentMode = store.mode
|
||||
const variant = local.model.variant.current()
|
||||
const editorSelection = editorContext()
|
||||
const currentEditorSelectionKey = editorSelectionKey(editorSelection)
|
||||
const editorParts =
|
||||
|
||||
298
packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx
Normal file
298
packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import { useEvent } from "@tui/context/event"
|
||||
import type {
|
||||
SessionMessage,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantReasoning,
|
||||
SessionMessageAssistantText,
|
||||
SessionMessageAssistantTool,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
function activeAssistant(messages: SessionMessage[]) {
|
||||
const index = messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed)
|
||||
if (index < 0) return
|
||||
const assistant = messages[index]
|
||||
return assistant?.type === "assistant" ? assistant : undefined
|
||||
}
|
||||
|
||||
function activeCompaction(messages: SessionMessage[]) {
|
||||
const index = messages.findLastIndex((message) => message.type === "compaction")
|
||||
if (index < 0) return
|
||||
const compaction = messages[index]
|
||||
return compaction?.type === "compaction" ? compaction : undefined
|
||||
}
|
||||
|
||||
function activeShell(messages: SessionMessage[], callID: string) {
|
||||
const index = messages.findLastIndex((message) => message.type === "shell" && message.callID === callID)
|
||||
if (index < 0) return
|
||||
const shell = messages[index]
|
||||
return shell?.type === "shell" ? shell : undefined
|
||||
}
|
||||
|
||||
function latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantTool => item.type === "tool" && (callID === undefined || item.id === callID),
|
||||
)
|
||||
}
|
||||
|
||||
function latestText(assistant: SessionMessageAssistant | undefined) {
|
||||
return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
|
||||
}
|
||||
|
||||
function latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID,
|
||||
)
|
||||
}
|
||||
|
||||
export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext({
|
||||
name: "SyncV2",
|
||||
init: () => {
|
||||
const [store, setStore] = createStore<{
|
||||
messages: {
|
||||
[sessionID: string]: SessionMessage[]
|
||||
}
|
||||
}>({
|
||||
messages: {},
|
||||
})
|
||||
|
||||
const event = useEvent()
|
||||
const sdk = useSDK()
|
||||
|
||||
function update(sessionID: string, fn: (messages: SessionMessage[]) => void) {
|
||||
setStore(
|
||||
"messages",
|
||||
produce((draft) => {
|
||||
fn((draft[sessionID] ??= []))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
event.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case "session.next.prompted": {
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.push({
|
||||
id: event.id,
|
||||
type: "user",
|
||||
text: event.properties.prompt.text,
|
||||
files: event.properties.prompt.files,
|
||||
agents: event.properties.prompt.agents,
|
||||
time: { created: event.properties.timestamp },
|
||||
})
|
||||
})
|
||||
break
|
||||
}
|
||||
case "session.next.synthetic":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.push({
|
||||
id: event.id,
|
||||
type: "synthetic",
|
||||
sessionID: event.properties.sessionID,
|
||||
text: event.properties.text,
|
||||
time: { created: event.properties.timestamp },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.shell.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.push({
|
||||
id: event.id,
|
||||
type: "shell",
|
||||
callID: event.properties.callID,
|
||||
command: event.properties.command,
|
||||
output: "",
|
||||
time: { created: event.properties.timestamp },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.shell.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = activeShell(draft, event.properties.callID)
|
||||
if (!match) return
|
||||
match.output = event.properties.output
|
||||
match.time.completed = event.properties.timestamp
|
||||
})
|
||||
break
|
||||
case "session.next.step.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const currentAssistant = activeAssistant(draft)
|
||||
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
|
||||
draft.push({
|
||||
id: event.id,
|
||||
type: "assistant",
|
||||
agent: event.properties.agent,
|
||||
model: event.properties.model,
|
||||
content: [],
|
||||
snapshot: event.properties.snapshot ? { start: event.properties.snapshot } : undefined,
|
||||
time: { created: event.properties.timestamp },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.step.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const currentAssistant = activeAssistant(draft)
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.time.completed = event.properties.timestamp
|
||||
currentAssistant.finish = event.properties.finish
|
||||
currentAssistant.cost = event.properties.cost
|
||||
currentAssistant.tokens = event.properties.tokens
|
||||
if (event.properties.snapshot)
|
||||
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot }
|
||||
})
|
||||
break
|
||||
case "session.next.text.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
activeAssistant(draft)?.content.push({ type: "text", text: "" })
|
||||
})
|
||||
break
|
||||
case "session.next.text.delta":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestText(activeAssistant(draft))
|
||||
if (match) match.text += event.properties.delta
|
||||
})
|
||||
break
|
||||
case "session.next.text.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestText(activeAssistant(draft))
|
||||
if (match) match.text = event.properties.text
|
||||
})
|
||||
break
|
||||
case "session.next.tool.input.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
activeAssistant(draft)?.content.push({
|
||||
type: "tool",
|
||||
id: event.properties.callID,
|
||||
name: event.properties.name,
|
||||
time: { created: event.properties.timestamp },
|
||||
state: { status: "pending", input: "" },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.tool.input.delta":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(activeAssistant(draft), event.properties.callID)
|
||||
if (match?.state.status === "pending") match.state.input += event.properties.delta
|
||||
})
|
||||
break
|
||||
case "session.next.tool.input.ended":
|
||||
break
|
||||
case "session.next.tool.called":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(activeAssistant(draft), event.properties.callID)
|
||||
if (!match) return
|
||||
match.time.ran = event.properties.timestamp
|
||||
match.provider = event.properties.provider
|
||||
match.state = { status: "running", input: event.properties.input, structured: {}, content: [] }
|
||||
})
|
||||
break
|
||||
case "session.next.tool.progress":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(activeAssistant(draft), event.properties.callID)
|
||||
if (match?.state.status !== "running") return
|
||||
match.state.structured = event.properties.structured
|
||||
match.state.content = [...event.properties.content]
|
||||
})
|
||||
break
|
||||
case "session.next.tool.success":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(activeAssistant(draft), event.properties.callID)
|
||||
if (match?.state.status !== "running") return
|
||||
match.state = {
|
||||
status: "completed",
|
||||
input: match.state.input,
|
||||
structured: event.properties.structured,
|
||||
content: [...event.properties.content],
|
||||
}
|
||||
match.provider = event.properties.provider
|
||||
match.time.completed = event.properties.timestamp
|
||||
})
|
||||
break
|
||||
case "session.next.tool.error":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(activeAssistant(draft), event.properties.callID)
|
||||
if (match?.state.status !== "running") return
|
||||
match.state = {
|
||||
status: "error",
|
||||
error: event.properties.error,
|
||||
input: match.state.input,
|
||||
structured: match.state.structured,
|
||||
content: match.state.content,
|
||||
}
|
||||
match.provider = event.properties.provider
|
||||
match.time.completed = event.properties.timestamp
|
||||
})
|
||||
break
|
||||
case "session.next.reasoning.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
activeAssistant(draft)?.content.push({
|
||||
type: "reasoning",
|
||||
id: event.properties.reasoningID,
|
||||
text: "",
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.reasoning.delta":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID)
|
||||
if (match) match.text += event.properties.delta
|
||||
})
|
||||
break
|
||||
case "session.next.reasoning.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID)
|
||||
if (match) match.text = event.properties.text
|
||||
})
|
||||
break
|
||||
case "session.next.retried":
|
||||
break
|
||||
case "session.next.compaction.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.push({
|
||||
id: event.id,
|
||||
type: "compaction",
|
||||
reason: event.properties.reason,
|
||||
summary: "",
|
||||
time: { created: event.properties.timestamp },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.compaction.delta":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = activeCompaction(draft)
|
||||
if (match) match.summary += event.properties.text
|
||||
})
|
||||
break
|
||||
case "session.next.compaction.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = activeCompaction(draft)
|
||||
if (!match) return
|
||||
match.summary = event.properties.text
|
||||
match.include = event.properties.include
|
||||
})
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
const result = {
|
||||
data: store,
|
||||
session: {
|
||||
message: {
|
||||
async sync(sessionID: string) {
|
||||
const response = await sdk.client.v2.session.messages({ sessionID })
|
||||
setStore("messages", sessionID, reconcile(response.data?.items ?? []))
|
||||
},
|
||||
fromSession(sessionID: string) {
|
||||
const messages = store.messages[sessionID]
|
||||
if (!messages) return []
|
||||
return messages
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,9 @@ import SidebarTodo from "../feature-plugins/sidebar/todo"
|
||||
import SidebarFiles from "../feature-plugins/sidebar/files"
|
||||
import SidebarFooter from "../feature-plugins/sidebar/footer"
|
||||
import PluginManager from "../feature-plugins/system/plugins"
|
||||
import SessionV2Debug from "../feature-plugins/system/session-v2"
|
||||
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
|
||||
export type InternalTuiPlugin = TuiPluginModule & {
|
||||
id: string
|
||||
@@ -24,4 +26,5 @@ export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [
|
||||
SidebarFiles,
|
||||
SidebarFooter,
|
||||
PluginManager,
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM ? [SessionV2Debug] : []),
|
||||
]
|
||||
|
||||
@@ -16,7 +16,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { errorData, errorMessage } from "@/util/error"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { WithInstance } from "@/project/with-instance"
|
||||
import {
|
||||
readPackageThemes,
|
||||
readPluginId,
|
||||
@@ -790,7 +790,7 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
|
||||
state.pending.delete(spec)
|
||||
return true
|
||||
}
|
||||
const ready = await Instance.provide({
|
||||
const ready = await WithInstance.provide({
|
||||
directory: state.directory,
|
||||
fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
|
||||
}).catch((error) => {
|
||||
@@ -986,7 +986,7 @@ async function load(input: { api: Api; config: TuiConfig.Info }) {
|
||||
}
|
||||
runtime = next
|
||||
try {
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: cwd,
|
||||
fn: async () => {
|
||||
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Installation } from "@/installation"
|
||||
import { Server } from "@/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { WithInstance } from "@/project/with-instance"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { upgrade } from "@/cli/upgrade"
|
||||
import { Config } from "@/config/config"
|
||||
@@ -77,7 +77,7 @@ export const rpc = {
|
||||
return { url: server.url.toString() }
|
||||
},
|
||||
async checkUpgrade(input: { directory: string }) {
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: input.directory,
|
||||
fn: async () => {
|
||||
await upgrade().catch(() => {})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Effect } from "effect"
|
||||
import { Server } from "../../server/server"
|
||||
import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import open from "open"
|
||||
@@ -28,16 +29,19 @@ function getNetworkIPs() {
|
||||
return results
|
||||
}
|
||||
|
||||
export const WebCommand = cmd({
|
||||
export const WebCommand = effectCmd({
|
||||
command: "web",
|
||||
builder: (yargs) => withNetworkOptions(yargs),
|
||||
describe: "start opencode server and open web interface",
|
||||
handler: async (args) => {
|
||||
// Server loads instances per-request via x-opencode-directory header — no
|
||||
// ambient project InstanceContext needed at startup.
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.web")(function* (args) {
|
||||
if (!Flag.OPENCODE_SERVER_PASSWORD) {
|
||||
UI.println(UI.Style.TEXT_WARNING_BOLD + "! OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = await Server.listen(opts)
|
||||
const opts = yield* Effect.promise(() => resolveNetworkOptions(args))
|
||||
const server = yield* Effect.promise(() => Server.listen(opts))
|
||||
UI.empty()
|
||||
UI.println(UI.logo(" "))
|
||||
UI.empty()
|
||||
@@ -75,7 +79,6 @@ export const WebCommand = cmd({
|
||||
open(displayUrl).catch(() => {})
|
||||
}
|
||||
|
||||
await new Promise(() => {})
|
||||
await server.stop()
|
||||
},
|
||||
yield* Effect.never
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Effect, Schema } from "effect"
|
||||
import { AppRuntime, type AppServices } from "@/effect/app-runtime"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { cmd } from "./cmd/cmd"
|
||||
import { cmd, type WithDoubleDash } from "./cmd/cmd"
|
||||
|
||||
/**
|
||||
* User-visible command failure. Throw via `fail("...")` from an effectCmd handler
|
||||
@@ -18,6 +18,38 @@ export class CliError extends Schema.TaggedErrorClass<CliError>()("CliError", {
|
||||
|
||||
export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError({ message, exitCode }))
|
||||
|
||||
interface EffectCmdOpts<Args, A> {
|
||||
command: string | readonly string[]
|
||||
aliases?: string | readonly string[]
|
||||
describe: string | false
|
||||
builder?: (yargs: Argv) => Argv<Args>
|
||||
/**
|
||||
* Whether the command needs a project InstanceContext. Defaults to true.
|
||||
*
|
||||
* `true` (default): wraps the handler in `InstanceStore.Service.provide({directory})`
|
||||
* so `InstanceRef` resolves to a loaded `InstanceContext`. Auto-disposes via
|
||||
* `Effect.ensuring(store.dispose(ctx))` on every Exit (matches the legacy
|
||||
* `bootstrap()` finally-disposal). Runs InstanceBootstrap (config + plugin
|
||||
* init + LSP/File/etc forks) eagerly.
|
||||
*
|
||||
* `false`: skip the instance entirely. Saves the InstanceBootstrap work and
|
||||
* suppresses the `server.instance.disposed` IPC event. The handler runs
|
||||
* directly under AppRuntime — it can yield any `AppServices` but must not
|
||||
* yield `InstanceRef` (it'd be undefined, causing a defect).
|
||||
*
|
||||
* Function form: `(args) => boolean` decides per-invocation. Useful for
|
||||
* commands like `run --attach <url>` where one flag flips between local
|
||||
* (needs instance) and remote (doesn't).
|
||||
*
|
||||
* Use `false` for commands that don't read project state (e.g. `models`,
|
||||
* `serve`, `web`, `account`, `db`, `upgrade`).
|
||||
*/
|
||||
instance?: boolean | ((args: Args) => boolean)
|
||||
/** Defaults to process.cwd(). Override for commands that take a directory positional. */
|
||||
directory?: (args: Args) => string
|
||||
handler: (args: WithDoubleDash<Args>) => Effect.Effect<A, CliError, AppServices | InstanceStore.Service>
|
||||
}
|
||||
|
||||
/**
|
||||
* Effect-native CLI command builder. Wraps yargs `cmd()` so the handler body is
|
||||
* an `Effect` with `InstanceRef` provided and any `AppServices` yieldable.
|
||||
@@ -35,15 +67,7 @@ export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError(
|
||||
* `effectCmd`, swapping the underlying `cmd()` factory for effect/cli's
|
||||
* `Command.make(...)` won't touch any handler bodies.
|
||||
*/
|
||||
export const effectCmd = <Args, A>(opts: {
|
||||
command: string | readonly string[]
|
||||
aliases?: string | readonly string[]
|
||||
describe: string | false
|
||||
builder?: (yargs: Argv) => Argv<Args>
|
||||
/** Defaults to process.cwd(). Override for commands that take a directory positional. */
|
||||
directory?: (args: Args) => string
|
||||
handler: (args: Args) => Effect.Effect<A, CliError, AppServices | InstanceStore.Service>
|
||||
}) =>
|
||||
export const effectCmd = <Args, A>(opts: EffectCmdOpts<Args, A>) =>
|
||||
cmd<{}, Args>({
|
||||
command: opts.command,
|
||||
aliases: opts.aliases,
|
||||
@@ -51,7 +75,12 @@ export const effectCmd = <Args, A>(opts: {
|
||||
builder: opts.builder as never,
|
||||
async handler(rawArgs) {
|
||||
// yargs typing wraps Args in ArgumentsCamelCase<WithDoubleDash<...>>; cast at the boundary.
|
||||
const args = rawArgs as unknown as Args
|
||||
const args = rawArgs as unknown as WithDoubleDash<Args>
|
||||
const useInstance = typeof opts.instance === "function" ? opts.instance(args) : opts.instance !== false
|
||||
if (!useInstance) {
|
||||
await AppRuntime.runPromise(opts.handler(args))
|
||||
return
|
||||
}
|
||||
const directory = opts.directory?.(args) ?? process.cwd()
|
||||
await AppRuntime.runPromise(
|
||||
InstanceStore.Service.use((store) =>
|
||||
|
||||
@@ -40,7 +40,7 @@ import { Command } from "@/command"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Format } from "@/format"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { InstanceLayer } from "@/project/instance-layer"
|
||||
import { Project } from "@/project/project"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
@@ -93,17 +93,16 @@ export const AppLayer = Layer.mergeAll(
|
||||
Truncate.defaultLayer,
|
||||
ToolRegistry.defaultLayer,
|
||||
Format.defaultLayer,
|
||||
InstanceRuntime.layer,
|
||||
Project.defaultLayer,
|
||||
Vcs.defaultLayer,
|
||||
Workspace.defaultLayer,
|
||||
Worktree.defaultLayer,
|
||||
Worktree.appLayer,
|
||||
Pty.defaultLayer,
|
||||
Installation.defaultLayer,
|
||||
ShareNext.defaultLayer,
|
||||
SessionShare.defaultLayer,
|
||||
SyncEvent.defaultLayer,
|
||||
).pipe(Layer.provideMerge(Observability.layer))
|
||||
).pipe(Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer))
|
||||
|
||||
const rt = ManagedRuntime.make(AppLayer, { memoMap })
|
||||
type Runtime = Pick<typeof rt, "runSync" | "runPromise" | "runPromiseExit" | "runFork" | "runCallback" | "dispose">
|
||||
|
||||
@@ -123,7 +123,9 @@ export const layer = Layer.effect(
|
||||
const cfgIgnores = cfg.watcher?.ignore ?? []
|
||||
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
||||
yield* subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)])
|
||||
yield* Effect.forkScoped(
|
||||
subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)]),
|
||||
)
|
||||
}
|
||||
|
||||
if (ctx.project.vcs === "git") {
|
||||
@@ -135,7 +137,7 @@ export const layer = Layer.effect(
|
||||
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
|
||||
(entry) => entry !== "HEAD",
|
||||
)
|
||||
yield* subscribe(vcsDir, ignore)
|
||||
yield* Effect.forkScoped(subscribe(vcsDir, ignore))
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -144,7 +144,6 @@ interface State {
|
||||
}
|
||||
|
||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
|
||||
return evalRule(permission, pattern, ...rulesets)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Snapshot } from "../snapshot"
|
||||
import * as Project from "./project"
|
||||
import * as Vcs from "./vcs"
|
||||
import { Bus } from "../bus"
|
||||
import { Command } from "../command"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
@@ -23,13 +22,13 @@ export const layer = Layer.effect(
|
||||
// Yield each bootstrap dep at layer init so `run` itself has R = never.
|
||||
// InstanceStore imports only the lightweight tag from bootstrap-service.ts,
|
||||
// so it can depend on bootstrap without importing this implementation graph.
|
||||
const bus = yield* Bus.Service
|
||||
const config = yield* Config.Service
|
||||
const file = yield* File.Service
|
||||
const fileWatcher = yield* FileWatcher.Service
|
||||
const format = yield* Format.Service
|
||||
const lsp = yield* LSP.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
const project = yield* Project.Service
|
||||
const shareNext = yield* ShareNext.Service
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const vcs = yield* Vcs.Service
|
||||
@@ -41,16 +40,13 @@ export const layer = Layer.effect(
|
||||
yield* config.get()
|
||||
// Plugin can mutate config so it has to be initialized before anything else.
|
||||
yield* plugin.init()
|
||||
yield* Effect.all(
|
||||
[lsp, shareNext, format, file, fileWatcher, vcs, snapshot].map((s) => Effect.forkDetach(s.init())),
|
||||
// Each service self-manages its own slow work via Effect.forkScoped against
|
||||
// its per-instance state scope. We just await materialization here.
|
||||
yield* Effect.forEach(
|
||||
[lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project],
|
||||
(s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
).pipe(Effect.withSpan("InstanceBootstrap.init"))
|
||||
|
||||
const projectID = ctx.project.id
|
||||
yield* bus.subscribeCallback(Command.Event.Executed, async (payload) => {
|
||||
if (payload.properties.name === Command.Default.INIT) {
|
||||
Project.setInitialized(projectID)
|
||||
}
|
||||
})
|
||||
}).pipe(Effect.withSpan("InstanceBootstrap"))
|
||||
|
||||
return Service.of({ run })
|
||||
|
||||
11
packages/opencode/src/project/instance-layer.ts
Normal file
11
packages/opencode/src/project/instance-layer.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Effect, Layer } from "effect"
|
||||
import { InstanceStore } from "./instance-store"
|
||||
|
||||
export const layer = Layer.unwrap(
|
||||
Effect.promise(async () => {
|
||||
const { InstanceBootstrap } = await import("./bootstrap")
|
||||
return InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))
|
||||
}),
|
||||
)
|
||||
|
||||
export * as InstanceLayer from "./instance-layer"
|
||||
@@ -1,27 +1,16 @@
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { type InstanceContext } from "./instance-context"
|
||||
import { InstanceStore, type LoadInput } from "./instance-store"
|
||||
import { Effect, Layer } from "effect"
|
||||
|
||||
// Production InstanceStore wiring plus a bridge for Promise/ALS callers that
|
||||
// cannot yet yield InstanceStore.Service. This keeps InstanceStore itself
|
||||
// low-level while still giving legacy Hono and CLI paths the production
|
||||
// bootstrap implementation. Delete the Promise helpers once those callers are
|
||||
// migrated to Effect boundaries that provide InstanceStore directly.
|
||||
// Keep the bootstrap implementation import lazy: Instance is imported broadly,
|
||||
// and importing the app bootstrap graph at module load can trigger ESM cycles.
|
||||
export const layer = Layer.unwrap(
|
||||
Effect.promise(async () => {
|
||||
const { InstanceBootstrap } = await import("./bootstrap")
|
||||
return InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))
|
||||
}),
|
||||
)
|
||||
// Bridge for Promise/ALS callers that cannot yet yield InstanceStore.Service.
|
||||
// Delete this module once those callers are migrated to Effect boundaries that
|
||||
// provide InstanceStore directly.
|
||||
|
||||
const runtime = makeRuntime(InstanceStore.Service, layer)
|
||||
|
||||
export const load = (input: LoadInput) => runtime.runPromise((store) => store.load(input))
|
||||
export const disposeInstance = (ctx: InstanceContext) => runtime.runPromise((store) => store.dispose(ctx))
|
||||
export const disposeAllInstances = () => runtime.runPromise((store) => store.disposeAll())
|
||||
export const reloadInstance = (input: LoadInput) => runtime.runPromise((store) => store.reload(input))
|
||||
export const load = (input: LoadInput) => AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load(input)))
|
||||
export const disposeInstance = (ctx: InstanceContext) =>
|
||||
AppRuntime.runPromise(InstanceStore.Service.use((store) => store.dispose(ctx)))
|
||||
export const disposeAllInstances = () => AppRuntime.runPromise(InstanceStore.Service.use((store) => store.disposeAll()))
|
||||
export const reloadInstance = (input: LoadInput) =>
|
||||
AppRuntime.runPromise(InstanceStore.Service.use((store) => store.reload(input)))
|
||||
|
||||
export * as InstanceRuntime from "./instance-runtime"
|
||||
|
||||
@@ -8,26 +8,18 @@ import { type InstanceContext } from "./instance-context"
|
||||
import { InstanceBootstrap } from "./bootstrap-service"
|
||||
import * as Project from "./project"
|
||||
|
||||
export interface LoadInput<R = never> {
|
||||
export interface LoadInput {
|
||||
directory: string
|
||||
/**
|
||||
* Additional setup to run after the default InstanceBootstrap.
|
||||
* Mainly used by tests for env-var setup or file writes that need the instance ALS context.
|
||||
*/
|
||||
init?: Effect.Effect<void, never, R>
|
||||
worktree?: string
|
||||
project?: Project.Info
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly load: <R = never>(input: LoadInput<R>) => Effect.Effect<InstanceContext, never, R>
|
||||
readonly reload: <R = never>(input: LoadInput<R>) => Effect.Effect<InstanceContext, never, R>
|
||||
readonly load: (input: LoadInput) => Effect.Effect<InstanceContext>
|
||||
readonly reload: (input: LoadInput) => Effect.Effect<InstanceContext>
|
||||
readonly dispose: (ctx: InstanceContext) => Effect.Effect<void>
|
||||
readonly disposeAll: () => Effect.Effect<void>
|
||||
readonly provide: <A, E, R, R2 = never>(
|
||||
input: LoadInput<R2>,
|
||||
effect: Effect.Effect<A, E, R>,
|
||||
) => Effect.Effect<A, E, R | R2>
|
||||
readonly provide: <A, E, R>(input: LoadInput, effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceStore") {}
|
||||
@@ -44,7 +36,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
|
||||
const scope = yield* Scope.Scope
|
||||
const cache = new Map<string, Entry>()
|
||||
|
||||
const boot = <R>(input: LoadInput<R> & { directory: string }) =>
|
||||
const boot = (input: LoadInput & { directory: string }) =>
|
||||
Effect.gen(function* () {
|
||||
const ctx: InstanceContext =
|
||||
input.project && input.worktree
|
||||
@@ -61,7 +53,6 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
|
||||
})),
|
||||
)
|
||||
yield* bootstrap.run.pipe(Effect.provideService(InstanceRef, ctx))
|
||||
if (input.init) yield* input.init.pipe(Effect.provideService(InstanceRef, ctx))
|
||||
return ctx
|
||||
}).pipe(Effect.withSpan("InstanceStore.boot"))
|
||||
|
||||
@@ -72,7 +63,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
|
||||
return true
|
||||
})
|
||||
|
||||
const completeLoad = <R>(directory: string, input: LoadInput<R>, entry: Entry) =>
|
||||
const completeLoad = (directory: string, input: LoadInput, entry: Entry) =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Effect.exit(boot({ ...input, directory }))
|
||||
if (Exit.isFailure(exit)) yield* removeEntry(directory, entry)
|
||||
@@ -108,7 +99,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
|
||||
return true
|
||||
})
|
||||
|
||||
const load = <R>(input: LoadInput<R>): Effect.Effect<InstanceContext, never, R> => {
|
||||
const load = (input: LoadInput): Effect.Effect<InstanceContext> => {
|
||||
const directory = AppFileSystem.resolve(input.directory)
|
||||
return Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -126,7 +117,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
|
||||
).pipe(Effect.withSpan("InstanceStore.load"))
|
||||
}
|
||||
|
||||
const reload = <R>(input: LoadInput<R>): Effect.Effect<InstanceContext, never, R> => {
|
||||
const reload = (input: LoadInput): Effect.Effect<InstanceContext> => {
|
||||
const directory = AppFileSystem.resolve(input.directory)
|
||||
return Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -180,7 +171,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
|
||||
return yield* cachedDisposeAll
|
||||
})
|
||||
|
||||
const provide = <A, E, R, R2>(input: LoadInput<R2>, effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R | R2> =>
|
||||
const provide = <A, E, R>(input: LoadInput, effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
||||
load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx))))
|
||||
|
||||
yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore))
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { Effect } from "effect"
|
||||
import { context, type InstanceContext } from "./instance-context"
|
||||
import { InstanceRuntime } from "./instance-runtime"
|
||||
|
||||
export type { InstanceContext } from "./instance-context"
|
||||
export type { LoadInput } from "./instance-store"
|
||||
|
||||
export const Instance = {
|
||||
async provide<R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }): Promise<R> {
|
||||
const ctx = await InstanceRuntime.load({ directory: input.directory, init: input.init })
|
||||
return context.provide(ctx, async () => input.fn())
|
||||
},
|
||||
get current() {
|
||||
return context.use()
|
||||
},
|
||||
|
||||
@@ -10,6 +10,9 @@ import { BusEvent } from "@/bus/bus-event"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { which } from "../util/which"
|
||||
import { ProjectID } from "./schema"
|
||||
import { Bus } from "@/bus"
|
||||
import { Command } from "@/command"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Effect, Layer, Path, Scope, Context, Stream, Types, Schema } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
@@ -108,6 +111,12 @@ export type UpdatePayload = Types.DeepMutable<Schema.Schema.Type<typeof UpdatePa
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Interface {
|
||||
/**
|
||||
* Per-instance setup. Subscribes to the `/init` slash command for the
|
||||
* current instance and stamps the project's initialized timestamp when it
|
||||
* fires. Subscription lifetime is tied to the per-instance state scope.
|
||||
*/
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }>
|
||||
readonly discover: (input: Info) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
@@ -127,13 +136,14 @@ type GitResult = { code: number; text: string; stderr: string }
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner
|
||||
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Bus.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const pathSvc = yield* Path.Path
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const git = Effect.fnUntraced(
|
||||
function* (args: string[], opts?: { cwd?: string }) {
|
||||
@@ -417,6 +427,21 @@ export const layer: Layer.Layer<
|
||||
)
|
||||
})
|
||||
|
||||
const initState = yield* InstanceState.make(
|
||||
Effect.fn("Project.initState")(function* (ctx) {
|
||||
yield* bus.subscribe(Command.Event.Executed).pipe(
|
||||
Stream.runForEach((payload) =>
|
||||
payload.properties.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void,
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const init = Effect.fn("Project.init")(function* () {
|
||||
yield* InstanceState.get(initState)
|
||||
})
|
||||
|
||||
const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) {
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
if (!row) return []
|
||||
@@ -466,6 +491,7 @@ export const layer: Layer.Layer<
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
init,
|
||||
fromDirectory,
|
||||
discover,
|
||||
list,
|
||||
@@ -481,6 +507,7 @@ export const layer: Layer.Layer<
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Bus.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
|
||||
12
packages/opencode/src/project/with-instance.ts
Normal file
12
packages/opencode/src/project/with-instance.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { context } from "./instance-context"
|
||||
import { InstanceStore } from "./instance-store"
|
||||
|
||||
export async function provide<R>(input: { directory: string; fn: () => R }): Promise<R> {
|
||||
const ctx = await AppRuntime.runPromise(
|
||||
InstanceStore.Service.use((store) => store.load({ directory: input.directory })),
|
||||
)
|
||||
return context.provide(ctx, () => input.fn())
|
||||
}
|
||||
|
||||
export * as WithInstance from "./with-instance"
|
||||
@@ -6,6 +6,7 @@ import z from "zod"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Bus } from "@/bus"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { AsyncQueue } from "@/util/queue"
|
||||
import { Installation } from "@/installation"
|
||||
@@ -26,6 +27,7 @@ async function streamEvents(c: Context, subscribe: (q: AsyncQueue<string | null>
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
payload: {
|
||||
id: Bus.createID(),
|
||||
type: "server.connected",
|
||||
properties: {},
|
||||
},
|
||||
@@ -37,6 +39,7 @@ async function streamEvents(c: Context, subscribe: (q: AsyncQueue<string | null>
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
payload: {
|
||||
id: Bus.createID(),
|
||||
type: "server.heartbeat",
|
||||
properties: {},
|
||||
},
|
||||
|
||||
@@ -6,7 +6,11 @@ import { InstanceStore } from "@/project/instance-store"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { jsonRequest } from "./trace"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
import { Effect } from "effect"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
const log = Log.create({ service: "server.config" })
|
||||
|
||||
export const ConfigRoutes = lazy(() =>
|
||||
new Hono()
|
||||
@@ -52,15 +56,28 @@ export const ConfigRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
validator("json", Config.Info.zod),
|
||||
async (c) =>
|
||||
jsonRequest("ConfigRoutes.update", c, function* () {
|
||||
const config = c.req.valid("json")
|
||||
const cfg = yield* Config.Service
|
||||
const store = yield* InstanceStore.Service
|
||||
yield* cfg.update(config)
|
||||
yield* store.dispose(yield* InstanceState.context)
|
||||
return config
|
||||
}),
|
||||
async (c) => {
|
||||
const result = await runRequest(
|
||||
"ConfigRoutes.update",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const config = c.req.valid("json")
|
||||
const cfg = yield* Config.Service
|
||||
yield* cfg.update(config)
|
||||
return { config, ctx: yield* InstanceState.context }
|
||||
}),
|
||||
)
|
||||
const response = c.json(result.config)
|
||||
void runRequest(
|
||||
"ConfigRoutes.update.dispose",
|
||||
c,
|
||||
InstanceStore.Service.use((store) => store.dispose(result.ctx)).pipe(
|
||||
Effect.uninterruptible,
|
||||
Effect.catchCause((cause) => Effect.sync(() => log.warn("instance disposal failed", { cause }))),
|
||||
),
|
||||
)
|
||||
return response
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/providers",
|
||||
|
||||
@@ -42,6 +42,7 @@ export const EventRoutes = () =>
|
||||
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
id: Bus.createID(),
|
||||
type: "server.connected",
|
||||
properties: {},
|
||||
}),
|
||||
@@ -51,6 +52,7 @@ export const EventRoutes = () =>
|
||||
const heartbeat = setInterval(() => {
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
id: Bus.createID(),
|
||||
type: "server.heartbeat",
|
||||
properties: {},
|
||||
}),
|
||||
|
||||
@@ -19,6 +19,7 @@ import { SessionApi } from "./groups/session"
|
||||
import { SyncApi } from "./groups/sync"
|
||||
import { TuiApi } from "./groups/tui"
|
||||
import { WorkspaceApi } from "./groups/workspace"
|
||||
import { V2Api } from "./groups/v2"
|
||||
|
||||
// SSE event schemas built from the same BusEvent/SyncEvent registries that
|
||||
// the Hono spec uses, so both specs emit identical Event/SyncEvent components.
|
||||
@@ -40,6 +41,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
|
||||
.addHttpApi(ProviderApi)
|
||||
.addHttpApi(SessionApi)
|
||||
.addHttpApi(SyncApi)
|
||||
.addHttpApi(V2Api)
|
||||
.addHttpApi(TuiApi)
|
||||
.addHttpApi(WorkspaceApi)
|
||||
|
||||
|
||||
@@ -41,12 +41,12 @@ function eventResponse(bus: Bus.Interface) {
|
||||
const events = bus.subscribeAll().pipe(Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type))
|
||||
const heartbeat = Stream.tick("10 seconds").pipe(
|
||||
Stream.drop(1),
|
||||
Stream.map(() => ({ type: "server.heartbeat", properties: {} })),
|
||||
Stream.map(() => ({ id: Bus.createID(), type: "server.heartbeat", properties: {} })),
|
||||
)
|
||||
|
||||
log.info("event connected")
|
||||
return HttpServerResponse.stream(
|
||||
Stream.make({ type: "server.connected", properties: {} }).pipe(
|
||||
Stream.make({ id: Bus.createID(), type: "server.connected", properties: {} }).pipe(
|
||||
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
|
||||
Stream.map(eventData),
|
||||
Stream.pipeThroughChannel(Sse.encode()),
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
|
||||
import { MessageGroup } from "./v2/message"
|
||||
import { SessionGroup } from "./v2/session"
|
||||
|
||||
export const V2Api = HttpApi.make("v2")
|
||||
.add(SessionGroup)
|
||||
.add(MessageGroup)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { SessionMessage } from "@/v2/session-message"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../../middleware/authorization"
|
||||
|
||||
export const MessageGroup = HttpApiGroup.make("v2.message")
|
||||
.add(
|
||||
HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", {
|
||||
params: { sessionID: SessionID },
|
||||
query: Schema.Union([
|
||||
Schema.Struct({
|
||||
limit: Schema.optional(
|
||||
Schema.NumberFromString.check(
|
||||
Schema.isInt(),
|
||||
Schema.isGreaterThanOrEqualTo(1),
|
||||
Schema.isLessThanOrEqualTo(200),
|
||||
),
|
||||
).annotate({
|
||||
description:
|
||||
"Maximum number of messages to return. When omitted, the endpoint returns its default page size.",
|
||||
}),
|
||||
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
|
||||
description: "Message order for the first page. Use desc for newest first or asc for oldest first.",
|
||||
}),
|
||||
cursor: Schema.optional(Schema.Never),
|
||||
}),
|
||||
Schema.Struct({
|
||||
limit: Schema.optional(
|
||||
Schema.NumberFromString.check(
|
||||
Schema.isInt(),
|
||||
Schema.isGreaterThanOrEqualTo(1),
|
||||
Schema.isLessThanOrEqualTo(200),
|
||||
),
|
||||
).annotate({
|
||||
description:
|
||||
"Maximum number of messages to return. When omitted, the endpoint returns its default page size.",
|
||||
}),
|
||||
cursor: Schema.String.annotate({
|
||||
description:
|
||||
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.",
|
||||
}),
|
||||
order: Schema.optional(Schema.Never),
|
||||
}),
|
||||
]).annotate({ identifier: "V2SessionMessagesQuery" }),
|
||||
success: Schema.Struct({
|
||||
items: Schema.Array(SessionMessage.Message),
|
||||
cursor: Schema.Struct({
|
||||
previous: Schema.String.pipe(Schema.optional),
|
||||
next: Schema.String.pipe(Schema.optional),
|
||||
}),
|
||||
}).annotate({ identifier: "V2SessionMessagesResponse" }),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.messages",
|
||||
summary: "Get v2 session messages",
|
||||
description:
|
||||
"Retrieve projected v2 messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "v2 messages",
|
||||
description: "Experimental v2 message routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(Authorization)
|
||||
@@ -0,0 +1,140 @@
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { SessionMessage } from "@/v2/session-message"
|
||||
import { Prompt } from "@/v2/session-prompt"
|
||||
import { SessionV2 } from "@/v2/session"
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../../middleware/authorization"
|
||||
|
||||
export const SessionGroup = HttpApiGroup.make("v2.session")
|
||||
.add(
|
||||
HttpApiEndpoint.get("sessions", "/api/session", {
|
||||
query: Schema.Union([
|
||||
Schema.Struct({
|
||||
limit: Schema.optional(
|
||||
Schema.NumberFromString.check(
|
||||
Schema.isInt(),
|
||||
Schema.isGreaterThanOrEqualTo(1),
|
||||
Schema.isLessThanOrEqualTo(200),
|
||||
),
|
||||
).annotate({
|
||||
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
|
||||
}),
|
||||
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
|
||||
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
|
||||
}),
|
||||
directory: Schema.String.pipe(Schema.optional),
|
||||
path: Schema.String.pipe(Schema.optional),
|
||||
workspace: WorkspaceID.pipe(Schema.optional),
|
||||
roots: Schema.Literals(["true", "false"])
|
||||
.pipe(
|
||||
Schema.decodeTo(Schema.Boolean, {
|
||||
decode: SchemaGetter.transform((value) => value === "true"),
|
||||
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
|
||||
}),
|
||||
)
|
||||
.pipe(Schema.optional),
|
||||
start: Schema.NumberFromString.pipe(Schema.optional),
|
||||
search: Schema.String.pipe(Schema.optional),
|
||||
cursor: Schema.optional(Schema.Never),
|
||||
}),
|
||||
Schema.Struct({
|
||||
limit: Schema.optional(
|
||||
Schema.NumberFromString.check(
|
||||
Schema.isInt(),
|
||||
Schema.isGreaterThanOrEqualTo(1),
|
||||
Schema.isLessThanOrEqualTo(200),
|
||||
),
|
||||
).annotate({
|
||||
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
|
||||
}),
|
||||
cursor: Schema.String.annotate({
|
||||
description:
|
||||
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.",
|
||||
}),
|
||||
order: Schema.optional(Schema.Never),
|
||||
directory: Schema.optional(Schema.Never),
|
||||
path: Schema.optional(Schema.Never),
|
||||
workspace: Schema.optional(Schema.Never),
|
||||
roots: Schema.optional(Schema.Never),
|
||||
start: Schema.optional(Schema.Never),
|
||||
search: Schema.optional(Schema.Never),
|
||||
}),
|
||||
]).annotate({ identifier: "V2SessionsQuery" }),
|
||||
success: Schema.Struct({
|
||||
items: Schema.Array(SessionV2.Info),
|
||||
cursor: Schema.Struct({
|
||||
previous: Schema.String.pipe(Schema.optional),
|
||||
next: Schema.String.pipe(Schema.optional),
|
||||
}),
|
||||
}).annotate({ identifier: "V2SessionsResponse" }),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.list",
|
||||
summary: "List v2 sessions",
|
||||
description:
|
||||
"Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", {
|
||||
params: { sessionID: SessionID },
|
||||
payload: Schema.Struct({
|
||||
prompt: Prompt,
|
||||
delivery: SessionV2.Delivery.pipe(Schema.optional),
|
||||
}),
|
||||
success: SessionMessage.Message,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.prompt",
|
||||
summary: "Send v2 message",
|
||||
description: "Create a v2 session message and queue it for the agent loop.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", {
|
||||
params: { sessionID: SessionID },
|
||||
success: HttpApiSchema.NoContent,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.compact",
|
||||
summary: "Compact v2 session",
|
||||
description: "Compact a v2 session conversation.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", {
|
||||
params: { sessionID: SessionID },
|
||||
success: HttpApiSchema.NoContent,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.wait",
|
||||
summary: "Wait for v2 session",
|
||||
description: "Wait for a v2 session agent loop to become idle.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("context", "/api/session/:sessionID/context", {
|
||||
params: { sessionID: SessionID },
|
||||
success: Schema.Array(SessionMessage.Message),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.context",
|
||||
summary: "Get v2 session context",
|
||||
description: "Retrieve the active context messages for a v2 session (all messages after the last compaction).",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "v2",
|
||||
description: "Experimental v2 routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(Authorization)
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Config } from "@/config/config"
|
||||
import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { Bus } from "@/bus"
|
||||
import { Installation } from "@/installation"
|
||||
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
@@ -43,11 +44,11 @@ function eventResponse() {
|
||||
})
|
||||
const heartbeat = Stream.tick("10 seconds").pipe(
|
||||
Stream.drop(1),
|
||||
Stream.map(() => ({ payload: { type: "server.heartbeat", properties: {} } })),
|
||||
Stream.map(() => ({ payload: { id: Bus.createID(), type: "server.heartbeat", properties: {} } })),
|
||||
)
|
||||
|
||||
return HttpServerResponse.stream(
|
||||
Stream.make({ payload: { type: "server.connected", properties: {} } }).pipe(
|
||||
Stream.make({ payload: { id: Bus.createID(), type: "server.connected", properties: {} } }).pipe(
|
||||
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
|
||||
Stream.map(eventData),
|
||||
Stream.pipeThroughChannel(Sse.encode()),
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { SessionV2 } from "@/v2/session"
|
||||
import { Layer } from "effect"
|
||||
import { messageHandlers } from "./v2/message"
|
||||
import { sessionHandlers } from "./v2/session"
|
||||
|
||||
export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers).pipe(Layer.provide(SessionV2.defaultLayer))
|
||||
@@ -0,0 +1,60 @@
|
||||
import { SessionMessage } from "@/v2/session-message"
|
||||
import { SessionV2 } from "@/v2/session"
|
||||
import { Effect, Schema } from "effect"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../../api"
|
||||
|
||||
const DefaultMessagesLimit = 50
|
||||
|
||||
const Cursor = Schema.Struct({
|
||||
id: SessionMessage.ID,
|
||||
time: Schema.Finite,
|
||||
order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]),
|
||||
direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]),
|
||||
})
|
||||
|
||||
const decodeCursor = Schema.decodeUnknownSync(Cursor)
|
||||
|
||||
const cursor = {
|
||||
encode(message: SessionMessage.Message, order: "asc" | "desc", direction: "previous" | "next") {
|
||||
return Buffer.from(
|
||||
JSON.stringify({ id: message.id, time: DateTime.toEpochMillis(message.time.created), order, direction }),
|
||||
).toString("base64url")
|
||||
},
|
||||
decode(input: string) {
|
||||
return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8")))
|
||||
},
|
||||
}
|
||||
|
||||
export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
|
||||
return handlers.handle(
|
||||
"messages",
|
||||
Effect.fn(function* (ctx) {
|
||||
const decoded = yield* Effect.try({
|
||||
try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined),
|
||||
catch: () => new HttpApiError.BadRequest({}),
|
||||
})
|
||||
const order = decoded?.order ?? ctx.query.order ?? "desc"
|
||||
const messages = yield* session.messages({
|
||||
sessionID: ctx.params.sessionID,
|
||||
limit: ctx.query.limit ?? DefaultMessagesLimit,
|
||||
order,
|
||||
cursor: decoded ? { id: decoded.id, time: decoded.time, direction: decoded.direction } : undefined,
|
||||
})
|
||||
const first = messages[0]
|
||||
const last = messages.at(-1)
|
||||
return {
|
||||
items: messages,
|
||||
cursor: {
|
||||
previous: first ? cursor.encode(first, order, "previous") : undefined,
|
||||
next: last ? cursor.encode(last, order, "next") : undefined,
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,115 @@
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { SessionV2 } from "@/v2/session"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../../api"
|
||||
|
||||
const DefaultSessionsLimit = 50
|
||||
|
||||
const SessionCursor = Schema.Struct({
|
||||
id: SessionV2.Info.fields.id,
|
||||
time: Schema.Finite,
|
||||
order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]),
|
||||
direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]),
|
||||
directory: Schema.String.pipe(Schema.optional),
|
||||
path: Schema.String.pipe(Schema.optional),
|
||||
workspaceID: WorkspaceID.pipe(Schema.optional),
|
||||
roots: Schema.Boolean.pipe(Schema.optional),
|
||||
start: Schema.Finite.pipe(Schema.optional),
|
||||
search: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
type SessionCursor = typeof SessionCursor.Type
|
||||
|
||||
const decodeCursor = Schema.decodeUnknownSync(SessionCursor)
|
||||
|
||||
const sessionCursor = {
|
||||
encode(
|
||||
session: SessionV2.Info,
|
||||
order: "asc" | "desc",
|
||||
direction: "previous" | "next",
|
||||
filters: Pick<SessionCursor, "directory" | "path" | "workspaceID" | "roots" | "start" | "search">,
|
||||
) {
|
||||
return Buffer.from(
|
||||
JSON.stringify({ id: session.id, time: session.time.created, order, direction, ...filters }),
|
||||
).toString("base64url")
|
||||
},
|
||||
decode(input: string) {
|
||||
return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8")))
|
||||
},
|
||||
}
|
||||
|
||||
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
|
||||
return handlers
|
||||
.handle(
|
||||
"sessions",
|
||||
Effect.fn(function* (ctx) {
|
||||
const decoded = yield* Effect.try({
|
||||
try: () => (ctx.query.cursor ? sessionCursor.decode(ctx.query.cursor) : undefined),
|
||||
catch: () => new HttpApiError.BadRequest({}),
|
||||
})
|
||||
const order = decoded?.order ?? ctx.query.order ?? "desc"
|
||||
const filters = decoded ?? {
|
||||
directory: ctx.query.directory,
|
||||
path: ctx.query.path,
|
||||
workspaceID: ctx.query.workspace ? WorkspaceID.make(ctx.query.workspace) : undefined,
|
||||
roots: ctx.query.roots,
|
||||
start: ctx.query.start,
|
||||
search: ctx.query.search,
|
||||
}
|
||||
const sessions = yield* session.list({
|
||||
limit: ctx.query.limit ?? DefaultSessionsLimit,
|
||||
order,
|
||||
directory: filters.directory,
|
||||
path: filters.path,
|
||||
workspaceID: filters.workspaceID,
|
||||
roots: filters.roots,
|
||||
start: filters.start,
|
||||
search: filters.search,
|
||||
cursor: decoded ? { id: decoded.id, time: decoded.time, direction: decoded.direction } : undefined,
|
||||
})
|
||||
const first = sessions[0]
|
||||
const last = sessions.at(-1)
|
||||
return {
|
||||
items: sessions,
|
||||
cursor: {
|
||||
previous: first ? sessionCursor.encode(first, order, "previous", filters) : undefined,
|
||||
next: last ? sessionCursor.encode(last, order, "next", filters) : undefined,
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"prompt",
|
||||
Effect.fn(function* (ctx) {
|
||||
return yield* session.prompt({
|
||||
sessionID: ctx.params.sessionID,
|
||||
prompt: ctx.payload.prompt,
|
||||
delivery: ctx.payload.delivery ?? SessionV2.DefaultDelivery,
|
||||
})
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"compact",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* session.compact(ctx.params.sessionID)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"wait",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* session.wait(ctx.params.sessionID)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"context",
|
||||
Effect.fn(function* (ctx) {
|
||||
return yield* session.context(ctx.params.sessionID)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -18,7 +18,7 @@ import { LSP } from "@/lsp/lsp"
|
||||
import { MCP } from "@/mcp"
|
||||
import { Permission } from "@/permission"
|
||||
import { Installation } from "@/installation"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { InstanceLayer } from "@/project/instance-layer"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Project } from "@/project/project"
|
||||
import { ProviderAuth } from "@/provider/auth"
|
||||
@@ -64,6 +64,7 @@ import { questionHandlers } from "./handlers/question"
|
||||
import { sessionHandlers } from "./handlers/session"
|
||||
import { syncHandlers } from "./handlers/sync"
|
||||
import { tuiHandlers } from "./handlers/tui"
|
||||
import { v2Handlers } from "./handlers/v2"
|
||||
import { workspaceHandlers } from "./handlers/workspace"
|
||||
import { instanceContextLayer, instanceRouterMiddleware } from "./middleware/instance-context"
|
||||
import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/workspace-routing"
|
||||
@@ -115,6 +116,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
|
||||
providerHandlers,
|
||||
sessionHandlers,
|
||||
syncHandlers,
|
||||
v2Handlers,
|
||||
tuiHandlers,
|
||||
workspaceHandlers,
|
||||
]),
|
||||
@@ -152,7 +154,6 @@ export function createRoutes(corsOptions?: CorsOptions) {
|
||||
Format.defaultLayer,
|
||||
LSP.defaultLayer,
|
||||
Installation.defaultLayer,
|
||||
InstanceRuntime.layer,
|
||||
MCP.defaultLayer,
|
||||
ModelsDev.defaultLayer,
|
||||
Permission.defaultLayer,
|
||||
@@ -179,12 +180,13 @@ export function createRoutes(corsOptions?: CorsOptions) {
|
||||
ToolRegistry.defaultLayer,
|
||||
Vcs.defaultLayer,
|
||||
Workspace.defaultLayer,
|
||||
Worktree.defaultLayer,
|
||||
Worktree.appLayer,
|
||||
Bus.layer,
|
||||
AppFileSystem.defaultLayer,
|
||||
FetchHttpClient.layer,
|
||||
HttpServer.layerServices,
|
||||
]),
|
||||
Layer.provideMerge(InstanceLayer.layer),
|
||||
Layer.provideMerge(Observability.layer),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import { Hono } from "hono"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import { Effect } from "effect"
|
||||
import { Context, Effect } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import z from "zod"
|
||||
import { Format } from "@/format"
|
||||
import { TuiRoutes } from "./tui"
|
||||
@@ -25,10 +26,135 @@ import { ExperimentalRoutes } from "./experimental"
|
||||
import { ProviderRoutes } from "./provider"
|
||||
import { EventRoutes } from "./event"
|
||||
import { SyncRoutes } from "./sync"
|
||||
import { InstanceMiddleware } from "./middleware"
|
||||
import { jsonRequest } from "./trace"
|
||||
import { ExperimentalHttpApiServer } from "./httpapi/server"
|
||||
import { EventPaths } from "./httpapi/event"
|
||||
import { ExperimentalPaths } from "./httpapi/groups/experimental"
|
||||
import { FilePaths } from "./httpapi/groups/file"
|
||||
import { InstancePaths } from "./httpapi/groups/instance"
|
||||
import { McpPaths } from "./httpapi/groups/mcp"
|
||||
import { PtyPaths } from "./httpapi/groups/pty"
|
||||
import { SessionPaths } from "./httpapi/groups/session"
|
||||
import { SyncPaths } from "./httpapi/groups/sync"
|
||||
import { TuiPaths } from "./httpapi/groups/tui"
|
||||
import { WorkspacePaths } from "./httpapi/groups/workspace"
|
||||
|
||||
export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
||||
const app = new Hono()
|
||||
const handler = ExperimentalHttpApiServer.webHandler().handler
|
||||
const context = Context.empty() as Context.Context<unknown>
|
||||
|
||||
app.all("/api/*", (c) => handler(c.req.raw, context))
|
||||
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
|
||||
app.get(EventPaths.event, (c) => handler(c.req.raw, context))
|
||||
app.get("/question", (c) => handler(c.req.raw, context))
|
||||
app.post("/question/:requestID/reply", (c) => handler(c.req.raw, context))
|
||||
app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context))
|
||||
app.get("/permission", (c) => handler(c.req.raw, context))
|
||||
app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context))
|
||||
app.get("/config", (c) => handler(c.req.raw, context))
|
||||
app.patch("/config", (c) => handler(c.req.raw, context))
|
||||
app.get("/config/providers", (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.console, (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.consoleOrgs, (c) => handler(c.req.raw, context))
|
||||
app.post(ExperimentalPaths.consoleSwitch, (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.tool, (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.toolIDs, (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
|
||||
app.post(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
|
||||
app.delete(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
|
||||
app.post(ExperimentalPaths.worktreeReset, (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.session, (c) => handler(c.req.raw, context))
|
||||
app.get(ExperimentalPaths.resource, (c) => handler(c.req.raw, context))
|
||||
app.get("/provider", (c) => handler(c.req.raw, context))
|
||||
app.get("/provider/auth", (c) => handler(c.req.raw, context))
|
||||
app.post("/provider/:providerID/oauth/authorize", (c) => handler(c.req.raw, context))
|
||||
app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context))
|
||||
app.get("/project", (c) => handler(c.req.raw, context))
|
||||
app.get("/project/current", (c) => handler(c.req.raw, context))
|
||||
app.post("/project/git/init", (c) => handler(c.req.raw, context))
|
||||
app.patch("/project/:projectID", (c) => handler(c.req.raw, context))
|
||||
app.get(FilePaths.findText, (c) => handler(c.req.raw, context))
|
||||
app.get(FilePaths.findFile, (c) => handler(c.req.raw, context))
|
||||
app.get(FilePaths.findSymbol, (c) => handler(c.req.raw, context))
|
||||
app.get(FilePaths.list, (c) => handler(c.req.raw, context))
|
||||
app.get(FilePaths.content, (c) => handler(c.req.raw, context))
|
||||
app.get(FilePaths.status, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.path, (c) => handler(c.req.raw, context))
|
||||
app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.command, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.agent, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.skill, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.lsp, (c) => handler(c.req.raw, context))
|
||||
app.get(InstancePaths.formatter, (c) => handler(c.req.raw, context))
|
||||
app.get(McpPaths.status, (c) => handler(c.req.raw, context))
|
||||
app.post(McpPaths.status, (c) => handler(c.req.raw, context))
|
||||
app.post(McpPaths.auth, (c) => handler(c.req.raw, context))
|
||||
app.post(McpPaths.authCallback, (c) => handler(c.req.raw, context))
|
||||
app.post(McpPaths.authAuthenticate, (c) => handler(c.req.raw, context))
|
||||
app.delete(McpPaths.auth, (c) => handler(c.req.raw, context))
|
||||
app.post(McpPaths.connect, (c) => handler(c.req.raw, context))
|
||||
app.post(McpPaths.disconnect, (c) => handler(c.req.raw, context))
|
||||
app.post(SyncPaths.start, (c) => handler(c.req.raw, context))
|
||||
app.post(SyncPaths.replay, (c) => handler(c.req.raw, context))
|
||||
app.post(SyncPaths.history, (c) => handler(c.req.raw, context))
|
||||
app.get(PtyPaths.list, (c) => handler(c.req.raw, context))
|
||||
app.post(PtyPaths.create, (c) => handler(c.req.raw, context))
|
||||
app.get(PtyPaths.get, (c) => handler(c.req.raw, context))
|
||||
app.put(PtyPaths.update, (c) => handler(c.req.raw, context))
|
||||
app.delete(PtyPaths.remove, (c) => handler(c.req.raw, context))
|
||||
app.get(PtyPaths.connect, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.list, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.status, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.get, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.children, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.todo, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.diff, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.messages, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.message, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.create, (c) => handler(c.req.raw, context))
|
||||
app.delete(SessionPaths.remove, (c) => handler(c.req.raw, context))
|
||||
app.patch(SessionPaths.update, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.init, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.fork, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.abort, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.share, (c) => handler(c.req.raw, context))
|
||||
app.delete(SessionPaths.share, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.summarize, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.prompt, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.promptAsync, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.command, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.shell, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.revert, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.unrevert, (c) => handler(c.req.raw, context))
|
||||
app.post(SessionPaths.permissions, (c) => handler(c.req.raw, context))
|
||||
app.delete(SessionPaths.deleteMessage, (c) => handler(c.req.raw, context))
|
||||
app.delete(SessionPaths.deletePart, (c) => handler(c.req.raw, context))
|
||||
app.patch(SessionPaths.updatePart, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.appendPrompt, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.openHelp, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.openSessions, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.openThemes, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.openModels, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.submitPrompt, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.clearPrompt, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.executeCommand, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.showToast, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.publish, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.selectSession, (c) => handler(c.req.raw, context))
|
||||
app.get(TuiPaths.controlNext, (c) => handler(c.req.raw, context))
|
||||
app.post(TuiPaths.controlResponse, (c) => handler(c.req.raw, context))
|
||||
app.get(WorkspacePaths.adapters, (c) => handler(c.req.raw, context))
|
||||
app.post(WorkspacePaths.list, (c) => handler(c.req.raw, context))
|
||||
app.get(WorkspacePaths.list, (c) => handler(c.req.raw, context))
|
||||
app.get(WorkspacePaths.status, (c) => handler(c.req.raw, context))
|
||||
app.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context))
|
||||
app.post(WorkspacePaths.sessionRestore, (c) => handler(c.req.raw, context))
|
||||
}
|
||||
|
||||
return app
|
||||
.route("/project", ProjectRoutes())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { WithInstance } from "@/project/with-instance"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
@@ -20,7 +20,7 @@ export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler
|
||||
return WorkspaceContext.provide({
|
||||
workspaceID,
|
||||
async fn() {
|
||||
return Instance.provide({
|
||||
return WithInstance.provide({
|
||||
directory,
|
||||
async fn() {
|
||||
return next()
|
||||
|
||||
@@ -26,13 +26,17 @@ export function nextTuiRequest() {
|
||||
return request.next()
|
||||
}
|
||||
|
||||
export function submitTuiRequest(body: TuiRequest) {
|
||||
request.push(body)
|
||||
}
|
||||
|
||||
export function submitTuiResponse(body: unknown) {
|
||||
response.push(body)
|
||||
}
|
||||
|
||||
export async function callTui(ctx: Context) {
|
||||
const body = await ctx.req.json()
|
||||
request.push({
|
||||
submitTuiRequest({
|
||||
path: ctx.req.path,
|
||||
body,
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { WithInstance } from "@/project/with-instance"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { Effect } from "effect"
|
||||
@@ -97,7 +97,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||
return WorkspaceContext.provide({
|
||||
workspaceID: WorkspaceID.make(workspaceID),
|
||||
fn: () =>
|
||||
Instance.provide({
|
||||
WithInstance.provide({
|
||||
directory: target.directory,
|
||||
async fn() {
|
||||
return next()
|
||||
|
||||
@@ -14,10 +14,13 @@ import { Config } from "@/config/config"
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { isOverflow as overflow, usable } from "./overflow"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { fn } from "@/util/fn"
|
||||
import { EventV2 } from "@/v2/event"
|
||||
import { SessionEvent } from "@/v2/session-event"
|
||||
|
||||
const log = Log.create({ service: "session.compaction" })
|
||||
|
||||
@@ -556,7 +559,21 @@ export const layer: Layer.Layer<
|
||||
}
|
||||
|
||||
if (processor.message.error) return "stop"
|
||||
if (result === "continue") yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
|
||||
if (result === "continue") {
|
||||
const summary = summaryText(
|
||||
(yield* session.messages({ sessionID: input.sessionID })).find((item) => item.info.id === msg.id) ?? {
|
||||
info: msg,
|
||||
parts: [],
|
||||
},
|
||||
)
|
||||
EventV2.run(SessionEvent.Compaction.Ended.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
text: summary ?? "",
|
||||
include: selected.tail_start_id,
|
||||
})
|
||||
yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -583,6 +600,11 @@ export const layer: Layer.Layer<
|
||||
auto: input.auto,
|
||||
overflow: input.overflow,
|
||||
})
|
||||
EventV2.run(SessionEvent.Compaction.Started.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
reason: input.auto ? "auto" : "manual",
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
|
||||
@@ -20,6 +20,9 @@ import { Question } from "@/question"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { EventV2 } from "@/v2/event"
|
||||
import { SessionEvent } from "@/v2/session-event"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
|
||||
const DOOM_LOOP_THRESHOLD = 3
|
||||
const log = Log.create({ service: "session.processor" })
|
||||
@@ -221,6 +224,12 @@ export const layer: Layer.Layer<
|
||||
|
||||
case "reasoning-start":
|
||||
if (value.id in ctx.reasoningMap) return
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Reasoning.Started.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
reasoningID: value.id,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
ctx.reasoningMap[value.id] = {
|
||||
id: PartID.ascending(),
|
||||
messageID: ctx.assistantMessage.id,
|
||||
@@ -248,6 +257,13 @@ export const layer: Layer.Layer<
|
||||
|
||||
case "reasoning-end":
|
||||
if (!(value.id in ctx.reasoningMap)) return
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Reasoning.Ended.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
reasoningID: value.id,
|
||||
text: ctx.reasoningMap[value.id].text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
// oxlint-disable-next-line no-self-assign -- reactivity trigger
|
||||
ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text
|
||||
ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() }
|
||||
@@ -260,6 +276,13 @@ export const layer: Layer.Layer<
|
||||
if (ctx.assistantMessage.summary) {
|
||||
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
|
||||
}
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Tool.Input.Started.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.id,
|
||||
name: value.toolName,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
const part = yield* session.updatePart({
|
||||
id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(),
|
||||
messageID: ctx.assistantMessage.id,
|
||||
@@ -281,13 +304,34 @@ export const layer: Layer.Layer<
|
||||
case "tool-input-delta":
|
||||
return
|
||||
|
||||
case "tool-input-end":
|
||||
case "tool-input-end": {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Tool.Input.Ended.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.id,
|
||||
text: "",
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
case "tool-call": {
|
||||
if (ctx.assistantMessage.summary) {
|
||||
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
|
||||
}
|
||||
const toolCall = yield* readToolCall(value.toolCallId)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Tool.Called.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.toolCallId,
|
||||
tool: value.toolName,
|
||||
input: value.input,
|
||||
provider: {
|
||||
executed: toolCall?.part.metadata?.providerExecuted === true,
|
||||
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
yield* updateToolCall(value.toolCallId, (match) => ({
|
||||
...match,
|
||||
tool: value.toolName,
|
||||
@@ -331,11 +375,48 @@ export const layer: Layer.Layer<
|
||||
}
|
||||
|
||||
case "tool-result": {
|
||||
const toolCall = yield* readToolCall(value.toolCallId)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Tool.Success.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.toolCallId,
|
||||
structured: value.output.metadata,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: value.output.output,
|
||||
},
|
||||
...(value.output.attachments?.map((item: MessageV2.FilePart) => ({
|
||||
type: "file",
|
||||
uri: item.url,
|
||||
mime: item.mime,
|
||||
name: item.filename,
|
||||
})) ?? []),
|
||||
],
|
||||
provider: {
|
||||
executed: toolCall?.part.metadata?.providerExecuted === true,
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
yield* completeToolCall(value.toolCallId, value.output)
|
||||
return
|
||||
}
|
||||
|
||||
case "tool-error": {
|
||||
const toolCall = yield* readToolCall(value.toolCallId)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Tool.Error.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.toolCallId,
|
||||
error: {
|
||||
type: "unknown",
|
||||
message: errorMessage(value.error),
|
||||
},
|
||||
provider: {
|
||||
executed: toolCall?.part.metadata?.providerExecuted === true,
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
yield* failToolCall(value.toolCallId, value.error)
|
||||
return
|
||||
}
|
||||
@@ -345,6 +426,20 @@ export const layer: Layer.Layer<
|
||||
|
||||
case "start-step":
|
||||
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Step.Started.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
agent: input.assistantMessage.agent,
|
||||
model: {
|
||||
id: ctx.model.id,
|
||||
providerID: ctx.model.providerID,
|
||||
variant: input.assistantMessage.variant,
|
||||
},
|
||||
snapshot: ctx.snapshot,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: ctx.assistantMessage.id,
|
||||
@@ -355,18 +450,30 @@ export const layer: Layer.Layer<
|
||||
return
|
||||
|
||||
case "finish-step": {
|
||||
const completedSnapshot = yield* snapshot.track()
|
||||
const usage = Session.getUsage({
|
||||
model: ctx.model,
|
||||
usage: value.usage,
|
||||
metadata: value.providerMetadata,
|
||||
})
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Step.Ended.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
finish: value.finishReason,
|
||||
cost: usage.cost,
|
||||
tokens: usage.tokens,
|
||||
snapshot: completedSnapshot,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
ctx.assistantMessage.finish = value.finishReason
|
||||
ctx.assistantMessage.cost += usage.cost
|
||||
ctx.assistantMessage.tokens = usage.tokens
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
reason: value.finishReason,
|
||||
snapshot: yield* snapshot.track(),
|
||||
snapshot: completedSnapshot,
|
||||
messageID: ctx.assistantMessage.id,
|
||||
sessionID: ctx.assistantMessage.sessionID,
|
||||
type: "step-finish",
|
||||
@@ -404,6 +511,13 @@ export const layer: Layer.Layer<
|
||||
}
|
||||
|
||||
case "text-start":
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Text.Started.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
ctx.currentText = {
|
||||
id: PartID.ascending(),
|
||||
messageID: ctx.assistantMessage.id,
|
||||
@@ -442,6 +556,14 @@ export const layer: Layer.Layer<
|
||||
},
|
||||
{ text: ctx.currentText.text },
|
||||
)).text
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Text.Ended.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
text: ctx.currentText.text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
{
|
||||
const end = Date.now()
|
||||
ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }
|
||||
@@ -568,13 +690,24 @@ export const layer: Layer.Layer<
|
||||
Effect.retry(
|
||||
SessionRetry.policy({
|
||||
parse,
|
||||
set: (info) =>
|
||||
status.set(ctx.sessionID, {
|
||||
set: (info) => {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Retried.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
attempt: info.attempt,
|
||||
error: {
|
||||
message: info.message,
|
||||
isRetryable: true,
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
return status.set(ctx.sessionID, {
|
||||
type: "retry",
|
||||
attempt: info.attempt,
|
||||
message: info.message,
|
||||
next: info.next,
|
||||
}),
|
||||
})
|
||||
},
|
||||
}),
|
||||
),
|
||||
Effect.catch(halt),
|
||||
|
||||
204
packages/opencode/src/session/projectors-next.ts
Normal file
204
packages/opencode/src/session/projectors-next.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { and, desc, eq } from "@/storage/db"
|
||||
import type { Database } from "@/storage/db"
|
||||
import { SessionMessage } from "@/v2/session-message"
|
||||
import { SessionMessageUpdater } from "@/v2/session-message-updater"
|
||||
import { SessionEvent } from "@/v2/session-event"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { SessionMessageTable, SessionTable } from "./session.sql"
|
||||
import type { SessionID } from "./schema"
|
||||
import { Schema } from "effect"
|
||||
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
|
||||
type SessionMessageData = NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>
|
||||
|
||||
function encodeDateTimes(value: unknown): unknown {
|
||||
if (DateTime.isDateTime(value)) return DateTime.toEpochMillis(value)
|
||||
if (Array.isArray(value)) return value.map(encodeDateTimes)
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, encodeDateTimes(item)]))
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function encodeMessageData(value: unknown): SessionMessageData {
|
||||
return encodeDateTimes(value) as SessionMessageData
|
||||
}
|
||||
|
||||
function sqlite(db: Database.TxOrDb, sessionID: SessionID): SessionMessageUpdater.Adapter<void> {
|
||||
return {
|
||||
getCurrentAssistant() {
|
||||
return db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "assistant")))
|
||||
.orderBy(desc(SessionMessageTable.id))
|
||||
.all()
|
||||
.map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type }))
|
||||
.find((message): message is SessionMessage.Assistant => message.type === "assistant" && !message.time.completed)
|
||||
},
|
||||
getCurrentCompaction() {
|
||||
return db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
|
||||
.orderBy(desc(SessionMessageTable.id))
|
||||
.all()
|
||||
.map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type }))
|
||||
.find((message): message is SessionMessage.Compaction => message.type === "compaction")
|
||||
},
|
||||
getCurrentShell(callID) {
|
||||
return db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "shell")))
|
||||
.orderBy(desc(SessionMessageTable.id))
|
||||
.all()
|
||||
.map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type }))
|
||||
.find((message): message is SessionMessage.Shell => message.type === "shell" && message.callID === callID)
|
||||
},
|
||||
updateAssistant(assistant) {
|
||||
const { id, type, ...data } = assistant
|
||||
db.update(SessionMessageTable)
|
||||
.set({ data: encodeMessageData(data) })
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.id, id),
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
eq(SessionMessageTable.type, type),
|
||||
),
|
||||
)
|
||||
.run()
|
||||
},
|
||||
updateCompaction(compaction) {
|
||||
const { id, type, ...data } = compaction
|
||||
db.update(SessionMessageTable)
|
||||
.set({ data: encodeMessageData(data) })
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.id, id),
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
eq(SessionMessageTable.type, type),
|
||||
),
|
||||
)
|
||||
.run()
|
||||
},
|
||||
updateShell(shell) {
|
||||
const { id, type, ...data } = shell
|
||||
db.update(SessionMessageTable)
|
||||
.set({ data: encodeMessageData(data) })
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.id, id),
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
eq(SessionMessageTable.type, type),
|
||||
),
|
||||
)
|
||||
.run()
|
||||
},
|
||||
appendMessage(message) {
|
||||
const { id, type, ...data } = message
|
||||
db.insert(SessionMessageTable)
|
||||
.values([
|
||||
{
|
||||
id,
|
||||
session_id: sessionID,
|
||||
type,
|
||||
time_created: DateTime.toEpochMillis(message.time.created),
|
||||
data: encodeMessageData(data),
|
||||
},
|
||||
])
|
||||
.run()
|
||||
},
|
||||
finish() {},
|
||||
}
|
||||
}
|
||||
|
||||
function update(db: Database.TxOrDb, event: SessionEvent.Event) {
|
||||
SessionMessageUpdater.update(sqlite(db, event.data.sessionID), event)
|
||||
}
|
||||
|
||||
export default [
|
||||
SyncEvent.project(SessionEvent.AgentSwitched.Sync, (db, data, event) => {
|
||||
db.update(SessionTable)
|
||||
.set({
|
||||
agent: data.agent,
|
||||
time_updated: DateTime.toEpochMillis(data.timestamp),
|
||||
})
|
||||
.where(eq(SessionTable.id, data.sessionID))
|
||||
.run()
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.agent.switched", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.ModelSwitched.Sync, (db, data, event) => {
|
||||
db.update(SessionTable)
|
||||
.set({
|
||||
model: {
|
||||
id: data.id,
|
||||
providerID: data.providerID,
|
||||
variant: data.variant,
|
||||
},
|
||||
time_updated: DateTime.toEpochMillis(data.timestamp),
|
||||
})
|
||||
.where(eq(SessionTable.id, data.sessionID))
|
||||
.run()
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.model.switched", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Prompted.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.prompted", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Synthetic.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.synthetic", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Shell.Started.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.shell.started", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Shell.Ended.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.shell.ended", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Step.Started.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.started", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Step.Ended.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.ended", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Text.Started.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.started", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Text.Delta.Sync, () => {}),
|
||||
SyncEvent.project(SessionEvent.Text.Ended.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.ended", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Tool.Input.Started.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.input.started", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Tool.Input.Delta.Sync, () => {}),
|
||||
SyncEvent.project(SessionEvent.Tool.Input.Ended.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.input.ended", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Tool.Called.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.called", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Tool.Success.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.success", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Tool.Error.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.error", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Reasoning.Started.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.started", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Reasoning.Delta.Sync, () => {}),
|
||||
SyncEvent.project(SessionEvent.Reasoning.Ended.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.ended", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Retried.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.retried", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Compaction.Started.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.compaction.started", data })
|
||||
}),
|
||||
SyncEvent.project(SessionEvent.Compaction.Delta.Sync, () => {}),
|
||||
SyncEvent.project(SessionEvent.Compaction.Ended.Sync, (db, data, event) => {
|
||||
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.compaction.ended", data })
|
||||
}),
|
||||
]
|
||||
@@ -5,7 +5,8 @@ import { SyncEvent } from "@/sync"
|
||||
import * as Session from "./session"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { SessionTable, MessageTable, PartTable } from "./session.sql"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Log } from "@opencode-ai/core/util/log"
|
||||
import nextProjectors from "./projectors-next"
|
||||
|
||||
const log = Log.create({ service: "session.projector" })
|
||||
|
||||
@@ -136,4 +137,6 @@ export default [
|
||||
log.warn("ignored late part update", { partID: id, messageID, sessionID })
|
||||
}
|
||||
}),
|
||||
|
||||
...nextProjectors,
|
||||
]
|
||||
|
||||
@@ -54,6 +54,13 @@ import { InstanceState } from "@/effect/instance-state"
|
||||
import { TaskTool, type TaskPromptOps } from "@/tool/task"
|
||||
import { SessionRunState } from "./run-state"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { EventV2 } from "@/v2/event"
|
||||
import { SessionEvent } from "@/v2/session-event"
|
||||
import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { eq } from "@/storage/db"
|
||||
import * as Database from "@/storage/db"
|
||||
import { SessionTable } from "./session.sql"
|
||||
|
||||
// @ts-ignore
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
@@ -785,6 +792,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
providerID: model.providerID,
|
||||
}
|
||||
yield* sessions.updateMessage(msg)
|
||||
const callID = ulid()
|
||||
const started = Date.now()
|
||||
const part: MessageV2.ToolPart = {
|
||||
type: "tool",
|
||||
id: PartID.ascending(),
|
||||
@@ -794,11 +803,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
callID: ulid(),
|
||||
state: {
|
||||
status: "running",
|
||||
time: { start: Date.now() },
|
||||
time: { start: started },
|
||||
input: { command: input.command },
|
||||
},
|
||||
}
|
||||
yield* sessions.updatePart(part)
|
||||
EventV2.run(SessionEvent.Shell.Started.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(started),
|
||||
callID,
|
||||
command: input.command,
|
||||
})
|
||||
return { msg, part, cwd: ctx.directory }
|
||||
}).pipe(Effect.ensuring(markReady))
|
||||
|
||||
@@ -813,14 +828,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
if (aborted) {
|
||||
output += "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n")
|
||||
}
|
||||
const completed = Date.now()
|
||||
EventV2.run(SessionEvent.Shell.Ended.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(completed),
|
||||
callID: part.callID,
|
||||
output,
|
||||
})
|
||||
if (!msg.time.completed) {
|
||||
msg.time.completed = Date.now()
|
||||
msg.time.completed = completed
|
||||
yield* sessions.updateMessage(msg)
|
||||
}
|
||||
if (part.state.status === "running") {
|
||||
part.state = {
|
||||
status: "completed",
|
||||
time: { ...part.state.time, end: Date.now() },
|
||||
time: { ...part.state.time, end: completed },
|
||||
input: part.state.input,
|
||||
title: "",
|
||||
metadata: { output, description: "" },
|
||||
@@ -934,6 +956,34 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
format: input.format,
|
||||
}
|
||||
|
||||
const current = Database.use((db) =>
|
||||
db
|
||||
.select({ agent: SessionTable.agent, model: SessionTable.model })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.get(),
|
||||
)
|
||||
if (current?.agent !== info.agent) {
|
||||
EventV2.run(SessionEvent.AgentSwitched.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
agent: info.agent,
|
||||
})
|
||||
}
|
||||
if (
|
||||
current?.model?.providerID !== info.model.providerID ||
|
||||
current.model.id !== info.model.modelID ||
|
||||
current.model.variant !== info.model.variant
|
||||
) {
|
||||
EventV2.run(SessionEvent.ModelSwitched.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
id: info.model.modelID,
|
||||
providerID: info.model.providerID,
|
||||
variant: info.model.variant,
|
||||
})
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() => instruction.clear(info.id))
|
||||
|
||||
type Draft<T> = T extends MessageV2.Part ? Omit<T, "id"> & { id?: string } : never
|
||||
@@ -1250,6 +1300,69 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
|
||||
yield* sessions.updateMessage(info)
|
||||
for (const part of parts) yield* sessions.updatePart(part)
|
||||
const nextPrompt = parts.reduce(
|
||||
(result, part) => {
|
||||
if (part.type === "text") {
|
||||
if (part.synthetic) result.synthetic.push(part.text)
|
||||
else result.text.push(part.text)
|
||||
}
|
||||
if (part.type === "file") {
|
||||
result.files.push(
|
||||
new FileAttachment({
|
||||
uri: part.url,
|
||||
mime: part.mime,
|
||||
name: part.filename,
|
||||
source: part.source
|
||||
? new Source({
|
||||
start: part.source.text.start,
|
||||
end: part.source.text.end,
|
||||
text: part.source.text.value,
|
||||
})
|
||||
: undefined,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (part.type === "agent") {
|
||||
result.agents.push(
|
||||
new AgentAttachment({
|
||||
name: part.name,
|
||||
source: part.source
|
||||
? new Source({
|
||||
start: part.source.start,
|
||||
end: part.source.end,
|
||||
text: part.source.value,
|
||||
})
|
||||
: undefined,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return result
|
||||
},
|
||||
{
|
||||
text: [] as string[],
|
||||
files: [] as FileAttachment[],
|
||||
agents: [] as AgentAttachment[],
|
||||
synthetic: [] as string[],
|
||||
},
|
||||
)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Prompted.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
prompt: {
|
||||
text: nextPrompt.text.join("\n"),
|
||||
files: nextPrompt.files,
|
||||
agents: nextPrompt.agents,
|
||||
},
|
||||
})
|
||||
for (const text of nextPrompt.synthetic) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Synthetic.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
text,
|
||||
})
|
||||
}
|
||||
|
||||
return { info, parts }
|
||||
}, Effect.scoped)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import type { MessageV2 } from "./message-v2"
|
||||
import type { SessionEntry } from "../v2/session-entry"
|
||||
import type { SessionMessage } from "../v2/session-message"
|
||||
import type { Snapshot } from "../snapshot"
|
||||
import type { Permission } from "../permission"
|
||||
import type { ProjectID } from "../project/schema"
|
||||
@@ -11,6 +11,7 @@ import { Timestamps } from "../storage/schema.sql"
|
||||
|
||||
type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID">
|
||||
type InfoData = Omit<MessageV2.Info, "id" | "sessionID">
|
||||
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
|
||||
|
||||
export const SessionTable = sqliteTable(
|
||||
"session",
|
||||
@@ -34,6 +35,12 @@ export const SessionTable = sqliteTable(
|
||||
summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
|
||||
revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
|
||||
permission: text({ mode: "json" }).$type<Permission.Ruleset>(),
|
||||
agent: text(),
|
||||
model: text({ mode: "json" }).$type<{
|
||||
id: string
|
||||
providerID: string
|
||||
variant?: string
|
||||
}>(),
|
||||
...Timestamps,
|
||||
time_compacting: integer(),
|
||||
time_archived: integer(),
|
||||
@@ -96,22 +103,22 @@ export const TodoTable = sqliteTable(
|
||||
],
|
||||
)
|
||||
|
||||
export const SessionEntryTable = sqliteTable(
|
||||
"session_entry",
|
||||
export const SessionMessageTable = sqliteTable(
|
||||
"session_message",
|
||||
{
|
||||
id: text().$type<SessionEntry.ID>().primaryKey(),
|
||||
id: text().$type<SessionMessage.ID>().primaryKey(),
|
||||
session_id: text()
|
||||
.$type<SessionID>()
|
||||
.notNull()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
type: text().$type<SessionEntry.Type>().notNull(),
|
||||
type: text().$type<SessionMessage.Type>().notNull(),
|
||||
...Timestamps,
|
||||
data: text({ mode: "json" }).notNull().$type<Omit<SessionEntry.Entry, "type" | "id">>(),
|
||||
data: text({ mode: "json" }).notNull().$type<SessionMessageData>(),
|
||||
},
|
||||
(table) => [
|
||||
index("session_entry_session_idx").on(table.session_id),
|
||||
index("session_entry_session_type_idx").on(table.session_id, table.type),
|
||||
index("session_entry_time_created_idx").on(table.time_created),
|
||||
index("session_message_session_idx").on(table.session_id),
|
||||
index("session_message_session_type_idx").on(table.session_id, table.type),
|
||||
index("session_message_time_created_idx").on(table.time_created),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import { Snapshot } from "@/snapshot"
|
||||
import { ProjectID } from "../project/schema"
|
||||
import { WorkspaceID } from "../control-plane/schema"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { Permission } from "@/permission"
|
||||
@@ -78,6 +79,14 @@ export function fromRow(row: SessionRow): Info {
|
||||
path: row.path ?? undefined,
|
||||
parentID: row.parent_id ?? undefined,
|
||||
title: row.title,
|
||||
agent: row.agent ?? undefined,
|
||||
model: row.model
|
||||
? {
|
||||
id: ModelID.make(row.model.id),
|
||||
providerID: ProviderID.make(row.model.providerID),
|
||||
variant: row.model.variant,
|
||||
}
|
||||
: undefined,
|
||||
version: row.version,
|
||||
summary,
|
||||
share,
|
||||
@@ -102,6 +111,8 @@ export function toRow(info: Info) {
|
||||
directory: info.directory,
|
||||
path: info.path,
|
||||
title: info.title,
|
||||
agent: info.agent,
|
||||
model: info.model,
|
||||
version: info.version,
|
||||
share_url: info.share?.url,
|
||||
summary_additions: info.summary?.additions,
|
||||
@@ -160,6 +171,12 @@ const Revert = Schema.Struct({
|
||||
diff: optionalOmitUndefined(Schema.String),
|
||||
})
|
||||
|
||||
const Model = Schema.Struct({
|
||||
id: ModelID,
|
||||
providerID: ProviderID,
|
||||
variant: optionalOmitUndefined(Schema.String),
|
||||
})
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
id: SessionID,
|
||||
slug: Schema.String,
|
||||
@@ -171,6 +188,8 @@ export const Info = Schema.Struct({
|
||||
summary: optionalOmitUndefined(Summary),
|
||||
share: optionalOmitUndefined(Share),
|
||||
title: Schema.String,
|
||||
agent: optionalOmitUndefined(Schema.String),
|
||||
model: optionalOmitUndefined(Model),
|
||||
version: Schema.String,
|
||||
time: Time,
|
||||
permission: optionalOmitUndefined(Permission.Ruleset),
|
||||
@@ -201,6 +220,8 @@ export const CreateInput = Schema.optional(
|
||||
Schema.Struct({
|
||||
parentID: Schema.optional(SessionID),
|
||||
title: Schema.optional(Schema.String),
|
||||
agent: Schema.optional(Schema.String),
|
||||
model: Schema.optional(Model),
|
||||
permission: Schema.optional(Permission.Ruleset),
|
||||
workspaceID: Schema.optional(WorkspaceID),
|
||||
}),
|
||||
@@ -272,6 +293,8 @@ const UpdatedInfo = Schema.Struct({
|
||||
summary: Schema.optional(Schema.NullOr(Summary)),
|
||||
share: Schema.optional(UpdatedShare),
|
||||
title: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
agent: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
model: Schema.optional(Schema.NullOr(Model)),
|
||||
version: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
time: Schema.optional(UpdatedTime),
|
||||
permission: Schema.optional(Schema.NullOr(Permission.Ruleset)),
|
||||
@@ -404,6 +427,8 @@ export interface Interface {
|
||||
readonly create: (input?: {
|
||||
parentID?: SessionID
|
||||
title?: string
|
||||
agent?: string
|
||||
model?: Schema.Schema.Type<typeof Model>
|
||||
permission?: Permission.Ruleset
|
||||
workspaceID?: WorkspaceID
|
||||
}) => Effect.Effect<Info>
|
||||
@@ -464,6 +489,8 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
|
||||
const createNext = Effect.fn("Session.createNext")(function* (input: {
|
||||
id?: SessionID
|
||||
title?: string
|
||||
agent?: string
|
||||
model?: Schema.Schema.Type<typeof Model>
|
||||
parentID?: SessionID
|
||||
workspaceID?: WorkspaceID
|
||||
directory: string
|
||||
@@ -481,6 +508,8 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
|
||||
workspaceID: input.workspaceID,
|
||||
parentID: input.parentID,
|
||||
title: input.title ?? createDefaultTitle(!!input.parentID),
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
permission: input.permission,
|
||||
time: {
|
||||
created: Date.now(),
|
||||
@@ -591,6 +620,8 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
|
||||
const create = Effect.fn("Session.create")(function* (input?: {
|
||||
parentID?: SessionID
|
||||
title?: string
|
||||
agent?: string
|
||||
model?: Schema.Schema.Type<typeof Model>
|
||||
permission?: Permission.Ruleset
|
||||
workspaceID?: WorkspaceID
|
||||
}) {
|
||||
@@ -601,6 +632,8 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
|
||||
directory: ctx.directory,
|
||||
path: sessionPath(ctx.worktree, ctx.directory),
|
||||
title: input?.title,
|
||||
agent: input?.agent,
|
||||
model: input?.model,
|
||||
permission: input?.permission,
|
||||
workspaceID: input?.workspaceID ?? workspace,
|
||||
})
|
||||
|
||||
@@ -122,6 +122,7 @@ export const Client = lazy(() => {
|
||||
})
|
||||
|
||||
export function close() {
|
||||
if (!Client.loaded()) return
|
||||
Client().$client.close()
|
||||
Client.reset()
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export type Properties<Def extends Definition = Definition> = EffectSchema.Schem
|
||||
|
||||
export type SerializedEvent<Def extends Definition = Definition> = Event<Def> & { type: string }
|
||||
|
||||
type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void
|
||||
type ProjectorFunc = (db: Database.TxOrDb, data: unknown, event: Event) => void
|
||||
type ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise<unknown>
|
||||
type PublishContext = {
|
||||
instance?: InstanceContext
|
||||
@@ -255,7 +255,7 @@ export function define<
|
||||
|
||||
export function project<Def extends Definition>(
|
||||
def: Def,
|
||||
func: (db: Database.TxOrDb, data: Event<Def>["data"]) => void,
|
||||
func: (db: Database.TxOrDb, data: Event<Def>["data"], event: Event<Def>) => void,
|
||||
): [Definition, ProjectorFunc] {
|
||||
return [def, func as ProjectorFunc]
|
||||
}
|
||||
@@ -277,7 +277,7 @@ function process<Def extends Definition>(
|
||||
// idempotent: need to ignore any events already logged
|
||||
|
||||
Database.transaction((tx) => {
|
||||
projector(tx, event.data)
|
||||
projector(tx, event.data, event)
|
||||
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||
tx.insert(EventSequenceTable)
|
||||
@@ -308,7 +308,7 @@ function process<Def extends Definition>(
|
||||
}
|
||||
|
||||
const result = convertEvent(def.type, event.data)
|
||||
const publish = (data: unknown) => ProjectBus.publish(def, data as Properties<Def>)
|
||||
const publish = (data: unknown) => ProjectBus.publish(def, data as Properties<Def>, { id: event.id })
|
||||
if (result instanceof Promise) {
|
||||
void result.then(publish)
|
||||
} else {
|
||||
|
||||
@@ -90,7 +90,7 @@ function bodyWithChecks(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
// Schema.withDecodingDefault also attaches encoding, but we want `.default(v)`
|
||||
// on the inner Zod rather than a transform wrapper — so optional ASTs whose
|
||||
// encoding resolves a default from Option.none() route through body()/opt().
|
||||
const hasEncoding = ast.encoding?.length && ast._tag !== "Declaration"
|
||||
const hasEncoding = ast.encoding?.length && (ast._tag !== "Declaration" || ast.typeParameters.length === 0)
|
||||
const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined)
|
||||
const base = hasTransform ? encoded(ast) : body(ast)
|
||||
return ast.checks?.length ? applyChecks(base, ast.checks, ast) : base
|
||||
|
||||
@@ -14,5 +14,7 @@ export function lazy<T>(fn: () => T) {
|
||||
value = undefined
|
||||
}
|
||||
|
||||
result.loaded = () => loaded
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
53
packages/opencode/src/v2/event.ts
Normal file
53
packages/opencode/src/v2/event.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Identifier } from "@/id/id"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import * as Schema from "effect/Schema"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
Schema.brand("Event.ID"),
|
||||
withStatics((s) => ({
|
||||
create: () => s.make(Identifier.create("evt", "ascending")),
|
||||
})),
|
||||
)
|
||||
export type ID = Schema.Schema.Type<typeof ID>
|
||||
|
||||
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
|
||||
type: Type
|
||||
schema: Fields
|
||||
aggregate: string
|
||||
version?: number
|
||||
}) {
|
||||
const Payload = Schema.Struct({
|
||||
id: ID,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
type: Schema.Literal(input.type),
|
||||
data: Schema.Struct(input.schema),
|
||||
}).annotate({
|
||||
identifier: input.type,
|
||||
})
|
||||
|
||||
const Sync = SyncEvent.define({
|
||||
type: input.type,
|
||||
version: input.version ?? 1,
|
||||
aggregate: input.aggregate,
|
||||
schema: Payload.fields.data,
|
||||
})
|
||||
|
||||
return Object.assign(Payload, {
|
||||
Sync,
|
||||
version: input.version,
|
||||
aggregate: input.aggregate,
|
||||
})
|
||||
}
|
||||
|
||||
export function run<Def extends SyncEvent.Definition>(
|
||||
def: Def,
|
||||
data: SyncEvent.Event<Def>["data"],
|
||||
options?: { publish?: boolean },
|
||||
) {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) return
|
||||
SyncEvent.run(def, data, options)
|
||||
}
|
||||
|
||||
export * as EventV2 from "./event"
|
||||
10
packages/opencode/src/v2/schema.ts
Normal file
10
packages/opencode/src/v2/schema.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { DateTime, Schema, SchemaGetter } from "effect"
|
||||
|
||||
export const DateTimeUtcFromMillis = Schema.Finite.pipe(
|
||||
Schema.decodeTo(Schema.DateTimeUtc, {
|
||||
decode: SchemaGetter.transform((value) => DateTime.makeUnsafe(value)),
|
||||
encode: SchemaGetter.transform((value) => DateTime.toEpochMillis(value)),
|
||||
}),
|
||||
)
|
||||
|
||||
export * as V2Schema from "./schema"
|
||||
@@ -1,261 +0,0 @@
|
||||
import { produce, type WritableDraft } from "immer"
|
||||
import { SessionEvent } from "./session-event"
|
||||
import { SessionEntry } from "./session-entry"
|
||||
|
||||
export type MemoryState = {
|
||||
entries: SessionEntry.Entry[]
|
||||
pending: SessionEntry.Entry[]
|
||||
}
|
||||
|
||||
export interface Adapter<Result> {
|
||||
readonly getCurrentAssistant: () => SessionEntry.Assistant | undefined
|
||||
readonly updateAssistant: (assistant: SessionEntry.Assistant) => void
|
||||
readonly appendEntry: (entry: SessionEntry.Entry) => void
|
||||
readonly appendPending: (entry: SessionEntry.Entry) => void
|
||||
readonly finish: () => Result
|
||||
}
|
||||
|
||||
export function memory(state: MemoryState): Adapter<MemoryState> {
|
||||
const activeAssistantIndex = () =>
|
||||
state.entries.findLastIndex((entry) => entry.type === "assistant" && !entry.time.completed)
|
||||
|
||||
return {
|
||||
getCurrentAssistant() {
|
||||
const index = activeAssistantIndex()
|
||||
if (index < 0) return
|
||||
const assistant = state.entries[index]
|
||||
return assistant?.type === "assistant" ? assistant : undefined
|
||||
},
|
||||
updateAssistant(assistant) {
|
||||
const index = activeAssistantIndex()
|
||||
if (index < 0) return
|
||||
const current = state.entries[index]
|
||||
if (current?.type !== "assistant") return
|
||||
state.entries[index] = assistant
|
||||
},
|
||||
appendEntry(entry) {
|
||||
state.entries.push(entry)
|
||||
},
|
||||
appendPending(entry) {
|
||||
state.pending.push(entry)
|
||||
},
|
||||
finish() {
|
||||
return state
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function stepWith<Result>(adapter: Adapter<Result>, event: SessionEvent.Event): Result {
|
||||
const currentAssistant = adapter.getCurrentAssistant()
|
||||
type DraftAssistant = WritableDraft<SessionEntry.Assistant>
|
||||
type DraftTool = WritableDraft<SessionEntry.AssistantTool>
|
||||
type DraftText = WritableDraft<SessionEntry.AssistantText>
|
||||
type DraftReasoning = WritableDraft<SessionEntry.AssistantReasoning>
|
||||
|
||||
const latestTool = (assistant: DraftAssistant | undefined, callID?: string) =>
|
||||
assistant?.content.findLast(
|
||||
(item): item is DraftTool => item.type === "tool" && (callID === undefined || item.callID === callID),
|
||||
)
|
||||
|
||||
const latestText = (assistant: DraftAssistant | undefined) =>
|
||||
assistant?.content.findLast((item): item is DraftText => item.type === "text")
|
||||
|
||||
const latestReasoning = (assistant: DraftAssistant | undefined) =>
|
||||
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning")
|
||||
|
||||
SessionEvent.Event.match(event, {
|
||||
prompt: (event) => {
|
||||
const entry = SessionEntry.User.fromEvent(event)
|
||||
if (currentAssistant) {
|
||||
adapter.appendPending(entry)
|
||||
return
|
||||
}
|
||||
adapter.appendEntry(entry)
|
||||
},
|
||||
synthetic: (event) => {
|
||||
adapter.appendEntry(SessionEntry.Synthetic.fromEvent(event))
|
||||
},
|
||||
"step.started": (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
draft.time.completed = event.timestamp
|
||||
}),
|
||||
)
|
||||
}
|
||||
adapter.appendEntry(SessionEntry.Assistant.fromEvent(event))
|
||||
},
|
||||
"step.ended": (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
draft.time.completed = event.timestamp
|
||||
draft.cost = event.cost
|
||||
draft.tokens = event.tokens
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"text.started": () => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
draft.content.push({
|
||||
type: "text",
|
||||
text: "",
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"text.delta": (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestText(draft)
|
||||
if (match) match.text += event.delta
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"text.ended": () => {},
|
||||
"tool.input.started": (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
draft.content.push({
|
||||
type: "tool",
|
||||
callID: event.callID,
|
||||
name: event.name,
|
||||
time: {
|
||||
created: event.timestamp,
|
||||
},
|
||||
state: {
|
||||
status: "pending",
|
||||
input: "",
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"tool.input.delta": (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestTool(draft, event.callID)
|
||||
// oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string)
|
||||
if (match && match.state.status === "pending") match.state.input += event.delta
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"tool.input.ended": () => {},
|
||||
"tool.called": (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestTool(draft, event.callID)
|
||||
if (match) {
|
||||
match.time.ran = event.timestamp
|
||||
match.state = {
|
||||
status: "running",
|
||||
input: event.input,
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"tool.success": (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestTool(draft, event.callID)
|
||||
if (match && match.state.status === "running") {
|
||||
match.state = {
|
||||
status: "completed",
|
||||
input: match.state.input,
|
||||
output: event.output ?? "",
|
||||
title: event.title,
|
||||
metadata: event.metadata ?? {},
|
||||
attachments: [...(event.attachments ?? [])],
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"tool.error": (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestTool(draft, event.callID)
|
||||
if (match && match.state.status === "running") {
|
||||
match.state = {
|
||||
status: "error",
|
||||
error: event.error,
|
||||
input: match.state.input,
|
||||
metadata: event.metadata ?? {},
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"reasoning.started": () => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
draft.content.push({
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"reasoning.delta": (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestReasoning(draft)
|
||||
if (match) match.text += event.delta
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"reasoning.ended": (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestReasoning(draft)
|
||||
if (match) match.text = event.text
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
retried: (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
draft.retries = [...(draft.retries ?? []), SessionEntry.AssistantRetry.fromEvent(event)]
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
compacted: (event) => {
|
||||
adapter.appendEntry(SessionEntry.Compaction.fromEvent(event))
|
||||
},
|
||||
})
|
||||
|
||||
return adapter.finish()
|
||||
}
|
||||
|
||||
export function step(old: MemoryState, event: SessionEvent.Event): MemoryState {
|
||||
return produce(old, (draft) => {
|
||||
stepWith(memory(draft as MemoryState), event)
|
||||
})
|
||||
}
|
||||
|
||||
export * as SessionEntryStepper from "./session-entry-stepper"
|
||||
@@ -1,220 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { SessionEvent } from "./session-event"
|
||||
|
||||
export const ID = SessionEvent.ID
|
||||
export type ID = Schema.Schema.Type<typeof ID>
|
||||
|
||||
const Base = {
|
||||
id: SessionEvent.ID,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
}),
|
||||
}
|
||||
|
||||
export class User extends Schema.Class<User>("Session.Entry.User")({
|
||||
...Base,
|
||||
text: SessionEvent.Prompt.fields.text,
|
||||
files: SessionEvent.Prompt.fields.files,
|
||||
agents: SessionEvent.Prompt.fields.agents,
|
||||
type: Schema.Literal("user"),
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
}),
|
||||
}) {
|
||||
static fromEvent(event: SessionEvent.Prompt) {
|
||||
return new User({
|
||||
id: event.id,
|
||||
type: "user",
|
||||
metadata: event.metadata,
|
||||
text: event.text,
|
||||
files: event.files,
|
||||
agents: event.agents,
|
||||
time: { created: event.timestamp },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Synthetic extends Schema.Class<Synthetic>("Session.Entry.Synthetic")({
|
||||
...SessionEvent.Synthetic.fields,
|
||||
...Base,
|
||||
type: Schema.Literal("synthetic"),
|
||||
}) {
|
||||
static fromEvent(event: SessionEvent.Synthetic) {
|
||||
return new Synthetic({
|
||||
...event,
|
||||
time: { created: event.timestamp },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolStatePending extends Schema.Class<ToolStatePending>("Session.Entry.ToolState.Pending")({
|
||||
status: Schema.Literal("pending"),
|
||||
input: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class ToolStateRunning extends Schema.Class<ToolStateRunning>("Session.Entry.ToolState.Running")({
|
||||
status: Schema.Literal("running"),
|
||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||
title: Schema.String.pipe(Schema.optional),
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class ToolStateCompleted extends Schema.Class<ToolStateCompleted>("Session.Entry.ToolState.Completed")({
|
||||
status: Schema.Literal("completed"),
|
||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||
output: Schema.String,
|
||||
title: Schema.String,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown),
|
||||
attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class ToolStateError extends Schema.Class<ToolStateError>("Session.Entry.ToolState.Error")({
|
||||
status: Schema.Literal("error"),
|
||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||
error: Schema.String,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe(
|
||||
Schema.toTaggedUnion("status"),
|
||||
)
|
||||
export type ToolState = Schema.Schema.Type<typeof ToolState>
|
||||
|
||||
export class AssistantTool extends Schema.Class<AssistantTool>("Session.Entry.Assistant.Tool")({
|
||||
type: Schema.Literal("tool"),
|
||||
callID: Schema.String,
|
||||
name: Schema.String,
|
||||
state: ToolState,
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
ran: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
completed: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
pruned: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class AssistantText extends Schema.Class<AssistantText>("Session.Entry.Assistant.Text")({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class AssistantReasoning extends Schema.Class<AssistantReasoning>("Session.Entry.Assistant.Reasoning")({
|
||||
type: Schema.Literal("reasoning"),
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class AssistantRetry extends Schema.Class<AssistantRetry>("Session.Entry.Assistant.Retry")({
|
||||
attempt: NonNegativeInt,
|
||||
error: SessionEvent.RetryError,
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
}),
|
||||
}) {
|
||||
static fromEvent(event: SessionEvent.Retried) {
|
||||
return new AssistantRetry({
|
||||
attempt: event.attempt,
|
||||
error: event.error,
|
||||
time: {
|
||||
created: event.timestamp,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
)
|
||||
export type AssistantContent = Schema.Schema.Type<typeof AssistantContent>
|
||||
|
||||
export class Assistant extends Schema.Class<Assistant>("Session.Entry.Assistant")({
|
||||
...Base,
|
||||
type: Schema.Literal("assistant"),
|
||||
content: AssistantContent.pipe(Schema.Array),
|
||||
retries: AssistantRetry.pipe(Schema.Array, Schema.optional),
|
||||
cost: Schema.Finite.pipe(Schema.optional),
|
||||
tokens: Schema.Struct({
|
||||
input: NonNegativeInt,
|
||||
output: NonNegativeInt,
|
||||
reasoning: NonNegativeInt,
|
||||
cache: Schema.Struct({
|
||||
read: NonNegativeInt,
|
||||
write: NonNegativeInt,
|
||||
}),
|
||||
}).pipe(Schema.optional),
|
||||
error: Schema.String.pipe(Schema.optional),
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
completed: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
}),
|
||||
}) {
|
||||
static fromEvent(event: SessionEvent.Step.Started) {
|
||||
return new Assistant({
|
||||
id: event.id,
|
||||
type: "assistant",
|
||||
time: {
|
||||
created: event.timestamp,
|
||||
},
|
||||
content: [],
|
||||
retries: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Compaction extends Schema.Class<Compaction>("Session.Entry.Compaction")({
|
||||
...SessionEvent.Compacted.fields,
|
||||
type: Schema.Literal("compaction"),
|
||||
...Base,
|
||||
}) {
|
||||
static fromEvent(event: SessionEvent.Compacted) {
|
||||
return new Compaction({
|
||||
...event,
|
||||
type: "compaction",
|
||||
time: { created: event.timestamp },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction]).pipe(Schema.toTaggedUnion("type"))
|
||||
|
||||
export type Entry = Schema.Schema.Type<typeof Entry>
|
||||
|
||||
export type Type = Entry["type"]
|
||||
|
||||
/*
|
||||
export interface Interface {
|
||||
readonly decode: (row: typeof SessionEntryTable.$inferSelect) => Entry
|
||||
readonly fromSession: (sessionID: SessionID) => Effect.Effect<Entry[], never>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionEntry") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, never> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const decodeEntry = Schema.decodeUnknownSync(Entry)
|
||||
|
||||
const decode: (typeof Service.Service)["decode"] = (row) => decodeEntry({ ...row, id: row.id, type: row.type })
|
||||
|
||||
const fromSession = Effect.fn("SessionEntry.fromSession")(function* (sessionID: SessionID) {
|
||||
return Database.use((db) =>
|
||||
db
|
||||
.select()
|
||||
.from(SessionEntryTable)
|
||||
.where(eq(SessionEntryTable.session_id, sessionID))
|
||||
.orderBy(SessionEntryTable.id)
|
||||
.all()
|
||||
.map((row) => decode(row)),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
decode,
|
||||
fromSession,
|
||||
})
|
||||
}),
|
||||
)
|
||||
*/
|
||||
|
||||
export * as SessionEntry from "./session-entry"
|
||||
@@ -1,128 +1,119 @@
|
||||
import { Identifier } from "@/id/id"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { EventV2 } from "./event"
|
||||
import { FileAttachment, Prompt } from "./session-prompt"
|
||||
import { Schema } from "effect"
|
||||
export { FileAttachment }
|
||||
import { ToolOutput } from "./tool-output"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { V2Schema } from "./schema"
|
||||
|
||||
export namespace SessionEvent {
|
||||
export const ID = Schema.String.pipe(
|
||||
Schema.brand("Session.Event.ID"),
|
||||
withStatics((s) => ({
|
||||
create: () => s.make(Identifier.create("evt", "ascending")),
|
||||
})),
|
||||
)
|
||||
export type ID = Schema.Schema.Type<typeof ID>
|
||||
type Stamp = Schema.Schema.Type<typeof Schema.DateTimeUtc>
|
||||
type BaseInput = {
|
||||
id?: ID
|
||||
metadata?: Record<string, unknown>
|
||||
timestamp?: Stamp
|
||||
}
|
||||
export const Source = Schema.Struct({
|
||||
start: NonNegativeInt,
|
||||
end: NonNegativeInt,
|
||||
text: Schema.String,
|
||||
}).annotate({
|
||||
identifier: "session.next.event.source",
|
||||
})
|
||||
export type Source = Schema.Schema.Type<typeof Source>
|
||||
|
||||
const Base = {
|
||||
id: ID,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
timestamp: Schema.DateTimeUtc,
|
||||
}
|
||||
const Base = {
|
||||
timestamp: V2Schema.DateTimeUtcFromMillis,
|
||||
sessionID: SessionID,
|
||||
}
|
||||
|
||||
export class Source extends Schema.Class<Source>("Session.Event.Source")({
|
||||
start: NonNegativeInt,
|
||||
end: NonNegativeInt,
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class FileAttachment extends Schema.Class<FileAttachment>("Session.Event.FileAttachment")({
|
||||
uri: Schema.String,
|
||||
mime: Schema.String,
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
source: Source.pipe(Schema.optional),
|
||||
}) {
|
||||
static create(input: FileAttachment) {
|
||||
return new FileAttachment({
|
||||
uri: input.uri,
|
||||
mime: input.mime,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
source: input.source,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentAttachment extends Schema.Class<AgentAttachment>("Session.Event.AgentAttachment")({
|
||||
name: Schema.String,
|
||||
source: Source.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class RetryError extends Schema.Class<RetryError>("Session.Event.Retry.Error")({
|
||||
message: Schema.String,
|
||||
statusCode: NonNegativeInt.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),
|
||||
}) {}
|
||||
|
||||
export class Prompt extends Schema.Class<Prompt>("Session.Event.Prompt")({
|
||||
export const AgentSwitched = EventV2.define({
|
||||
type: "session.next.agent.switched",
|
||||
aggregate: "sessionID",
|
||||
version: 1,
|
||||
schema: {
|
||||
...Base,
|
||||
type: Schema.Literal("prompt"),
|
||||
text: Schema.String,
|
||||
files: Schema.Array(FileAttachment).pipe(Schema.optional),
|
||||
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
|
||||
}) {
|
||||
static create(input: BaseInput & { text: string; files?: FileAttachment[]; agents?: AgentAttachment[] }) {
|
||||
return new Prompt({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "prompt",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
text: input.text,
|
||||
files: input.files,
|
||||
agents: input.agents,
|
||||
})
|
||||
}
|
||||
}
|
||||
agent: Schema.String,
|
||||
},
|
||||
})
|
||||
export type AgentSwitched = Schema.Schema.Type<typeof AgentSwitched>
|
||||
|
||||
export class Synthetic extends Schema.Class<Synthetic>("Session.Event.Synthetic")({
|
||||
export const ModelSwitched = EventV2.define({
|
||||
type: "session.next.model.switched",
|
||||
aggregate: "sessionID",
|
||||
version: 1,
|
||||
schema: {
|
||||
...Base,
|
||||
type: Schema.Literal("synthetic"),
|
||||
text: Schema.String,
|
||||
}) {
|
||||
static create(input: BaseInput & { text: string }) {
|
||||
return new Synthetic({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "synthetic",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
text: input.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
id: ModelID,
|
||||
providerID: ProviderID,
|
||||
variant: Schema.String.pipe(Schema.optional),
|
||||
},
|
||||
})
|
||||
export type ModelSwitched = Schema.Schema.Type<typeof ModelSwitched>
|
||||
|
||||
export namespace Step {
|
||||
export class Started extends Schema.Class<Started>("Session.Event.Step.Started")({
|
||||
export const Prompted = EventV2.define({
|
||||
type: "session.next.prompted",
|
||||
aggregate: "sessionID",
|
||||
version: 1,
|
||||
schema: {
|
||||
...Base,
|
||||
prompt: Prompt,
|
||||
},
|
||||
})
|
||||
export type Prompted = Schema.Schema.Type<typeof Prompted>
|
||||
|
||||
export const Synthetic = EventV2.define({
|
||||
type: "session.next.synthetic",
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
text: Schema.String,
|
||||
},
|
||||
})
|
||||
export type Synthetic = Schema.Schema.Type<typeof Synthetic>
|
||||
|
||||
export namespace Shell {
|
||||
export const Started = EventV2.define({
|
||||
type: "session.next.shell.started",
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
type: Schema.Literal("step.started"),
|
||||
callID: Schema.String,
|
||||
command: Schema.String,
|
||||
},
|
||||
})
|
||||
export type Started = Schema.Schema.Type<typeof Started>
|
||||
|
||||
export const Ended = EventV2.define({
|
||||
type: "session.next.shell.ended",
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
callID: Schema.String,
|
||||
output: Schema.String,
|
||||
},
|
||||
})
|
||||
export type Ended = Schema.Schema.Type<typeof Ended>
|
||||
}
|
||||
|
||||
export namespace Step {
|
||||
export const Started = EventV2.define({
|
||||
type: "session.next.step.started",
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
agent: Schema.String,
|
||||
model: Schema.Struct({
|
||||
id: Schema.String,
|
||||
providerID: Schema.String,
|
||||
variant: Schema.String.pipe(Schema.optional),
|
||||
}),
|
||||
}) {
|
||||
static create(input: BaseInput & { model: { id: string; providerID: string; variant?: string } }) {
|
||||
return new Started({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "step.started",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
model: input.model,
|
||||
})
|
||||
}
|
||||
}
|
||||
snapshot: Schema.String.pipe(Schema.optional),
|
||||
},
|
||||
})
|
||||
export type Started = Schema.Schema.Type<typeof Started>
|
||||
|
||||
export class Ended extends Schema.Class<Ended>("Session.Event.Step.Ended")({
|
||||
export const Ended = EventV2.define({
|
||||
type: "session.next.step.ended",
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
type: Schema.Literal("step.ended"),
|
||||
reason: Schema.String,
|
||||
finish: Schema.String,
|
||||
cost: Schema.Finite,
|
||||
tokens: Schema.Struct({
|
||||
input: NonNegativeInt,
|
||||
@@ -133,177 +124,118 @@ export namespace SessionEvent {
|
||||
write: NonNegativeInt,
|
||||
}),
|
||||
}),
|
||||
}) {
|
||||
static create(input: BaseInput & { reason: string; cost: number; tokens: Ended["tokens"] }) {
|
||||
return new Ended({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "step.ended",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
reason: input.reason,
|
||||
cost: input.cost,
|
||||
tokens: input.tokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
snapshot: Schema.String.pipe(Schema.optional),
|
||||
},
|
||||
})
|
||||
export type Ended = Schema.Schema.Type<typeof Ended>
|
||||
}
|
||||
|
||||
export namespace Text {
|
||||
export class Started extends Schema.Class<Started>("Session.Event.Text.Started")({
|
||||
export namespace Text {
|
||||
export const Started = EventV2.define({
|
||||
type: "session.next.text.started",
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
type: Schema.Literal("text.started"),
|
||||
}) {
|
||||
static create(input: BaseInput = {}) {
|
||||
return new Started({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "text.started",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
export type Started = Schema.Schema.Type<typeof Started>
|
||||
|
||||
export class Delta extends Schema.Class<Delta>("Session.Event.Text.Delta")({
|
||||
export const Delta = EventV2.define({
|
||||
type: "session.next.text.delta",
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
type: Schema.Literal("text.delta"),
|
||||
delta: Schema.String,
|
||||
}) {
|
||||
static create(input: BaseInput & { delta: string }) {
|
||||
return new Delta({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "text.delta",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
delta: input.delta,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
export type Delta = Schema.Schema.Type<typeof Delta>
|
||||
|
||||
export class Ended extends Schema.Class<Ended>("Session.Event.Text.Ended")({
|
||||
export const Ended = EventV2.define({
|
||||
type: "session.next.text.ended",
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
type: Schema.Literal("text.ended"),
|
||||
text: Schema.String,
|
||||
}) {
|
||||
static create(input: BaseInput & { text: string }) {
|
||||
return new Ended({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "text.ended",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
text: input.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
export type Ended = Schema.Schema.Type<typeof Ended>
|
||||
}
|
||||
|
||||
export namespace Reasoning {
|
||||
export class Started extends Schema.Class<Started>("Session.Event.Reasoning.Started")({
|
||||
export namespace Reasoning {
|
||||
export const Started = EventV2.define({
|
||||
type: "session.next.reasoning.started",
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
type: Schema.Literal("reasoning.started"),
|
||||
}) {
|
||||
static create(input: BaseInput = {}) {
|
||||
return new Started({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "reasoning.started",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
})
|
||||
}
|
||||
}
|
||||
reasoningID: Schema.String,
|
||||
},
|
||||
})
|
||||
export type Started = Schema.Schema.Type<typeof Started>
|
||||
|
||||
export class Delta extends Schema.Class<Delta>("Session.Event.Reasoning.Delta")({
|
||||
export const Delta = EventV2.define({
|
||||
type: "session.next.reasoning.delta",
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
type: Schema.Literal("reasoning.delta"),
|
||||
reasoningID: Schema.String,
|
||||
delta: Schema.String,
|
||||
}) {
|
||||
static create(input: BaseInput & { delta: string }) {
|
||||
return new Delta({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "reasoning.delta",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
delta: input.delta,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
export type Delta = Schema.Schema.Type<typeof Delta>
|
||||
|
||||
export class Ended extends Schema.Class<Ended>("Session.Event.Reasoning.Ended")({
|
||||
export const Ended = EventV2.define({
|
||||
type: "session.next.reasoning.ended",
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
type: Schema.Literal("reasoning.ended"),
|
||||
reasoningID: Schema.String,
|
||||
text: Schema.String,
|
||||
}) {
|
||||
static create(input: BaseInput & { text: string }) {
|
||||
return new Ended({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "reasoning.ended",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
text: input.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
export type Ended = Schema.Schema.Type<typeof Ended>
|
||||
}
|
||||
|
||||
export namespace Tool {
|
||||
export namespace Input {
|
||||
export class Started extends Schema.Class<Started>("Session.Event.Tool.Input.Started")({
|
||||
export namespace Tool {
|
||||
export namespace Input {
|
||||
export const Started = EventV2.define({
|
||||
type: "session.next.tool.input.started",
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
callID: Schema.String,
|
||||
name: Schema.String,
|
||||
type: Schema.Literal("tool.input.started"),
|
||||
}) {
|
||||
static create(input: BaseInput & { callID: string; name: string }) {
|
||||
return new Started({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "tool.input.started",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
callID: input.callID,
|
||||
name: input.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
export type Started = Schema.Schema.Type<typeof Started>
|
||||
|
||||
export class Delta extends Schema.Class<Delta>("Session.Event.Tool.Input.Delta")({
|
||||
export const Delta = EventV2.define({
|
||||
type: "session.next.tool.input.delta",
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
callID: Schema.String,
|
||||
type: Schema.Literal("tool.input.delta"),
|
||||
delta: Schema.String,
|
||||
}) {
|
||||
static create(input: BaseInput & { callID: string; delta: string }) {
|
||||
return new Delta({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "tool.input.delta",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
callID: input.callID,
|
||||
delta: input.delta,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
export type Delta = Schema.Schema.Type<typeof Delta>
|
||||
|
||||
export class Ended extends Schema.Class<Ended>("Session.Event.Tool.Input.Ended")({
|
||||
export const Ended = EventV2.define({
|
||||
type: "session.next.tool.input.ended",
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
callID: Schema.String,
|
||||
type: Schema.Literal("tool.input.ended"),
|
||||
text: Schema.String,
|
||||
}) {
|
||||
static create(input: BaseInput & { callID: string; text: string }) {
|
||||
return new Ended({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "tool.input.ended",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
callID: input.callID,
|
||||
text: input.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
export type Ended = Schema.Schema.Type<typeof Ended>
|
||||
}
|
||||
|
||||
export class Called extends Schema.Class<Called>("Session.Event.Tool.Called")({
|
||||
export const Called = EventV2.define({
|
||||
type: "session.next.tool.called",
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
type: Schema.Literal("tool.called"),
|
||||
callID: Schema.String,
|
||||
tool: Schema.String,
|
||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||
@@ -311,148 +243,155 @@ export namespace SessionEvent {
|
||||
executed: Schema.Boolean,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
}),
|
||||
}) {
|
||||
static create(
|
||||
input: BaseInput & {
|
||||
callID: string
|
||||
tool: string
|
||||
input: Record<string, unknown>
|
||||
provider: Called["provider"]
|
||||
},
|
||||
) {
|
||||
return new Called({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "tool.called",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
callID: input.callID,
|
||||
tool: input.tool,
|
||||
input: input.input,
|
||||
provider: input.provider,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
export type Called = Schema.Schema.Type<typeof Called>
|
||||
|
||||
export class Success extends Schema.Class<Success>("Session.Event.Tool.Success")({
|
||||
export const Progress = EventV2.define({
|
||||
type: "session.next.tool.progress",
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
type: Schema.Literal("tool.success"),
|
||||
callID: Schema.String,
|
||||
title: Schema.String,
|
||||
output: Schema.String.pipe(Schema.optional),
|
||||
attachments: Schema.Array(FileAttachment).pipe(Schema.optional),
|
||||
structured: ToolOutput.Structured,
|
||||
content: Schema.Array(ToolOutput.Content),
|
||||
},
|
||||
})
|
||||
export type Progress = Schema.Schema.Type<typeof Progress>
|
||||
|
||||
export const Success = EventV2.define({
|
||||
type: "session.next.tool.success",
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
callID: Schema.String,
|
||||
structured: ToolOutput.Structured,
|
||||
content: Schema.Array(ToolOutput.Content),
|
||||
provider: Schema.Struct({
|
||||
executed: Schema.Boolean,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
}),
|
||||
}) {
|
||||
static create(
|
||||
input: BaseInput & {
|
||||
callID: string
|
||||
title: string
|
||||
output?: string
|
||||
attachments?: FileAttachment[]
|
||||
provider: Success["provider"]
|
||||
},
|
||||
) {
|
||||
return new Success({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "tool.success",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
callID: input.callID,
|
||||
title: input.title,
|
||||
output: input.output,
|
||||
attachments: input.attachments,
|
||||
provider: input.provider,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
export type Success = Schema.Schema.Type<typeof Success>
|
||||
|
||||
export class Error extends Schema.Class<Error>("Session.Event.Tool.Error")({
|
||||
export const Error = EventV2.define({
|
||||
type: "session.next.tool.error",
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
type: Schema.Literal("tool.error"),
|
||||
callID: Schema.String,
|
||||
error: Schema.String,
|
||||
error: Schema.Struct({
|
||||
type: Schema.String,
|
||||
message: Schema.String,
|
||||
}),
|
||||
provider: Schema.Struct({
|
||||
executed: Schema.Boolean,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
}),
|
||||
}) {
|
||||
static create(input: BaseInput & { callID: string; error: string; provider: Error["provider"] }) {
|
||||
return new Error({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "tool.error",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
callID: input.callID,
|
||||
error: input.error,
|
||||
provider: input.provider,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
export type Error = Schema.Schema.Type<typeof Error>
|
||||
}
|
||||
|
||||
export class Retried extends Schema.Class<Retried>("Session.Event.Retried")({
|
||||
export const RetryError = Schema.Struct({
|
||||
message: Schema.String,
|
||||
statusCode: NonNegativeInt.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 = Schema.Schema.Type<typeof RetryError>
|
||||
|
||||
export const Retried = EventV2.define({
|
||||
type: "session.next.retried",
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
type: Schema.Literal("retried"),
|
||||
attempt: NonNegativeInt,
|
||||
error: RetryError,
|
||||
}) {
|
||||
static create(input: BaseInput & { attempt: number; error: RetryError }) {
|
||||
return new Retried({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "retried",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
attempt: input.attempt,
|
||||
error: input.error,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
export type Retried = Schema.Schema.Type<typeof Retried>
|
||||
|
||||
export class Compacted extends Schema.Class<Compacted>("Session.Event.Compated")({
|
||||
...Base,
|
||||
type: Schema.Literal("compacted"),
|
||||
auto: Schema.Boolean,
|
||||
overflow: Schema.Boolean.pipe(Schema.optional),
|
||||
}) {
|
||||
static create(input: BaseInput & { auto: boolean; overflow?: boolean }) {
|
||||
return new Compacted({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "compacted",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
auto: input.auto,
|
||||
overflow: input.overflow,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const Event = Schema.Union(
|
||||
[
|
||||
Prompt,
|
||||
Synthetic,
|
||||
Step.Started,
|
||||
Step.Ended,
|
||||
Text.Started,
|
||||
Text.Delta,
|
||||
Text.Ended,
|
||||
Tool.Input.Started,
|
||||
Tool.Input.Delta,
|
||||
Tool.Input.Ended,
|
||||
Tool.Called,
|
||||
Tool.Success,
|
||||
Tool.Error,
|
||||
Reasoning.Started,
|
||||
Reasoning.Delta,
|
||||
Reasoning.Ended,
|
||||
Retried,
|
||||
Compacted,
|
||||
],
|
||||
{
|
||||
mode: "oneOf",
|
||||
export namespace Compaction {
|
||||
export const Started = EventV2.define({
|
||||
type: "session.next.compaction.started",
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]),
|
||||
},
|
||||
).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Event = Schema.Schema.Type<typeof Event>
|
||||
export type Type = Event["type"]
|
||||
})
|
||||
export type Started = Schema.Schema.Type<typeof Started>
|
||||
|
||||
export const Delta = EventV2.define({
|
||||
type: "session.next.compaction.delta",
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
text: Schema.String,
|
||||
},
|
||||
})
|
||||
|
||||
export const Ended = EventV2.define({
|
||||
type: "session.next.compaction.ended",
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
text: Schema.String,
|
||||
include: Schema.String.pipe(Schema.optional),
|
||||
},
|
||||
})
|
||||
export type Ended = Schema.Schema.Type<typeof Ended>
|
||||
}
|
||||
|
||||
export const All = Schema.Union(
|
||||
[
|
||||
AgentSwitched,
|
||||
ModelSwitched,
|
||||
Prompted,
|
||||
Synthetic,
|
||||
Shell.Started,
|
||||
Shell.Ended,
|
||||
Step.Started,
|
||||
Step.Ended,
|
||||
Text.Started,
|
||||
Text.Delta,
|
||||
Text.Ended,
|
||||
Tool.Input.Started,
|
||||
Tool.Input.Delta,
|
||||
Tool.Input.Ended,
|
||||
Tool.Called,
|
||||
Tool.Progress,
|
||||
Tool.Success,
|
||||
Tool.Error,
|
||||
Reasoning.Started,
|
||||
Reasoning.Delta,
|
||||
Reasoning.Ended,
|
||||
Retried,
|
||||
Compaction.Started,
|
||||
Compaction.Delta,
|
||||
Compaction.Ended,
|
||||
],
|
||||
{
|
||||
mode: "oneOf",
|
||||
},
|
||||
).pipe(Schema.toTaggedUnion("type"))
|
||||
|
||||
// user
|
||||
// assistant
|
||||
// assistant
|
||||
// assistant
|
||||
// user
|
||||
// compaction marker
|
||||
// -> text
|
||||
// assistant
|
||||
|
||||
export type Event = Schema.Schema.Type<typeof All>
|
||||
export type Type = Event["type"]
|
||||
|
||||
export * as SessionEvent from "./session-event"
|
||||
|
||||
409
packages/opencode/src/v2/session-message-updater.ts
Normal file
409
packages/opencode/src/v2/session-message-updater.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import { produce, type WritableDraft } from "immer"
|
||||
import { SessionEvent } from "./session-event"
|
||||
import { SessionMessage } from "./session-message"
|
||||
|
||||
export type MemoryState = {
|
||||
messages: SessionMessage.Message[]
|
||||
}
|
||||
|
||||
export interface Adapter<Result> {
|
||||
readonly getCurrentAssistant: () => SessionMessage.Assistant | undefined
|
||||
readonly getCurrentCompaction: () => SessionMessage.Compaction | undefined
|
||||
readonly getCurrentShell: (callID: string) => SessionMessage.Shell | undefined
|
||||
readonly updateAssistant: (assistant: SessionMessage.Assistant) => void
|
||||
readonly updateCompaction: (compaction: SessionMessage.Compaction) => void
|
||||
readonly updateShell: (shell: SessionMessage.Shell) => void
|
||||
readonly appendMessage: (message: SessionMessage.Message) => void
|
||||
readonly finish: () => Result
|
||||
}
|
||||
|
||||
export function memory(state: MemoryState): Adapter<MemoryState> {
|
||||
const activeAssistantIndex = () =>
|
||||
state.messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed)
|
||||
const activeCompactionIndex = () => state.messages.findLastIndex((message) => message.type === "compaction")
|
||||
const activeShellIndex = (callID: string) =>
|
||||
state.messages.findLastIndex((message) => message.type === "shell" && message.callID === callID)
|
||||
|
||||
return {
|
||||
getCurrentAssistant() {
|
||||
const index = activeAssistantIndex()
|
||||
if (index < 0) return
|
||||
const assistant = state.messages[index]
|
||||
return assistant?.type === "assistant" ? assistant : undefined
|
||||
},
|
||||
getCurrentCompaction() {
|
||||
const index = activeCompactionIndex()
|
||||
if (index < 0) return
|
||||
const compaction = state.messages[index]
|
||||
return compaction?.type === "compaction" ? compaction : undefined
|
||||
},
|
||||
getCurrentShell(callID) {
|
||||
const index = activeShellIndex(callID)
|
||||
if (index < 0) return
|
||||
const shell = state.messages[index]
|
||||
return shell?.type === "shell" ? shell : undefined
|
||||
},
|
||||
updateAssistant(assistant) {
|
||||
const index = activeAssistantIndex()
|
||||
if (index < 0) return
|
||||
const current = state.messages[index]
|
||||
if (current?.type !== "assistant") return
|
||||
state.messages[index] = assistant
|
||||
},
|
||||
updateCompaction(compaction) {
|
||||
const index = activeCompactionIndex()
|
||||
if (index < 0) return
|
||||
const current = state.messages[index]
|
||||
if (current?.type !== "compaction") return
|
||||
state.messages[index] = compaction
|
||||
},
|
||||
updateShell(shell) {
|
||||
const index = activeShellIndex(shell.callID)
|
||||
if (index < 0) return
|
||||
const current = state.messages[index]
|
||||
if (current?.type !== "shell") return
|
||||
state.messages[index] = shell
|
||||
},
|
||||
appendMessage(message) {
|
||||
state.messages.push(message)
|
||||
},
|
||||
finish() {
|
||||
return state
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Event): Result {
|
||||
const currentAssistant = adapter.getCurrentAssistant()
|
||||
type DraftAssistant = WritableDraft<SessionMessage.Assistant>
|
||||
type DraftTool = WritableDraft<SessionMessage.AssistantTool>
|
||||
type DraftText = WritableDraft<SessionMessage.AssistantText>
|
||||
type DraftReasoning = WritableDraft<SessionMessage.AssistantReasoning>
|
||||
|
||||
const latestTool = (assistant: DraftAssistant | undefined, callID?: string) =>
|
||||
assistant?.content.findLast(
|
||||
(item): item is DraftTool => item.type === "tool" && (callID === undefined || item.id === callID),
|
||||
)
|
||||
|
||||
const latestText = (assistant: DraftAssistant | undefined) =>
|
||||
assistant?.content.findLast((item): item is DraftText => item.type === "text")
|
||||
|
||||
const latestReasoning = (assistant: DraftAssistant | undefined, reasoningID: string) =>
|
||||
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID)
|
||||
|
||||
SessionEvent.All.match(event, {
|
||||
"session.next.agent.switched": (event) => {
|
||||
adapter.appendMessage(
|
||||
new SessionMessage.AgentSwitched({
|
||||
id: event.id,
|
||||
type: "agent-switched",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
time: { created: event.data.timestamp },
|
||||
}),
|
||||
)
|
||||
},
|
||||
"session.next.model.switched": (event) => {
|
||||
adapter.appendMessage(
|
||||
new SessionMessage.ModelSwitched({
|
||||
id: event.id,
|
||||
type: "model-switched",
|
||||
metadata: event.metadata,
|
||||
model: {
|
||||
id: event.data.id,
|
||||
providerID: event.data.providerID,
|
||||
variant: event.data.variant,
|
||||
},
|
||||
time: { created: event.data.timestamp },
|
||||
}),
|
||||
)
|
||||
},
|
||||
"session.next.prompted": (event) => {
|
||||
adapter.appendMessage(
|
||||
new SessionMessage.User({
|
||||
id: event.id,
|
||||
type: "user",
|
||||
metadata: event.metadata,
|
||||
text: event.data.prompt.text,
|
||||
files: event.data.prompt.files,
|
||||
agents: event.data.prompt.agents,
|
||||
time: { created: event.data.timestamp },
|
||||
}),
|
||||
)
|
||||
},
|
||||
"session.next.synthetic": (event) => {
|
||||
adapter.appendMessage(
|
||||
new SessionMessage.Synthetic({
|
||||
sessionID: event.data.sessionID,
|
||||
text: event.data.text,
|
||||
id: event.id,
|
||||
type: "synthetic",
|
||||
time: { created: event.data.timestamp },
|
||||
}),
|
||||
)
|
||||
},
|
||||
"session.next.shell.started": (event) => {
|
||||
adapter.appendMessage(
|
||||
new SessionMessage.Shell({
|
||||
id: event.id,
|
||||
type: "shell",
|
||||
metadata: event.metadata,
|
||||
callID: event.data.callID,
|
||||
command: event.data.command,
|
||||
output: "",
|
||||
time: { created: event.data.timestamp },
|
||||
}),
|
||||
)
|
||||
},
|
||||
"session.next.shell.ended": (event) => {
|
||||
const currentShell = adapter.getCurrentShell(event.data.callID)
|
||||
if (currentShell) {
|
||||
adapter.updateShell(
|
||||
produce(currentShell, (draft) => {
|
||||
draft.output = event.data.output
|
||||
draft.time.completed = event.data.timestamp
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"session.next.step.started": (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
draft.time.completed = event.data.timestamp
|
||||
}),
|
||||
)
|
||||
}
|
||||
adapter.appendMessage(
|
||||
new SessionMessage.Assistant({
|
||||
id: event.id,
|
||||
type: "assistant",
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
time: { created: event.data.timestamp },
|
||||
content: [],
|
||||
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
|
||||
}),
|
||||
)
|
||||
},
|
||||
"session.next.step.ended": (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
draft.time.completed = event.data.timestamp
|
||||
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 }
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"session.next.text.started": () => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
draft.content.push({
|
||||
type: "text",
|
||||
text: "",
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"session.next.text.delta": (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestText(draft)
|
||||
if (match) match.text += event.data.delta
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"session.next.text.ended": (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestText(draft)
|
||||
if (match) match.text = event.data.text
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"session.next.tool.input.started": (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
draft.content.push({
|
||||
type: "tool",
|
||||
id: event.data.callID,
|
||||
name: event.data.name,
|
||||
time: {
|
||||
created: event.data.timestamp,
|
||||
},
|
||||
state: {
|
||||
status: "pending",
|
||||
input: "",
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"session.next.tool.input.delta": (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
// oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string)
|
||||
if (match && match.state.status === "pending") match.state.input += event.data.delta
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"session.next.tool.input.ended": () => {},
|
||||
"session.next.tool.called": (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match) {
|
||||
match.provider = event.data.provider
|
||||
match.time.ran = event.data.timestamp
|
||||
match.state = {
|
||||
status: "running",
|
||||
input: event.data.input,
|
||||
structured: {},
|
||||
content: [],
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"session.next.tool.progress": (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && match.state.status === "running") {
|
||||
match.state.structured = event.data.structured
|
||||
match.state.content = [...event.data.content]
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"session.next.tool.success": (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && match.state.status === "running") {
|
||||
match.provider = event.data.provider
|
||||
match.time.completed = event.data.timestamp
|
||||
match.state = {
|
||||
status: "completed",
|
||||
input: match.state.input,
|
||||
structured: event.data.structured,
|
||||
content: [...event.data.content],
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"session.next.tool.error": (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && match.state.status === "running") {
|
||||
match.provider = event.data.provider
|
||||
match.time.completed = event.data.timestamp
|
||||
match.state = {
|
||||
status: "error",
|
||||
error: event.data.error,
|
||||
input: match.state.input,
|
||||
structured: match.state.structured,
|
||||
content: match.state.content,
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"session.next.reasoning.started": (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
draft.content.push({
|
||||
type: "reasoning",
|
||||
id: event.data.reasoningID,
|
||||
text: "",
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"session.next.reasoning.delta": (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestReasoning(draft, event.data.reasoningID)
|
||||
if (match) match.text += event.data.delta
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"session.next.reasoning.ended": (event) => {
|
||||
if (currentAssistant) {
|
||||
adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestReasoning(draft, event.data.reasoningID)
|
||||
if (match) match.text = event.data.text
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"session.next.retried": () => {},
|
||||
"session.next.compaction.started": (event) => {
|
||||
adapter.appendMessage(
|
||||
new SessionMessage.Compaction({
|
||||
id: event.id,
|
||||
type: "compaction",
|
||||
metadata: event.metadata,
|
||||
reason: event.data.reason,
|
||||
summary: "",
|
||||
time: { created: event.data.timestamp },
|
||||
}),
|
||||
)
|
||||
},
|
||||
"session.next.compaction.delta": (event) => {
|
||||
const currentCompaction = adapter.getCurrentCompaction()
|
||||
if (currentCompaction) {
|
||||
adapter.updateCompaction(
|
||||
produce(currentCompaction, (draft) => {
|
||||
draft.summary += event.data.text
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
"session.next.compaction.ended": (event) => {
|
||||
const currentCompaction = adapter.getCurrentCompaction()
|
||||
if (currentCompaction) {
|
||||
adapter.updateCompaction(
|
||||
produce(currentCompaction, (draft) => {
|
||||
draft.summary = event.data.text
|
||||
draft.include = event.data.include
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return adapter.finish()
|
||||
}
|
||||
|
||||
export * as SessionMessageUpdater from "./session-message-updater"
|
||||
178
packages/opencode/src/v2/session-message.ts
Normal file
178
packages/opencode/src/v2/session-message.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { Schema } from "effect"
|
||||
import { Prompt } from "./session-prompt"
|
||||
import { SessionEvent } from "./session-event"
|
||||
import { EventV2 } from "./event"
|
||||
import { ToolOutput } from "./tool-output"
|
||||
import { V2Schema } from "./schema"
|
||||
|
||||
export const ID = EventV2.ID
|
||||
export type ID = Schema.Schema.Type<typeof ID>
|
||||
|
||||
const Base = {
|
||||
id: ID,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
time: Schema.Struct({
|
||||
created: V2Schema.DateTimeUtcFromMillis,
|
||||
}),
|
||||
}
|
||||
|
||||
export class AgentSwitched extends Schema.Class<AgentSwitched>("Session.Message.AgentSwitched")({
|
||||
...Base,
|
||||
type: Schema.Literal("agent-switched"),
|
||||
agent: SessionEvent.AgentSwitched.fields.data.fields.agent,
|
||||
}) {}
|
||||
|
||||
export class ModelSwitched extends Schema.Class<ModelSwitched>("Session.Message.ModelSwitched")({
|
||||
...Base,
|
||||
type: Schema.Literal("model-switched"),
|
||||
model: Schema.Struct({
|
||||
id: SessionEvent.ModelSwitched.fields.data.fields.id,
|
||||
providerID: SessionEvent.ModelSwitched.fields.data.fields.providerID,
|
||||
variant: SessionEvent.ModelSwitched.fields.data.fields.variant,
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class User extends Schema.Class<User>("Session.Message.User")({
|
||||
...Base,
|
||||
text: Prompt.fields.text,
|
||||
files: Prompt.fields.files,
|
||||
agents: Prompt.fields.agents,
|
||||
type: Schema.Literal("user"),
|
||||
time: Schema.Struct({
|
||||
created: V2Schema.DateTimeUtcFromMillis,
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class Synthetic extends Schema.Class<Synthetic>("Session.Message.Synthetic")({
|
||||
...Base,
|
||||
sessionID: SessionEvent.Synthetic.fields.data.fields.sessionID,
|
||||
text: SessionEvent.Synthetic.fields.data.fields.text,
|
||||
type: Schema.Literal("synthetic"),
|
||||
}) {}
|
||||
|
||||
export class Shell extends Schema.Class<Shell>("Session.Message.Shell")({
|
||||
...Base,
|
||||
type: Schema.Literal("shell"),
|
||||
callID: SessionEvent.Shell.Started.fields.data.fields.callID,
|
||||
command: SessionEvent.Shell.Started.fields.data.fields.command,
|
||||
output: Schema.String,
|
||||
time: Schema.Struct({
|
||||
created: V2Schema.DateTimeUtcFromMillis,
|
||||
completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class ToolStatePending extends Schema.Class<ToolStatePending>("Session.Message.ToolState.Pending")({
|
||||
status: Schema.Literal("pending"),
|
||||
input: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class ToolStateRunning extends Schema.Class<ToolStateRunning>("Session.Message.ToolState.Running")({
|
||||
status: Schema.Literal("running"),
|
||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||
structured: ToolOutput.Structured,
|
||||
content: ToolOutput.Content.pipe(Schema.Array),
|
||||
}) {}
|
||||
|
||||
export class ToolStateCompleted extends Schema.Class<ToolStateCompleted>("Session.Message.ToolState.Completed")({
|
||||
status: Schema.Literal("completed"),
|
||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||
attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional),
|
||||
content: ToolOutput.Content.pipe(Schema.Array),
|
||||
structured: ToolOutput.Structured,
|
||||
}) {}
|
||||
|
||||
export class ToolStateError extends Schema.Class<ToolStateError>("Session.Message.ToolState.Error")({
|
||||
status: Schema.Literal("error"),
|
||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||
content: ToolOutput.Content.pipe(Schema.Array),
|
||||
structured: ToolOutput.Structured,
|
||||
error: Schema.Struct({
|
||||
type: Schema.String,
|
||||
message: Schema.String,
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe(
|
||||
Schema.toTaggedUnion("status"),
|
||||
)
|
||||
export type ToolState = Schema.Schema.Type<typeof ToolState>
|
||||
|
||||
export class AssistantTool extends Schema.Class<AssistantTool>("Session.Message.Assistant.Tool")({
|
||||
type: Schema.Literal("tool"),
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
provider: Schema.Struct({
|
||||
executed: Schema.Boolean,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
}).pipe(Schema.optional),
|
||||
state: ToolState,
|
||||
time: Schema.Struct({
|
||||
created: V2Schema.DateTimeUtcFromMillis,
|
||||
ran: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
|
||||
completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
|
||||
pruned: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class AssistantText extends Schema.Class<AssistantText>("Session.Message.Assistant.Text")({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class AssistantReasoning extends Schema.Class<AssistantReasoning>("Session.Message.Assistant.Reasoning")({
|
||||
type: Schema.Literal("reasoning"),
|
||||
id: Schema.String,
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
)
|
||||
export type AssistantContent = Schema.Schema.Type<typeof AssistantContent>
|
||||
|
||||
export class Assistant extends Schema.Class<Assistant>("Session.Message.Assistant")({
|
||||
...Base,
|
||||
type: Schema.Literal("assistant"),
|
||||
agent: Schema.String,
|
||||
model: SessionEvent.Step.Started.fields.data.fields.model,
|
||||
content: AssistantContent.pipe(Schema.Array),
|
||||
snapshot: Schema.Struct({
|
||||
start: Schema.String.pipe(Schema.optional),
|
||||
end: Schema.String.pipe(Schema.optional),
|
||||
}).pipe(Schema.optional),
|
||||
finish: Schema.String.pipe(Schema.optional),
|
||||
cost: Schema.Finite.pipe(Schema.optional),
|
||||
tokens: Schema.Struct({
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
reasoning: Schema.Finite,
|
||||
cache: Schema.Struct({
|
||||
read: Schema.Finite,
|
||||
write: Schema.Finite,
|
||||
}),
|
||||
}).pipe(Schema.optional),
|
||||
error: Schema.String.pipe(Schema.optional),
|
||||
time: Schema.Struct({
|
||||
created: V2Schema.DateTimeUtcFromMillis,
|
||||
completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class Compaction extends Schema.Class<Compaction>("Session.Message.Compaction")({
|
||||
type: Schema.Literal("compaction"),
|
||||
reason: SessionEvent.Compaction.Started.fields.data.fields.reason,
|
||||
summary: Schema.String,
|
||||
include: Schema.String.pipe(Schema.optional),
|
||||
...Base,
|
||||
}) {}
|
||||
|
||||
export const Message = Schema.Union([AgentSwitched, ModelSwitched, User, Synthetic, Shell, Assistant, Compaction])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Session.Message" })
|
||||
|
||||
export type Message = Schema.Schema.Type<typeof Message>
|
||||
|
||||
export type Type = Message["type"]
|
||||
|
||||
export * as SessionMessage from "./session-message"
|
||||
36
packages/opencode/src/v2/session-prompt.ts
Normal file
36
packages/opencode/src/v2/session-prompt.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as Schema from "effect/Schema"
|
||||
|
||||
export class Source extends Schema.Class<Source>("Prompt.Source")({
|
||||
start: Schema.Finite,
|
||||
end: Schema.Finite,
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class FileAttachment extends Schema.Class<FileAttachment>("Prompt.FileAttachment")({
|
||||
uri: Schema.String,
|
||||
mime: Schema.String,
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
source: Source.pipe(Schema.optional),
|
||||
}) {
|
||||
static create(input: FileAttachment) {
|
||||
return new FileAttachment({
|
||||
uri: input.uri,
|
||||
mime: input.mime,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
source: input.source,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentAttachment extends Schema.Class<AgentAttachment>("Prompt.AgentAttachment")({
|
||||
name: Schema.String,
|
||||
source: Source.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Prompt extends Schema.Class<Prompt>("Prompt")({
|
||||
text: Schema.String,
|
||||
files: Schema.Array(FileAttachment).pipe(Schema.optional),
|
||||
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
|
||||
}) {}
|
||||
@@ -1,69 +1,279 @@
|
||||
import { Context, Layer, Schema, Effect } from "effect"
|
||||
import { SessionEntry } from "./session-entry"
|
||||
import { Struct } from "effect"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionMessageTable, SessionTable } from "@/session/session.sql"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/storage/db"
|
||||
import * as Database from "@/storage/db"
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { SessionMessage } from "./session-message"
|
||||
import type { Prompt } from "./session-prompt"
|
||||
import { EventV2 } from "./event"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { SessionEvent } from "./session-event"
|
||||
import { V2Schema } from "./schema"
|
||||
|
||||
export const ID = SessionID
|
||||
export const Delivery = Schema.Union([Schema.Literal("immediate"), Schema.Literal("deferred")]).annotate({
|
||||
identifier: "Session.Delivery",
|
||||
})
|
||||
export type Delivery = Schema.Schema.Type<typeof Delivery>
|
||||
|
||||
export type ID = Schema.Schema.Type<typeof ID>
|
||||
|
||||
export class PromptInput extends Schema.Class<PromptInput>("Session.PromptInput")({
|
||||
...Struct.omit(SessionEntry.User.fields, ["time", "type"]),
|
||||
id: Schema.optionalKey(SessionEntry.ID),
|
||||
sessionID: ID,
|
||||
}) {}
|
||||
|
||||
export class CreateInput extends Schema.Class<CreateInput>("Session.CreateInput")({
|
||||
id: Schema.optionalKey(ID),
|
||||
}) {}
|
||||
export const DefaultDelivery = "immediate" satisfies Delivery
|
||||
|
||||
export class Info extends Schema.Class<Info>("Session.Info")({
|
||||
id: ID,
|
||||
id: SessionID,
|
||||
parentID: SessionID.pipe(Schema.optional),
|
||||
projectID: ProjectID,
|
||||
workspaceID: WorkspaceID.pipe(Schema.optional),
|
||||
path: Schema.String.pipe(Schema.optional),
|
||||
agent: Schema.String.pipe(Schema.optional),
|
||||
model: Schema.Struct({
|
||||
id: Schema.String,
|
||||
providerID: Schema.String,
|
||||
modelID: Schema.String,
|
||||
id: ModelID,
|
||||
providerID: ProviderID,
|
||||
variant: Schema.String.pipe(Schema.optional),
|
||||
}).pipe(Schema.optional),
|
||||
time: Schema.Struct({
|
||||
created: V2Schema.DateTimeUtcFromMillis,
|
||||
updated: V2Schema.DateTimeUtcFromMillis,
|
||||
archived: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
|
||||
}),
|
||||
title: Schema.String,
|
||||
/*
|
||||
slug: Schema.String,
|
||||
directory: Schema.String,
|
||||
path: optionalOmitUndefined(Schema.String),
|
||||
parentID: optionalOmitUndefined(SessionID),
|
||||
summary: optionalOmitUndefined(Summary),
|
||||
share: optionalOmitUndefined(Share),
|
||||
title: Schema.String,
|
||||
version: Schema.String,
|
||||
time: Time,
|
||||
permission: optionalOmitUndefined(Permission.Ruleset),
|
||||
revert: optionalOmitUndefined(Revert),
|
||||
*/
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
fromID: (id: ID) => Effect.Effect<Info>
|
||||
create: (input: CreateInput) => Effect.Effect<Info>
|
||||
prompt: (input: PromptInput) => Effect.Effect<SessionEntry.User>
|
||||
readonly list: (input: {
|
||||
limit?: number
|
||||
order?: "asc" | "desc"
|
||||
directory?: string
|
||||
path?: string
|
||||
workspaceID?: WorkspaceID
|
||||
roots?: boolean
|
||||
start?: number
|
||||
search?: string
|
||||
cursor?: {
|
||||
id: SessionID
|
||||
time: number
|
||||
direction: "previous" | "next"
|
||||
}
|
||||
}) => Effect.Effect<Info[], never>
|
||||
readonly messages: (input: {
|
||||
sessionID: SessionID
|
||||
limit?: number
|
||||
order?: "asc" | "desc"
|
||||
cursor?: {
|
||||
id: SessionMessage.ID
|
||||
time: number
|
||||
direction: "previous" | "next"
|
||||
}
|
||||
}) => Effect.Effect<SessionMessage.Message[], never>
|
||||
readonly context: (sessionID: SessionID) => Effect.Effect<SessionMessage.Message[], never>
|
||||
readonly prompt: (input: {
|
||||
id?: EventV2.ID
|
||||
sessionID: SessionID
|
||||
prompt: Prompt
|
||||
delivery?: Delivery
|
||||
}) => Effect.Effect<SessionMessage.User, never>
|
||||
readonly shell: (input: { id?: EventV2.ID; sessionID: SessionID; command: string }) => Effect.Effect<void, never>
|
||||
readonly skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect<void, never>
|
||||
readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect<void, never>
|
||||
readonly switchModel: (input: {
|
||||
sessionID: SessionID
|
||||
id: ModelID
|
||||
providerID: ProviderID
|
||||
variant?: string
|
||||
}) => Effect.Effect<void, never>
|
||||
readonly compact: (sessionID: SessionID) => Effect.Effect<void, never>
|
||||
readonly wait: (sessionID: SessionID) => Effect.Effect<void, never>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("Session.Service") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
|
||||
|
||||
export const layer = Layer.effect(Service)(
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
|
||||
|
||||
const create: Interface["create"] = Effect.fn("Session.create")(function* (_input) {
|
||||
throw new Error("Not implemented")
|
||||
})
|
||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||
|
||||
const prompt: Interface["prompt"] = Effect.fn("Session.prompt")(function* (_input) {
|
||||
throw new Error("Not implemented")
|
||||
})
|
||||
function fromRow(row: typeof SessionTable.$inferSelect): Info {
|
||||
return {
|
||||
id: SessionID.make(row.id),
|
||||
projectID: ProjectID.make(row.project_id),
|
||||
workspaceID: row.workspace_id ? WorkspaceID.make(row.workspace_id) : undefined,
|
||||
title: row.title,
|
||||
parentID: row.parent_id ? SessionID.make(row.parent_id) : undefined,
|
||||
path: row.path ?? "",
|
||||
agent: row.agent ?? undefined,
|
||||
model: row.model
|
||||
? {
|
||||
id: ModelID.make(row.model.id),
|
||||
providerID: ProviderID.make(row.model.providerID),
|
||||
variant: row.model.variant,
|
||||
}
|
||||
: undefined,
|
||||
time: {
|
||||
created: DateTime.makeUnsafe(row.time_created),
|
||||
updated: DateTime.makeUnsafe(row.time_updated),
|
||||
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const fromID: Interface["fromID"] = Effect.fn("Session.fromID")(function* (id) {
|
||||
const match = yield* session.get(id)
|
||||
return fromV1(match)
|
||||
})
|
||||
const result: Interface = {
|
||||
list: Effect.fn("V2Session.list")(function* (input) {
|
||||
const direction = input.cursor?.direction ?? "next"
|
||||
let order = input.order ?? "desc"
|
||||
// Query the adjacent rows in reverse, then flip them back into the requested order below.
|
||||
if (direction === "previous" && order === "asc") order = "desc"
|
||||
if (direction === "previous" && order === "desc") order = "asc"
|
||||
const conditions: SQL[] = []
|
||||
if (input.directory) conditions.push(eq(SessionTable.directory, input.directory))
|
||||
if (input.path)
|
||||
conditions.push(or(eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`))!)
|
||||
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
||||
if (input.roots) conditions.push(isNull(SessionTable.parent_id))
|
||||
if (input.start) conditions.push(gte(SessionTable.time_created, input.start))
|
||||
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||
if (input.cursor) {
|
||||
conditions.push(
|
||||
order === "asc"
|
||||
? or(
|
||||
gt(SessionTable.time_created, input.cursor.time),
|
||||
and(eq(SessionTable.time_created, input.cursor.time), gt(SessionTable.id, input.cursor.id)),
|
||||
)!
|
||||
: or(
|
||||
lt(SessionTable.time_created, input.cursor.time),
|
||||
and(eq(SessionTable.time_created, input.cursor.time), lt(SessionTable.id, input.cursor.id)),
|
||||
)!,
|
||||
)
|
||||
}
|
||||
const query = Database.Client()
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(
|
||||
order === "asc" ? asc(SessionTable.time_created) : desc(SessionTable.time_created),
|
||||
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
create,
|
||||
prompt,
|
||||
fromID,
|
||||
})
|
||||
const rows = input.limit === undefined ? query.all() : query.limit(input.limit).all()
|
||||
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
|
||||
}),
|
||||
messages: Effect.fn("V2Session.messages")(function* (input) {
|
||||
const direction = input.cursor?.direction ?? "next"
|
||||
let order = input.order ?? "desc"
|
||||
// Query the adjacent rows in reverse, then flip them back into the requested order below.
|
||||
if (direction === "previous" && order === "asc") order = "desc"
|
||||
if (direction === "previous" && order === "desc") order = "asc"
|
||||
const boundary = input.cursor
|
||||
? order === "asc"
|
||||
? or(
|
||||
gt(SessionMessageTable.time_created, input.cursor.time),
|
||||
and(
|
||||
eq(SessionMessageTable.time_created, input.cursor.time),
|
||||
gt(SessionMessageTable.id, input.cursor.id),
|
||||
),
|
||||
)
|
||||
: or(
|
||||
lt(SessionMessageTable.time_created, input.cursor.time),
|
||||
and(
|
||||
eq(SessionMessageTable.time_created, input.cursor.time),
|
||||
lt(SessionMessageTable.id, input.cursor.id),
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
const where = boundary
|
||||
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
|
||||
: eq(SessionMessageTable.session_id, input.sessionID)
|
||||
|
||||
const rows = Database.use((db) => {
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(where)
|
||||
.orderBy(
|
||||
order === "asc" ? asc(SessionMessageTable.time_created) : desc(SessionMessageTable.time_created),
|
||||
order === "asc" ? asc(SessionMessageTable.id) : desc(SessionMessageTable.id),
|
||||
)
|
||||
const rows = input.limit === undefined ? query.all() : query.limit(input.limit).all()
|
||||
return direction === "previous" ? rows.toReversed() : rows
|
||||
})
|
||||
return rows.map((row) => decode(row))
|
||||
}),
|
||||
context: Effect.fn("V2Session.context")(function* (sessionID) {
|
||||
const rows = Database.use((db) => {
|
||||
const compaction = db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
|
||||
.orderBy(desc(SessionMessageTable.time_created), desc(SessionMessageTable.id))
|
||||
.limit(1)
|
||||
.get()
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
compaction
|
||||
? or(
|
||||
gt(SessionMessageTable.time_created, compaction.time_created),
|
||||
and(
|
||||
eq(SessionMessageTable.time_created, compaction.time_created),
|
||||
gte(SessionMessageTable.id, compaction.id),
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.time_created), asc(SessionMessageTable.id))
|
||||
.all()
|
||||
})
|
||||
return rows.map((row) => decode(row))
|
||||
}),
|
||||
prompt: Effect.fn("V2Session.prompt")(function* (_input) {
|
||||
return {} as any
|
||||
}),
|
||||
shell: Effect.fn("V2Session.shell")(function* (_input) {}),
|
||||
skill: Effect.fn("V2Session.skill")(function* (_input) {}),
|
||||
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
|
||||
EventV2.run(SessionEvent.AgentSwitched.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
agent: input.agent,
|
||||
})
|
||||
}),
|
||||
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
|
||||
EventV2.run(SessionEvent.ModelSwitched.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
id: input.id,
|
||||
providerID: input.providerID,
|
||||
variant: input.variant,
|
||||
})
|
||||
}),
|
||||
compact: Effect.fn("V2Session.compact")(function* (_sessionID) {}),
|
||||
wait: Effect.fn("V2Session.wait")(function* (_sessionID) {}),
|
||||
}
|
||||
|
||||
return Service.of(result)
|
||||
}),
|
||||
)
|
||||
|
||||
function fromV1(input: Session.Info): Info {
|
||||
return new Info({
|
||||
id: ID.make(input.id),
|
||||
})
|
||||
}
|
||||
export const defaultLayer = layer
|
||||
|
||||
export * as SessionV2 from "./session"
|
||||
|
||||
18
packages/opencode/src/v2/tool-output.ts
Normal file
18
packages/opencode/src/v2/tool-output.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export * as ToolOutput from "./tool-output"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class TextContent extends Schema.Class<TextContent>("Tool.TextContent")({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class FileContent extends Schema.Class<FileContent>("Tool.FileContent")({
|
||||
type: Schema.Literal("file"),
|
||||
uri: Schema.String,
|
||||
mime: Schema.String,
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const Content = Schema.Union([TextContent, FileContent]).pipe(Schema.toTaggedUnion("type"))
|
||||
|
||||
export const Structured = Schema.Record(Schema.String, Schema.Any)
|
||||
@@ -1,7 +1,8 @@
|
||||
import z from "zod"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Instance } from "../project/instance"
|
||||
import { InstanceLayer } from "@/project/instance-layer"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { Project } from "@/project/project"
|
||||
import { Database } from "@/storage/db"
|
||||
import { eq } from "drizzle-orm"
|
||||
@@ -159,7 +160,12 @@ type GitResult = { code: number; text: string; stderr: string }
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Git.Service | Project.Service
|
||||
| AppFileSystem.Service
|
||||
| Path.Path
|
||||
| ChildProcessSpawner.ChildProcessSpawner
|
||||
| Git.Service
|
||||
| Project.Service
|
||||
| InstanceStore.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -169,6 +175,7 @@ export const layer: Layer.Layer<
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const gitSvc = yield* Git.Service
|
||||
const project = yield* Project.Service
|
||||
const store = yield* InstanceStore.Service
|
||||
|
||||
const git = Effect.fnUntraced(
|
||||
function* (args: string[], opts?: { cwd?: string }) {
|
||||
@@ -251,13 +258,10 @@ export const layer: Layer.Layer<
|
||||
return
|
||||
}
|
||||
|
||||
const booted = yield* Effect.promise(() =>
|
||||
Instance.provide({
|
||||
directory: info.directory,
|
||||
fn: () => undefined,
|
||||
})
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
const booted = yield* store.load({ directory: info.directory }).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
const message = errorMessage(error)
|
||||
log.error("worktree bootstrap failed", { directory: info.directory, message })
|
||||
GlobalBus.emit("event", {
|
||||
@@ -268,6 +272,7 @@ export const layer: Layer.Layer<
|
||||
})
|
||||
return false
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (!booted) return
|
||||
|
||||
@@ -579,7 +584,7 @@ export const layer: Layer.Layer<
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
export const appLayer = layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(Project.defaultLayer),
|
||||
@@ -587,4 +592,6 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
|
||||
export const defaultLayer = appLayer.pipe(Layer.provide(InstanceLayer.layer))
|
||||
|
||||
export * as Worktree from "."
|
||||
|
||||
@@ -89,20 +89,17 @@ Use `testEffect(...)` from `test/lib/effect.ts` for tests that exercise Effect s
|
||||
```typescript
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(MyService.defaultLayer))
|
||||
|
||||
describe("my service", () => {
|
||||
it.live("does the thing", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* MyService.Service
|
||||
const out = yield* svc.run()
|
||||
expect(out).toEqual("ok")
|
||||
}),
|
||||
),
|
||||
it.instance("does the thing", () =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* MyService.Service
|
||||
const out = yield* svc.run()
|
||||
expect(out).toEqual("ok")
|
||||
}),
|
||||
)
|
||||
})
|
||||
```
|
||||
@@ -111,6 +108,7 @@ describe("my service", () => {
|
||||
|
||||
- Use `it.effect(...)` when the test should run with `TestClock` and `TestConsole`.
|
||||
- Use `it.live(...)` when the test depends on real time, filesystem mtimes, child processes, git, locks, or other live OS behavior.
|
||||
- Use `it.instance(...)` for live Effect tests that need a scoped temporary directory and instance context.
|
||||
- Most integration-style tests in this package use `it.live(...)`.
|
||||
|
||||
### Effect Fixtures
|
||||
@@ -122,7 +120,20 @@ Prefer the Effect-aware helpers from `fixture/fixture.ts` instead of building a
|
||||
- `provideTmpdirInstance((dir) => effect, options?)` is the convenience helper. It creates a temp directory, binds it as the active instance, and disposes the instance on cleanup.
|
||||
- `provideTmpdirServer((input) => effect, options?)` does the same, but also provides the test LLM server.
|
||||
|
||||
Use `provideTmpdirInstance(...)` by default when a test only needs one temp instance. Use `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, or needs to switch instance context within one test.
|
||||
Use `it.instance(...)` by default when a test only needs one temp instance. Yield `TestInstance` from `fixture/fixture.ts` when the test needs the temp directory path:
|
||||
|
||||
```typescript
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
|
||||
it.instance("uses the temp directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
expect(test.directory).toContain("opencode-test-")
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Use `provideTmpdirInstance(...)` or `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, needs to switch instance context within one test, or explicitly tests instance disposal/reload lifetime.
|
||||
|
||||
### Style
|
||||
|
||||
@@ -130,4 +141,4 @@ Use `provideTmpdirInstance(...)` by default when a test only needs one temp inst
|
||||
- Keep the test body inside `Effect.gen(function* () { ... })`.
|
||||
- Yield services directly with `yield* MyService.Service` or `yield* MyTool`.
|
||||
- Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` wrappers when `testEffect(...)` already provides the runtime.
|
||||
- When a test needs instance-local state, prefer `provideTmpdirInstance(...)` or `provideInstance(...)` over manual `Instance.provide(...)` inside Promise-style tests.
|
||||
- When a test needs instance-local state, prefer `it.instance(...)` over manual `Instance.provide(...)` inside Promise-style tests.
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ACP } from "../../src/acp/agent"
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||
import type { Event, EventMessagePartUpdated, ToolStatePending, ToolStateRunning } from "@opencode-ai/sdk/v2"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
|
||||
@@ -58,6 +59,7 @@ function toolEvent(
|
||||
raw: opts.raw,
|
||||
}
|
||||
const payload: EventMessagePartUpdated = {
|
||||
id: `evt_${opts.callID}`,
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: sessionId,
|
||||
@@ -262,7 +264,7 @@ function createFakeAgent() {
|
||||
describe("acp.agent event subscription", () => {
|
||||
test("routes message.part.delta by the event sessionID (no cross-session pollution)", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { agent, controller, updates, stop } = createFakeAgent()
|
||||
@@ -297,7 +299,7 @@ describe("acp.agent event subscription", () => {
|
||||
|
||||
test("does not emit user_message_chunk for live prompt parts", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { agent, controller, sessionUpdates, stop } = createFakeAgent()
|
||||
@@ -337,7 +339,7 @@ describe("acp.agent event subscription", () => {
|
||||
|
||||
test("keeps concurrent sessions isolated when message.part.delta events are interleaved", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { agent, controller, chunks, stop } = createFakeAgent()
|
||||
@@ -389,7 +391,7 @@ describe("acp.agent event subscription", () => {
|
||||
|
||||
test("does not create additional event subscriptions on repeated loadSession()", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { agent, calls, stop } = createFakeAgent()
|
||||
@@ -411,7 +413,7 @@ describe("acp.agent event subscription", () => {
|
||||
|
||||
test("permission.asked events are handled and replied", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const permissionReplies: string[] = []
|
||||
@@ -450,7 +452,7 @@ describe("acp.agent event subscription", () => {
|
||||
|
||||
test("permission prompt on session A does not block message updates for session B", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const permissionReplies: string[] = []
|
||||
@@ -537,7 +539,7 @@ describe("acp.agent event subscription", () => {
|
||||
|
||||
test("streams running bash output snapshots and de-dupes identical snapshots", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { agent, controller, sessionUpdates, stop } = createFakeAgent()
|
||||
@@ -571,7 +573,7 @@ describe("acp.agent event subscription", () => {
|
||||
|
||||
test("emits synthetic pending before first running update for any tool", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { agent, controller, sessionUpdates, stop } = createFakeAgent()
|
||||
@@ -616,7 +618,7 @@ describe("acp.agent event subscription", () => {
|
||||
|
||||
test("does not emit duplicate synthetic pending after replayed running tool", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { agent, controller, sessionUpdates, stop, sdk } = createFakeAgent()
|
||||
@@ -675,7 +677,7 @@ describe("acp.agent event subscription", () => {
|
||||
|
||||
test("clears bash snapshot marker on pending state", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { agent, controller, sessionUpdates, stop } = createFakeAgent()
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Effect } from "effect"
|
||||
import path from "path"
|
||||
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
@@ -23,7 +24,7 @@ afterEach(async () => {
|
||||
|
||||
test("returns default native agents when no config", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const agents = await load(tmp.path, (svc) => svc.list())
|
||||
@@ -41,7 +42,7 @@ test("returns default native agents when no config", async () => {
|
||||
|
||||
test("build agent has correct default properties", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await load(tmp.path, (svc) => svc.get("build"))
|
||||
@@ -56,7 +57,7 @@ test("build agent has correct default properties", async () => {
|
||||
|
||||
test("plan agent denies edits except .opencode/plans/*", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const plan = await load(tmp.path, (svc) => svc.get("plan"))
|
||||
@@ -71,7 +72,7 @@ test("plan agent denies edits except .opencode/plans/*", async () => {
|
||||
|
||||
test("explore agent denies edit and write", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const explore = await load(tmp.path, (svc) => svc.get("explore"))
|
||||
@@ -87,7 +88,7 @@ test("explore agent denies edit and write", async () => {
|
||||
test("explore agent asks for external directories and allows whitelisted external paths", async () => {
|
||||
const { Truncate } = await import("../../src/tool/truncate")
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const explore = await load(tmp.path, (svc) => svc.get("explore"))
|
||||
@@ -103,7 +104,7 @@ test("explore agent asks for external directories and allows whitelisted externa
|
||||
|
||||
test("general agent denies todo tools", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const general = await load(tmp.path, (svc) => svc.get("general"))
|
||||
@@ -117,7 +118,7 @@ test("general agent denies todo tools", async () => {
|
||||
|
||||
test("compaction agent denies all permissions", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const compaction = await load(tmp.path, (svc) => svc.get("compaction"))
|
||||
@@ -143,7 +144,7 @@ test("custom agent from config creates new agent", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const custom = await load(tmp.path, (svc) => svc.get("my_custom_agent"))
|
||||
@@ -172,7 +173,7 @@ test("custom agent config overrides native agent properties", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await load(tmp.path, (svc) => svc.get("build"))
|
||||
@@ -195,7 +196,7 @@ test("agent disable removes agent from list", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const explore = await load(tmp.path, (svc) => svc.get("explore"))
|
||||
@@ -221,7 +222,7 @@ test("agent permission config merges with defaults", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await load(tmp.path, (svc) => svc.get("build"))
|
||||
@@ -242,7 +243,7 @@ test("global permission config applies to all agents", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await load(tmp.path, (svc) => svc.get("build"))
|
||||
@@ -261,7 +262,7 @@ test("agent steps/maxSteps config sets steps property", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await load(tmp.path, (svc) => svc.get("build"))
|
||||
@@ -280,7 +281,7 @@ test("agent mode can be overridden", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const explore = await load(tmp.path, (svc) => svc.get("explore"))
|
||||
@@ -297,7 +298,7 @@ test("agent name can be overridden", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await load(tmp.path, (svc) => svc.get("build"))
|
||||
@@ -314,7 +315,7 @@ test("agent prompt can be set from config", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await load(tmp.path, (svc) => svc.get("build"))
|
||||
@@ -334,7 +335,7 @@ test("unknown agent properties are placed into options", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await load(tmp.path, (svc) => svc.get("build"))
|
||||
@@ -357,7 +358,7 @@ test("agent options merge correctly", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await load(tmp.path, (svc) => svc.get("build"))
|
||||
@@ -382,7 +383,7 @@ test("multiple custom agents can be defined", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const agentA = await load(tmp.path, (svc) => svc.get("agent_a"))
|
||||
@@ -411,7 +412,7 @@ test("Agent.list keeps the default agent first and sorts the rest by name", asyn
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const names = (await load(tmp.path, (svc) => svc.list())).map((a) => a.name)
|
||||
@@ -423,7 +424,7 @@ test("Agent.list keeps the default agent first and sorts the rest by name", asyn
|
||||
|
||||
test("Agent.get returns undefined for non-existent agent", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nonExistent = await load(tmp.path, (svc) => svc.get("does_not_exist"))
|
||||
@@ -434,7 +435,7 @@ test("Agent.get returns undefined for non-existent agent", async () => {
|
||||
|
||||
test("default permission includes doom_loop and external_directory as ask", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await load(tmp.path, (svc) => svc.get("build"))
|
||||
@@ -446,7 +447,7 @@ test("default permission includes doom_loop and external_directory as ask", asyn
|
||||
|
||||
test("webfetch is allowed by default", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await load(tmp.path, (svc) => svc.get("build"))
|
||||
@@ -468,7 +469,7 @@ test("legacy tools config converts to permissions", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await load(tmp.path, (svc) => svc.get("build"))
|
||||
@@ -490,7 +491,7 @@ test("legacy tools config maps write/edit/patch to edit permission", async () =>
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await load(tmp.path, (svc) => svc.get("build"))
|
||||
@@ -508,7 +509,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await load(tmp.path, (svc) => svc.get("build"))
|
||||
@@ -521,7 +522,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
|
||||
|
||||
test("global tmp directory children are allowed for external_directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await load(tmp.path, (svc) => svc.get("build"))
|
||||
@@ -546,7 +547,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await load(tmp.path, (svc) => svc.get("build"))
|
||||
@@ -569,7 +570,7 @@ test("explicit Truncate.GLOB deny is respected", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await load(tmp.path, (svc) => svc.get("build"))
|
||||
@@ -601,7 +602,7 @@ description: Permission skill.
|
||||
process.env.OPENCODE_TEST_HOME = tmp.path
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await load(tmp.path, (svc) => svc.get("build"))
|
||||
@@ -617,7 +618,7 @@ description: Permission skill.
|
||||
|
||||
test("defaultAgent returns build when no default_agent config", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const agent = await load(tmp.path, (svc) => svc.defaultAgent())
|
||||
@@ -632,7 +633,7 @@ test("defaultAgent respects default_agent config set to plan", async () => {
|
||||
default_agent: "plan",
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const agent = await load(tmp.path, (svc) => svc.defaultAgent())
|
||||
@@ -652,7 +653,7 @@ test("defaultAgent respects default_agent config set to custom agent with mode a
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const agent = await load(tmp.path, (svc) => svc.defaultAgent())
|
||||
@@ -667,7 +668,7 @@ test("defaultAgent throws when default_agent points to subagent", async () => {
|
||||
default_agent: "explore",
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "explore" is a subagent')
|
||||
@@ -681,7 +682,7 @@ test("defaultAgent throws when default_agent points to hidden agent", async () =
|
||||
default_agent: "compaction",
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "compaction" is hidden')
|
||||
@@ -695,7 +696,7 @@ test("defaultAgent throws when default_agent points to non-existent agent", asyn
|
||||
default_agent: "does_not_exist",
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow(
|
||||
@@ -713,7 +714,7 @@ test("defaultAgent returns plan when build is disabled and default_agent not set
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const agent = await load(tmp.path, (svc) => svc.defaultAgent())
|
||||
@@ -732,7 +733,7 @@ test("defaultAgent throws when all primary agents are disabled", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// build and plan are disabled, no primary-capable agents remain
|
||||
|
||||
@@ -4,6 +4,7 @@ import { pathToFileURL } from "url"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -39,7 +40,7 @@ test("plugin-registered agents appear in Agent.list", async () => {
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user