Compare commits

..

3 Commits

Author SHA1 Message Date
Kit Langton c2def7e471 feat(plugin): cursor pagination for plugin session list and messages 2026-07-31 15:23:21 -04:00
Kit Langton 3c87211ef6 refactor(schema): move session pagination cursors to schema 2026-07-31 15:23:21 -04:00
Kit Langton a3f3c4951d feat(plugin): expose list, messages, rename, and wait to plugins 2026-07-31 15:01:50 -04:00
12 changed files with 288 additions and 170 deletions
+39
View File
@@ -4,6 +4,8 @@ import { Plugin } from "@opencode-ai/plugin/effect"
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { SessionMessagesCursor } from "@opencode-ai/schema/session-messages-cursor"
import { SessionsQuery } from "@opencode-ai/schema/sessions-query"
import { App } from "../app"
import { Effect, Schema, Stream } from "effect"
import { Agent } from "../agent"
@@ -341,17 +343,54 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
create: (input) =>
runtime.session.create({
id: input?.id,
title: input?.title,
agent: input?.agent,
model: input?.model,
location:
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
}),
get: (input) => runtime.session.get(input.sessionID),
list: (input) =>
Effect.gen(function* () {
const query =
input?.cursor !== undefined
? yield* SessionsQuery.Cursor.parse(input.cursor).pipe(
Effect.mapError(() => new Error("Invalid cursor")),
)
: (input ?? {})
const page = yield* runtime.session.list({
...query,
workspaceID: query.workspace,
limit: input?.limit ?? SessionsQuery.DefaultLimit,
})
return { data: page.data, cursor: SessionsQuery.Cursor.page(query, page.data) }
}),
messages: (input) =>
Effect.gen(function* () {
if (input.cursor !== undefined && input.order !== undefined)
return yield* Effect.fail(new Error("Cursor cannot be combined with order"))
const decoded =
input.cursor !== undefined
? yield* SessionMessagesCursor.parse(input.cursor).pipe(
Effect.mapError(() => new Error("Invalid cursor")),
)
: undefined
const order = decoded?.order ?? input.order ?? "desc"
const data = yield* runtime.session.messages({
sessionID: input.sessionID,
limit: input.limit ?? SessionMessagesCursor.DefaultLimit,
order,
cursor: decoded ? { id: decoded.id, direction: decoded.direction } : undefined,
})
return { data, cursor: SessionMessagesCursor.page(data, order) }
}),
prompt: runtime.session.prompt,
generate: (input) => runtime.session.generate(input).pipe(Effect.map((text) => ({ text }))),
command: runtime.session.command,
rename: runtime.session.rename,
synthetic: runtime.session.synthetic,
interrupt: (input) => runtime.session.interrupt(input.sessionID),
wait: (input) => runtime.session.wait(input.sessionID),
},
} satisfies Plugin.Context
})
+15 -1
View File
@@ -11,7 +11,18 @@ import { Session } from "../session"
export interface Interface {
readonly session: Pick<
Session.Interface,
"get" | "create" | "messages" | "prompt" | "generate" | "command" | "resume" | "interrupt" | "synthetic"
| "get"
| "create"
| "list"
| "messages"
| "prompt"
| "generate"
| "command"
| "rename"
| "resume"
| "interrupt"
| "synthetic"
| "wait"
>
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
readonly location: {
@@ -48,13 +59,16 @@ export const layerWithCell = (cell: Cell) =>
session: {
get: (sessionID) => require(cell, (runtime) => runtime.session.get(sessionID)),
create: (input) => require(cell, (runtime) => runtime.session.create(input)),
list: (input) => require(cell, (runtime) => runtime.session.list(input)),
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
generate: (input) => require(cell, (runtime) => runtime.session.generate(input)),
command: (input) => require(cell, (runtime) => runtime.session.command(input)),
rename: (input) => require(cell, (runtime) => runtime.session.rename(input)),
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
wait: (sessionID) => require(cell, (runtime) => runtime.session.wait(sessionID)),
},
job: {
start: (input) => require(cell, (runtime) => runtime.job.start(input)),
+4
View File
@@ -103,11 +103,15 @@ export function host(overrides: Overrides = {}): Plugin.Context {
hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")),
create: overrides.session?.create ?? (() => Effect.die("unused session.create")),
get: overrides.session?.get ?? (() => Effect.die("unused session.get")),
list: overrides.session?.list ?? (() => Effect.die("unused session.list")),
messages: overrides.session?.messages ?? (() => Effect.die("unused session.messages")),
prompt: overrides.session?.prompt ?? (() => Effect.die("unused session.prompt")),
generate: overrides.session?.generate ?? (() => Effect.die("unused session.generate")),
command: overrides.session?.command ?? (() => Effect.die("unused session.command")),
rename: overrides.session?.rename ?? (() => Effect.die("unused session.rename")),
synthetic: overrides.session?.synthetic ?? (() => Effect.die("unused session.synthetic")),
interrupt: overrides.session?.interrupt ?? (() => Effect.die("unused session.interrupt")),
wait: overrides.session?.wait ?? (() => Effect.die("unused session.wait")),
},
}
}
+4 -2
View File
@@ -1,4 +1,4 @@
import type { SessionApi } from "@opencode-ai/client/effect/api"
import type { MessageApi, SessionApi } from "@opencode-ai/client/effect/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { HttpRequest } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent"
@@ -29,7 +29,9 @@ export interface SessionHooks {
export type SessionDomain = Pick<
SessionApi<unknown>,
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt"
"create" | "get" | "list" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
> & {
readonly hook: Hooks<SessionHooks>
/** Read a session's projected message history, paginated like the HTTP message list endpoint. */
readonly messages: MessageApi<unknown>["list"]
}
+5 -85
View File
@@ -4,7 +4,8 @@ import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Session } from "@opencode-ai/schema/session"
import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
import { SessionsQuery } from "@opencode-ai/schema/sessions-query"
import { Event } from "@opencode-ai/schema/event"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Context, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
@@ -29,76 +30,6 @@ import { Location } from "@opencode-ai/schema/location"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { EventLog } from "@opencode-ai/schema/event-log"
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 = {
workspace: Workspace.ID.pipe(Schema.optional),
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
const SessionActive = Schema.Struct({
type: Schema.Literal("running"),
@@ -111,28 +42,17 @@ 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 = 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>(sessionLocationMiddleware: Context.Key<I, S>) =>
HttpApiGroup.make("server.session")
.add(
HttpApiEndpoint.get("session.list", "/api/session", {
query: SessionsQuery,
query: SessionsQuery.Query,
success: Schema.Struct({
data: Schema.Array(Session.Info),
cursor: Schema.Struct({
previous: SessionsCursor.pipe(Schema.optional),
next: SessionsCursor.pipe(Schema.optional),
previous: SessionsQuery.Cursor.pipe(Schema.optional),
next: SessionsQuery.Cursor.pipe(Schema.optional),
}),
}).annotate({ identifier: "SessionsResponse" }),
error: [InvalidCursorError, InvalidRequestError],
@@ -1,18 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { SessionsCursor } from "../src/groups/session.js"
import { Session } from "@opencode-ai/schema/session"
describe("SessionsCursor", () => {
test("round trips without Node globals", async () => {
const input = {
workspace: undefined,
search: "protocol",
order: "desc" as const,
anchor: { id: Session.ID.make("ses_test"), time: 1, direction: "next" as const },
}
const cursor = SessionsCursor.make(input)
expect(await Effect.runPromise(SessionsCursor.parse(cursor))).toEqual(input)
})
})
@@ -0,0 +1,48 @@
export * as SessionMessagesCursor from "./session-messages-cursor.js"
import { Effect, Encoding, Result, Schema } from "effect"
import { SessionMessage } from "./session-message.js"
/**
* Shared session message pagination cursor. Lives in schema so the protocol
* message handler and the core plugin host paginate identically. The encoded
* cursor carries the page order, so a cursor cannot be combined with an
* explicit order.
*/
export const DefaultLimit = 50
export const Order = Schema.Literals(["asc", "desc"])
export type Order = typeof Order.Type
const Payload = Schema.Struct({
id: SessionMessage.ID,
order: Order,
direction: Schema.Literals(["previous", "next"]),
})
export type Payload = typeof Payload.Type
const PayloadJson = Schema.fromJsonString(Payload)
const encodePayload = Schema.encodeSync(PayloadJson)
const decodePayload = Schema.decodeUnknownEffect(PayloadJson)
const invalidCursor = "Invalid cursor" as const
export const make = (input: Payload) => Encoding.encodeBase64Url(encodePayload(input))
export const parse = (input: string) =>
Effect.suspend(() => {
const result = Encoding.decodeBase64UrlString(input)
return Result.isFailure(result)
? Effect.fail(invalidCursor)
: decodePayload(result.success).pipe(Effect.mapError(() => invalidCursor))
})
/** previous/next cursors for one returned page of messages. */
export const page = (data: ReadonlyArray<SessionMessage.Info>, order: Order) => {
const first = data[0]
const last = data.at(-1)
return {
previous: first ? make({ id: first.id, order, direction: "previous" }) : undefined,
next: last ? make({ id: last.id, order, direction: "next" }) : undefined,
}
}
+125
View File
@@ -0,0 +1,125 @@
export * as SessionsQuery from "./sessions-query.js"
import { DateTime, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
import { Project } from "./project.js"
import { AbsolutePath, PositiveInt, RelativePath, statics } from "./schema.js"
import { Session } from "./session.js"
import { Workspace } from "./workspace.js"
/**
* Shared session list query and cursor codec. Lives in schema so both the
* protocol session group and the core plugin host paginate identically.
*/
export const DefaultLimit = 50
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.",
})
export const Fields = {
workspace: Workspace.ID.pipe(Schema.optional),
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({
...Fields,
directory: AbsolutePath,
})
const ProjectQuery = Schema.Struct({
...Fields,
project: Project.ID,
subpath: RelativePath.pipe(Schema.optional),
})
const AllQuery = Schema.Struct(Fields)
const withAnchor = <Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) =>
schema.mapFields((fields) => ({
...Struct.omit(fields, ["limit"]),
anchor: Session.ListAnchor,
}))
const CursorInput = Schema.Union([withAnchor(DirectoryQuery), withAnchor(ProjectQuery), withAnchor(AllQuery)])
const CursorJson = Schema.fromJsonString(CursorInput)
const encodeCursor = Schema.encodeSync(CursorJson)
const decodeCursor = Schema.decodeUnknownEffect(CursorJson)
const invalidCursor = "Invalid cursor" as const
type PageQuery = Omit<typeof Query.Type, "limit" | "cursor">
export const Cursor = Schema.String.pipe(
Schema.brand("SessionsCursor"),
statics((schema) => {
const make = schema.make.bind(schema)
const makeCursor = (input: typeof CursorInput.Type) => make(Encoding.encodeBase64Url(encodeCursor(input)))
return {
make: makeCursor,
parse: (input: string) =>
Effect.suspend(() => {
const result = Encoding.decodeBase64UrlString(input)
return Result.isFailure(result)
? Effect.fail(invalidCursor)
: decodeCursor(result.success).pipe(Effect.mapError(() => invalidCursor))
}),
/**
* previous/next cursors for one returned page, re-anchoring the
* originating query at the page's first and last items.
*/
page: (query: PageQuery, data: ReadonlyArray<Session.Info>) => {
const first = data[0]
const last = data.at(-1)
return {
previous: first
? makeCursor({
...query,
anchor: {
id: first.id,
time: DateTime.toEpochMillis(first.time.updated),
direction: "previous",
},
})
: undefined,
next: last
? makeCursor({
...query,
anchor: {
id: last.id,
time: DateTime.toEpochMillis(last.time.updated),
direction: "next",
},
})
: undefined,
}
},
}
}),
)
export type Cursor = typeof Cursor.Type
export const Query = Schema.Struct({
...Fields,
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" })
export type Query = typeof Query.Type
@@ -0,0 +1,33 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { SessionsQuery } from "../src/sessions-query.js"
import { SessionMessagesCursor } from "../src/session-messages-cursor.js"
import { SessionMessage } from "../src/session-message.js"
import { Session } from "../src/session.js"
describe("SessionsQuery.Cursor", () => {
test("round trips without Node globals", async () => {
const input = {
workspace: undefined,
search: "protocol",
order: "desc" as const,
anchor: { id: Session.ID.make("ses_test"), time: 1, direction: "next" as const },
}
const cursor = SessionsQuery.Cursor.make(input)
expect(await Effect.runPromise(SessionsQuery.Cursor.parse(cursor))).toEqual(input)
})
})
describe("SessionMessagesCursor", () => {
test("round trips", async () => {
const input = {
id: SessionMessage.ID.make("msg_test"),
order: "desc" as const,
direction: "next" as const,
}
const cursor = SessionMessagesCursor.make(input)
expect(await Effect.runPromise(SessionMessagesCursor.parse(cursor))).toEqual(input)
})
})
+9 -32
View File
@@ -1,29 +1,10 @@
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Session } from "@opencode-ai/core/session"
import { Effect, Schema } from "effect"
import { SessionMessagesCursor } from "@opencode-ai/schema/session-messages-cursor"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "@opencode-ai/protocol/errors"
const DefaultMessagesLimit = 50
const Cursor = Schema.Struct({
id: SessionMessage.ID,
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.Info, order: "asc" | "desc", direction: "previous" | "next") {
return Buffer.from(JSON.stringify({ id: message.id, order, direction })).toString("base64url")
},
decode(input: string) {
return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8")))
},
}
export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handlers) =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -33,15 +14,16 @@ export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handl
Effect.fn(function* (ctx) {
if (ctx.query.cursor && ctx.query.order !== undefined)
return yield* new InvalidCursorError({ message: "Cursor cannot be combined with order" })
const decoded = yield* Effect.try({
try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined),
catch: () => new InvalidCursorError({ message: "Invalid cursor" }),
})
const decoded = ctx.query.cursor
? yield* SessionMessagesCursor.parse(ctx.query.cursor).pipe(
Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })),
)
: undefined
const order = decoded?.order ?? ctx.query.order ?? "desc"
const messages = yield* session
.messages({
sessionID: ctx.params.sessionID,
limit: ctx.query.limit ?? DefaultMessagesLimit,
limit: ctx.query.limit ?? SessionMessagesCursor.DefaultLimit,
order,
cursor: decoded ? { id: decoded.id, direction: decoded.direction } : undefined,
})
@@ -66,14 +48,9 @@ export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handl
)
}),
)
const first = messages[0]
const last = messages.at(-1)
return {
data: messages,
cursor: {
previous: first ? cursor.encode(first, order, "previous") : undefined,
next: last ? cursor.encode(last, order, "next") : undefined,
},
cursor: SessionMessagesCursor.page(messages, order),
}
}),
)
+5 -31
View File
@@ -3,7 +3,7 @@ import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
import { DateTime, Effect, Stream } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { SessionsCursor } from "@opencode-ai/protocol/groups/session"
import { SessionsQuery } from "@opencode-ai/schema/sessions-query"
import {
ConflictError,
CommandEvaluationError,
@@ -19,8 +19,6 @@ import {
} from "@opencode-ai/protocol/errors"
import { AbsolutePath } from "@opencode-ai/core/schema"
const DefaultSessionsLimit = 50
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -31,42 +29,18 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
Effect.fn(function* (ctx) {
const query =
ctx.query.cursor !== undefined
? yield* SessionsCursor.parse(ctx.query.cursor).pipe(
? yield* SessionsQuery.Cursor.parse(ctx.query.cursor).pipe(
Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })),
)
: ctx.query
const page = yield* session.list({
...query,
workspaceID: query.workspace,
limit: ctx.query.limit ?? DefaultSessionsLimit,
limit: ctx.query.limit ?? SessionsQuery.DefaultLimit,
})
const sessions = page.data
const first = sessions[0]
const last = sessions.at(-1)
return {
data: sessions,
cursor: {
previous: first
? SessionsCursor.make({
...query,
anchor: {
id: first.id,
time: DateTime.toEpochMillis(first.time.updated),
direction: "previous",
},
})
: undefined,
next: last
? SessionsCursor.make({
...query,
anchor: {
id: last.id,
time: DateTime.toEpochMillis(last.time.updated),
direction: "next",
},
})
: undefined,
},
data: page.data,
cursor: SessionsQuery.Cursor.page(query, page.data),
}
}),
)
+1 -1
View File
@@ -177,7 +177,7 @@ and plugin options.
| `ctx.integration` | `list`, `get`, `connect`, `attempt`, `transform`, `reload`, and connection lookup/resolution |
| `ctx.plugin` | `list` currently active plugin IDs |
| `ctx.reference` | `list`, `transform`, `reload` |
| `ctx.session` | `create`, `get`, `prompt`, `command`, `synthetic`, `interrupt`, and `hook` |
| `ctx.session` | `create`, `get`, `list`, `messages`, `prompt`, `command`, `rename`, `synthetic`, `interrupt`, `wait`, and `hook` |
| `ctx.skill` | `list`, `transform`, `reload` |
| `ctx.tool` | `transform` and `hook` |
| `ctx.aisdk` | `hook` |