Compare commits

...
Author SHA1 Message Date
Brendonovich d9af025ec3 feat(plugin): expose session list and active reads 2026-10-02 06:13:29 +00:00
9 changed files with 205 additions and 99 deletions

No files matched your search

+35 -1
View File
@@ -1,12 +1,13 @@
export * as PluginHost from "./host.js"
import { Plugin } from "@opencode/plugin/effect"
import { SessionList } from "@opencode/schema/session-list"
import type { IntegrationMethodRegistration } from "@opencode/plugin/effect/integration"
import { EventManifest } from "@opencode/schema/event-manifest"
import type { Event } from "@opencode/schema/event"
import { ServerConfig } from "@opencode/schema/mcp"
import { App } from "../app.js"
import { Effect, Schema, Stream } from "effect"
import { DateTime, Effect, Schema, Stream } from "effect"
import { Agent } from "../agent.js"
import { AISDK } from "../aisdk.js"
import { Command } from "../command.js"
@@ -526,6 +527,39 @@ export const make = Effect.fn("PluginHost.make")(function* (
},
session: {
hook: (name, callback, options) => hooks.register("session", name, callback, options),
list: (input) =>
Effect.gen(function* () {
const query =
input?.cursor === undefined
? (input ?? {})
: yield* SessionList.Cursor.parse(input.cursor).pipe(Effect.mapError(() => new Error("Invalid cursor")))
const page = yield* sessions.list({ ...query, limit: input?.limit ?? 50 })
const first = page.data[0]
const last = page.data.at(-1)
return {
data: page.data,
cursor: {
previous: first
? SessionList.Cursor.make({
...query,
anchor: { id: first.id, time: DateTime.toEpochMillis(first.time.updated), direction: "previous" },
})
: undefined,
next: last
? SessionList.Cursor.make({
...query,
anchor: { id: last.id, time: DateTime.toEpochMillis(last.time.updated), direction: "next" },
})
: undefined,
},
}
}),
active: () =>
sessions.active.pipe(
Effect.map((active) =>
Object.fromEntries(Array.from(active, (sessionID) => [sessionID, { type: "running" as const }])),
),
),
create: (input) =>
sessions.create({
id: input?.id,
@@ -0,0 +1,58 @@
import { expect } from "bun:test"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { PluginPromise } from "@opencode/core/plugin/promise"
import { Session } from "@opencode/core/session"
import { Effect } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
it.live("Effect plugins list filtered sessions with server-style cursors", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const context = yield* PluginHost.make(plugins)
const first = yield* context.session.create({ title: "match one" })
yield* context.session.create({ title: "different" })
const second = yield* context.session.create({ title: "match two" })
const query = { directory: context.location.directory, limit: 1, order: "asc" as const, search: "match" }
const page = yield* context.session.list(query)
expect(page.data.map((session) => session.id)).toEqual([first.id])
expect(page.cursor.next).toBeDefined()
const next = yield* context.session.list({ cursor: page.cursor.next, limit: 1 })
expect(next.data.map((session) => session.id)).toEqual([second.id])
const previous = yield* context.session.list({ cursor: next.cursor.previous, limit: 1 })
expect(previous.data.map((session) => session.id)).toEqual([first.id])
expect(
yield* context.session.list({ cursor: "invalid" as NonNullable<typeof page.cursor.next> }).pipe(Effect.flip),
).toMatchObject({ message: "Invalid cursor" })
}),
)
it.live("Promise plugins list sessions and report active sessions", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const sessions = yield* Session.Service
const context = yield* PluginHost.make(plugins)
const created = yield* context.session.create({ title: "running" })
const active = yield* PluginHost.make(plugins).pipe(
Effect.provideService(
Session.Service,
Session.Service.of({ ...sessions, active: Effect.succeed(new Set([created.id])) }),
),
)
yield* PluginPromise.fromPromise({
id: "test.session-list-active",
async setup(ctx) {
const page = await ctx.session.list({ directory: ctx.location.directory, search: "running", limit: 1 })
expect(page.data.map((session) => session.id)).toEqual([created.id])
expect(page.cursor.next).toBeDefined()
expect(await ctx.session.list({ cursor: page.cursor.next, limit: 1 })).toMatchObject({ data: [] })
expect(await ctx.session.active()).toEqual({ [created.id]: { type: "running" } })
},
}).effect(active)
}),
)
+2
View File
@@ -152,6 +152,8 @@ export interface SessionHooks {
export type SessionDomain = Pick<
SessionApi<unknown>,
| "list"
| "active"
| "create"
| "get"
| "remove"
+2
View File
@@ -579,6 +579,8 @@ export function fromPromise(plugin: Plugin) {
register(
host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))), options),
),
list: adaptApiMethod(SessionEndpoints["session.list"], host.session.list),
active: adaptApiMethod(SessionEndpoints["session.active"], host.session.active),
create: adaptApiMethod(SessionEndpoints["session.create"], host.session.create),
get: adaptApiMethod(SessionEndpoints["session.get"], host.session.get),
remove: adaptApiMethod(SessionEndpoints["session.remove"], host.session.remove),
+2
View File
@@ -152,6 +152,8 @@ export interface SessionHooks {
export type SessionDomain = Pick<
SessionApi,
| "list"
| "active"
| "create"
| "get"
| "remove"
+11 -98
View File
@@ -5,16 +5,10 @@ import { Session } from "@opencode/schema/session"
import { SessionStats } from "@opencode/schema/session-stats"
import { InstructionEntry } from "@opencode/schema/instruction-entry"
import { Project } from "@opencode/schema/project"
import {
AbsolutePath,
DateTimeUtcFromMillis,
NonNegativeInt,
PositiveInt,
RelativePath,
statics,
} from "@opencode/schema/schema"
import { AbsolutePath, DateTimeUtcFromMillis, NonNegativeInt } from "@opencode/schema/schema"
import { SessionList } from "@opencode/schema/session-list"
import { Event } from "@opencode/schema/event"
import { Context, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
import { Context, Effect, Schema, SchemaGetter, Struct } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import {
ConflictError,
@@ -43,75 +37,8 @@ import { FileDiff } from "@opencode/schema/file-diff"
import { Form } from "@opencode/schema/form"
import { PublicSessionMessage } from "./message.js"
const ParentIDFilter = Schema.Union([
Session.ID,
Schema.Null.pipe(
Schema.encodeTo(Schema.Literal("null"), {
decode: SchemaGetter.transform(() => null),
encode: SchemaGetter.transform(() => "null" as const),
}),
),
]).annotate({
description: "Filter by parent session. Use null to return only root sessions.",
})
const SessionsQueryFields = {
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).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.",
}),
search: Schema.optional(Schema.String),
parentID: ParentIDFilter.pipe(Schema.optional),
}
const SessionsDirectoryQuery = Schema.Struct({
...SessionsQueryFields,
directory: AbsolutePath,
})
const SessionsProjectQuery = Schema.Struct({
...SessionsQueryFields,
project: Project.ID,
subpath: RelativePath.pipe(Schema.optional),
})
const SessionsAllQuery = Schema.Struct(SessionsQueryFields)
const withCursor = <Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) =>
schema.mapFields((fields) => ({
...Struct.omit(fields, ["limit"]),
anchor: Session.ListAnchor,
}))
const SessionsCursorInput = Schema.Union([
withCursor(SessionsDirectoryQuery),
withCursor(SessionsProjectQuery),
withCursor(SessionsAllQuery),
])
const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput)
const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson)
const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson)
const invalidCursor = "Invalid cursor" as const
export const SessionsCursor = Schema.String.pipe(
Schema.brand("SessionsCursor"),
statics((schema) => {
const make = schema.make.bind(schema)
return {
make: (input: typeof SessionsCursorInput.Type) => make(Encoding.encodeBase64Url(encodeSessionsCursor(input))),
parse: (input: string) =>
Effect.suspend(() => {
const result = Encoding.decodeBase64UrlString(input)
return Result.isFailure(result)
? Effect.fail(invalidCursor)
: decodeSessionsCursor(result.success).pipe(Effect.mapError(() => invalidCursor))
}),
}
}),
)
export type SessionsCursor = typeof SessionsCursor.Type
export const SessionsCursor = SessionList.Cursor
export type SessionsCursor = SessionList.Cursor
const SessionActive = Schema.Struct({
type: Schema.Literal("running"),
@@ -158,24 +85,12 @@ const BooleanFromString = Schema.Literals(["true", "false"]).pipe(
}),
)
const SessionsQueryCursor = SessionsCursor.annotate({
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
})
export const SessionsQuery = SessionList.Query
export const SessionsQuery = Schema.Struct({
...SessionsQueryFields,
directory: AbsolutePath.pipe(Schema.optional),
project: Project.ID.pipe(Schema.optional),
subpath: RelativePath.pipe(Schema.optional),
cursor: SessionsQueryCursor.pipe(Schema.optional),
}).annotate({ identifier: "SessionsQuery" })
export const makeSessionGroup = <
I extends HttpApiMiddleware.AnyId,
S,
FormI extends HttpApiMiddleware.AnyId,
FormS,
>(sessionLocationMiddleware: Context.Key<I, S>, formLocationMiddleware: Context.Key<FormI, FormS>) =>
export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S, FormI extends HttpApiMiddleware.AnyId, FormS>(
sessionLocationMiddleware: Context.Key<I, S>,
formLocationMiddleware: Context.Key<FormI, FormS>,
) =>
HttpApiGroup.make("server.session")
.add(
HttpApiEndpoint.get("session.list", "/api/session", {
@@ -562,9 +477,7 @@ export const makeSessionGroup = <
error: [SessionNotFoundError, SessionBusyError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({ identifier: "session.revert.commit", summary: "Commit staged revert" }),
),
.annotateMerge(OpenApi.annotations({ identifier: "session.revert.commit", summary: "Commit staged revert" })),
)
.add(
HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", {
+70
View File
@@ -0,0 +1,70 @@
export * as SessionList from "./session-list.js"
import { Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
import { Project } from "./project.js"
import { Session } from "./session.js"
import { AbsolutePath, PositiveInt, RelativePath, statics } from "./schema.js"
const ParentIDFilter = Schema.Union([
Session.ID,
Schema.Null.pipe(
Schema.encodeTo(Schema.Literal("null"), {
decode: SchemaGetter.transform(() => null),
encode: SchemaGetter.transform(() => "null" as const),
}),
),
]).annotate({
description: "Filter by parent session. Use null to return only root sessions.",
})
const QueryFields = {
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).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.",
}),
search: Schema.optional(Schema.String),
parentID: ParentIDFilter.pipe(Schema.optional),
}
const DirectoryQuery = Schema.Struct({ ...QueryFields, directory: AbsolutePath })
const ProjectQuery = Schema.Struct({ ...QueryFields, project: Project.ID, subpath: RelativePath.pipe(Schema.optional) })
const AllQuery = Schema.Struct(QueryFields)
const withCursor = <Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) =>
schema.mapFields((fields) => ({ ...Struct.omit(fields, ["limit"]), anchor: Session.ListAnchor }))
const CursorInput = Schema.Union([withCursor(DirectoryQuery), withCursor(ProjectQuery), withCursor(AllQuery)])
const CursorJson = Schema.fromJsonString(CursorInput)
const encodeCursor = Schema.encodeSync(CursorJson)
const decodeCursor = Schema.decodeUnknownEffect(CursorJson)
const invalidCursor = "Invalid cursor" as const
export const Cursor = Schema.String.pipe(
Schema.brand("SessionsCursor"),
statics((schema) => {
const make = schema.make.bind(schema)
return {
make: (input: typeof CursorInput.Type) => make(Encoding.encodeBase64Url(encodeCursor(input))),
parse: (input: string) =>
Effect.suspend(() => {
const result = Encoding.decodeBase64UrlString(input)
return Result.isFailure(result)
? Effect.fail(invalidCursor)
: decodeCursor(result.success).pipe(Effect.mapError(() => invalidCursor))
}),
}
}),
)
export type Cursor = typeof Cursor.Type
export const Query = Schema.Struct({
...QueryFields,
directory: AbsolutePath.pipe(Schema.optional),
project: Project.ID.pipe(Schema.optional),
subpath: RelativePath.pipe(Schema.optional),
cursor: Cursor.annotate({
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
}).pipe(Schema.optional),
}).annotate({ identifier: "SessionsQuery" })
@@ -785,6 +785,18 @@ interface ReferenceDomain extends ReferenceApi<unknown> {
### Sessions
List sessions with server-style filters and cursor pagination. `active()` reports the sessions running in this server
process.
```ts
effect: (ctx) =>
Effect.gen(function* () {
const page = yield* ctx.session.list({ directory: ctx.location.directory, limit: 20, order: "desc" })
const next = page.cursor.next ? yield* ctx.session.list({ cursor: page.cursor.next, limit: 20 }) : undefined
const active = yield* ctx.session.active()
}),
```
Create or read a session, then select the agent and model used by later work.
```ts
@@ -849,6 +861,8 @@ Schemas: [`Session.Info`](/api#schema-Session.Info), [`Model.Ref`](/api#schema-M
```ts
type SessionDomain = Pick<
SessionApi<unknown>,
| "list"
| "active"
| "create"
| "get"
| "remove"
@@ -742,6 +742,15 @@ await ctx.permission.rules({
### Sessions
List sessions with the same filters and cursor pagination as the server API. The cursor preserves the filters and order;
pass `cursor.next` to load the next page. `active()` returns the sessions currently running in this server process.
```ts
const page = await ctx.session.list({ directory: ctx.location.directory, limit: 20, order: "desc" })
const next = page.cursor.next ? await ctx.session.list({ cursor: page.cursor.next, limit: 20 }) : undefined
const active = await ctx.session.active()
```
Create or read a session.
```ts
@@ -794,6 +803,8 @@ Schemas: [`Session.Info`](/api#schema-Session.Info), [`Model.Ref`](/api#schema-M
```ts
interface SessionContext {
list(input?: SessionListInput, requestOptions?: RequestOptions): Promise<SessionListOutput>
active(requestOptions?: RequestOptions): Promise<SessionActiveOutput>
create(input?: SessionCreateInput, requestOptions?: RequestOptions): Promise<SessionInfo>
get(input: SessionGetInput, requestOptions?: RequestOptions): Promise<SessionInfo>
remove(input: SessionRemoveInput, requestOptions?: RequestOptions): Promise<void>