feat(plugin): expose experimental terminal reads (#45792)

This commit is contained in:
James Long
2026-08-28 08:03:35 -04:00
committed by GitHub
parent 1c8e557eb4
commit ca47949475
23 changed files with 508 additions and 6 deletions
+18
View File
@@ -1639,6 +1639,23 @@ export interface PtyApi<E = never> {
readonly connect: { readonly token: PtyConnectTokenOperation<E> }
}
export type ExperimentalPersistentPtyReadInput = { readonly sessionID: Session.ID; readonly lines?: number | undefined }
export type ExperimentalPersistentPtyReadOutput = {
readonly ptyID: Pty.ID
readonly title: string
readonly cwd: string
readonly foregroundProcess: string | null
readonly screen: {
readonly text: string
readonly cols: number
readonly rows: number
readonly cursor: { readonly x: number; readonly y: number }
}
} | null
export type ExperimentalPersistentPtyReadOperation<E = never> = (
input: ExperimentalPersistentPtyReadInput,
) => Effect.Effect<ExperimentalPersistentPtyReadOutput, E>
export type ExperimentalPersistentPtyListInput = { readonly sessionID: Session.ID }
export type ExperimentalPersistentPtyListOutput = ReadonlyArray<{
readonly id: Pty.ID
@@ -1787,6 +1804,7 @@ export type ExperimentalPersistentPtyConnectTokenOperation<E = never> = (
export interface ExperimentalApi<E = never> {
readonly persistentPty: {
readonly read: ExperimentalPersistentPtyReadOperation<E>
readonly list: ExperimentalPersistentPtyListOperation<E>
readonly create: ExperimentalPersistentPtyCreateOperation<E>
readonly shutdown: ExperimentalPersistentPtyShutdownOperation<E>
@@ -198,6 +198,8 @@ import type {
PtyRemoveOutput,
PtyConnectTokenInput,
PtyConnectTokenOutput,
ExperimentalPersistentPtyReadInput,
ExperimentalPersistentPtyReadOutput,
ExperimentalPersistentPtyListInput,
ExperimentalPersistentPtyListOutput,
ExperimentalPersistentPtyCreateInput,
@@ -1234,6 +1236,15 @@ const adaptGroupPty = (raw: RawClient["server.pty"]) => ({
connect: { token: EndpointPtyConnectToken(raw) },
})
const EndpointExperimentalPersistentPtyRead =
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyReadInput) =>
preserveEffect<ExperimentalPersistentPtyReadOutput>()(
raw["persistentPty.read"]({ params: { sessionID: input["sessionID"] }, query: { lines: input["lines"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointExperimentalPersistentPtyList =
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyListInput) =>
preserveEffect<ExperimentalPersistentPtyListOutput>()(
@@ -1322,6 +1333,7 @@ const EndpointExperimentalPersistentPtyConnectToken =
const adaptGroupExperimental = (raw: RawClient["server.experimental"]) => ({
persistentPty: {
read: EndpointExperimentalPersistentPtyRead(raw),
list: EndpointExperimentalPersistentPtyList(raw),
create: EndpointExperimentalPersistentPtyCreate(raw),
shutdown: EndpointExperimentalPersistentPtyShutdown(raw),
@@ -194,6 +194,8 @@ import type {
PtyRemoveOutput,
PtyConnectTokenInput,
PtyConnectTokenOutput,
ExperimentalPersistentPtyReadInput,
ExperimentalPersistentPtyReadOutput,
ExperimentalPersistentPtyListInput,
ExperimentalPersistentPtyListOutput,
ExperimentalPersistentPtyCreateInput,
@@ -1684,6 +1686,18 @@ export function make(options: ClientOptions) {
},
experimental: {
persistentPty: {
read: (input: ExperimentalPersistentPtyReadInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ExperimentalPersistentPtyReadOutput }>(
{
method: "GET",
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/terminal/read`,
query: { lines: input["lines"] },
successStatus: 200,
declaredStatuses: [503, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
list: (input: ExperimentalPersistentPtyListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ExperimentalPersistentPtyListOutput }>(
{
@@ -378,6 +378,14 @@ export type SessionStatus =
export type PtyTicketConnectToken = { ticket: string; expires_in: number }
export type PersistentPtyReadResult = {
ptyID: string
title: string
cwd: string
foregroundProcess: string | null
screen: { text: string; cols: number; rows: number; cursor: { x: number; y: number } }
}
export type PersistentPtyHandoff = { directory: string; instanceID: string; ticket: string; expiresAt: number }
export type ShellInfo1 = {
@@ -5769,6 +5777,13 @@ export type PtyConnectTokenOutput = {
data: PtyTicketConnectToken
}
export type ExperimentalPersistentPtyReadInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly lines?: { readonly lines?: number | undefined }["lines"]
}
export type ExperimentalPersistentPtyReadOutput = { data: PersistentPtyReadResult | null }["data"]
export type ExperimentalPersistentPtyListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type ExperimentalPersistentPtyListOutput = { data: Array<PersistentPtyInfo> }["data"]
+2
View File
@@ -50,6 +50,8 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.vcs)).toEqual(["get", "status", "branches", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"])
expect(Object.keys(client.pty.connect)).toEqual(["token"])
expect(Object.keys(client.experimental)).toEqual(["persistentPty"])
expect(client.experimental.persistentPty.read).toBeFunction()
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["list", "update", "current"])
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
@@ -59,6 +59,13 @@ export const WireResponse = Schema.Union([
cursor_x: Schema.Number,
cursor_y: Schema.Number,
}),
Schema.Struct({
type: Schema.Literal("rows"),
terminal: WireTerminal,
lines: Schema.Array(Schema.String),
cursor_x: Schema.Number,
cursor_y: Schema.Number,
}),
Schema.Struct({
type: Schema.Literal("attached"),
terminal: WireTerminal,
+42 -4
View File
@@ -4,7 +4,7 @@ import os from "node:os"
import path from "node:path"
import { Context, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Added, Handoff, Removed } from "@opencode-ai/schema/persistent-pty"
import { Added, Handoff, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
import { Session } from "@opencode-ai/schema/session"
import { Bus } from "../bus.js"
import { Pty } from "@opencode-ai/schema/pty"
@@ -103,6 +103,7 @@ export interface Interface {
data: Uint8Array,
) => Effect.Effect<void, NotFoundError | UnavailableError>
readonly snapshot: (id: Pty.ID) => Effect.Effect<Snapshot, NotFoundError | UnavailableError>
readonly read: (sessionID: Session.ID, lines?: number) => Effect.Effect<ReadResult | null, UnavailableError>
readonly remove: (id: Pty.ID) => Effect.Effect<void, NotFoundError | UnavailableError>
readonly shutdown: () => Effect.Effect<void, UnavailableError>
readonly handoff: () => Effect.Effect<Handoff | null, UnavailableError>
@@ -140,6 +141,8 @@ export const configured = (options: Options = {}) =>
options.handoff,
).pipe(Effect.mapError(unavailable))
const removing = new Set<Pty.ID>()
// Controller activity selects a terminal; observer reads and pane visibility do not.
const current = new Map<Session.ID, Pty.ID>()
const list = Effect.fn("PersistentPty.list")(function* (sessionID?: Session.ID) {
const response = yield* optionalRequest(daemon, { op: "list" })
@@ -207,7 +210,7 @@ export const configured = (options: Options = {}) =>
rows: number,
attachmentID?: string,
) {
yield* get(id)
const terminal = yield* get(id)
const response = yield* request(daemon, {
op: "resize",
id: fromID(id),
@@ -216,6 +219,7 @@ export const configured = (options: Options = {}) =>
rows,
})
if (response.type !== "ok") return yield* unexpected(response)
current.set(terminal.sessionID, id)
return undefined
})
@@ -225,7 +229,7 @@ export const configured = (options: Options = {}) =>
cols: number,
rows: number,
) {
yield* get(id)
const terminal = yield* get(id)
const response = yield* request(daemon, {
op: "control",
id: fromID(id),
@@ -234,6 +238,7 @@ export const configured = (options: Options = {}) =>
rows,
})
if (response.type !== "ok") return yield* unexpected(response)
current.set(terminal.sessionID, id)
return undefined
})
@@ -244,7 +249,7 @@ export const configured = (options: Options = {}) =>
rows: number,
data: Uint8Array,
) {
yield* get(id)
const terminal = yield* get(id)
const response = yield* request(daemon, {
op: "input",
id: fromID(id),
@@ -254,6 +259,7 @@ export const configured = (options: Options = {}) =>
data_base64: Buffer.from(data).toString("base64"),
})
if (response.type !== "ok") return yield* unexpected(response)
current.set(terminal.sessionID, id)
return undefined
})
@@ -269,16 +275,46 @@ export const configured = (options: Options = {}) =>
}
})
const read = Effect.fn("PersistentPty.read")(function* (sessionID: Session.ID, lines?: number) {
if (lines !== undefined && !Schema.is(ReadLines)(lines))
return yield* new UnavailableError({ message: "lines must be an integer between 1 and 65535" })
const id = current.get(sessionID)
if (!id) return null
const terminal = yield* get(id).pipe(Effect.catchTag("PersistentPty.NotFoundError", () => Effect.succeed(null)))
if (!terminal || terminal.sessionID !== sessionID) {
if (current.get(sessionID) === id) current.delete(sessionID)
return null
}
// Let the daemon choose the live height in the same snapshot when lines is omitted.
const response = yield* request(daemon, { op: "read_rows", id: fromID(id), rows: lines })
if (response.type !== "rows") return yield* unexpected(response)
const info = toInfo(response.terminal)
return {
ptyID: info.id,
title: info.title,
cwd: info.cwd,
foregroundProcess: info.foregroundProcess,
screen: {
text: response.lines.join("\n"),
cols: info.size.cols,
rows: info.size.rows,
cursor: { x: response.cursor_x, y: response.cursor_y },
},
}
})
const remove = Effect.fn("PersistentPty.remove")(function* (id: Pty.ID) {
const terminal = yield* get(id)
const response = yield* request(daemon, { op: "terminate", id: fromID(id) })
if (response.type !== "ok") return yield* unexpected(response)
if (current.get(terminal.sessionID) === id) current.delete(terminal.sessionID)
yield* bus.publish(Removed, { sessionID: terminal.sessionID, ptyID: id })
return undefined
})
const shutdown = Effect.fn("PersistentPty.shutdown")(function* () {
const response = yield* daemon.shutdown.pipe(Effect.mapError(unavailable))
current.clear()
if (!response) return
if (response.type !== "ok") return yield* unexpected(response)
})
@@ -321,6 +357,7 @@ export const configured = (options: Options = {}) =>
},
})
.pipe(Effect.mapError(unavailable))
if (attachment.role === "controller") current.set(Session.ID.make(attachment.terminal.group_id), id)
return {
info: toInfo(attachment.terminal),
role: attachment.role,
@@ -340,6 +377,7 @@ export const configured = (options: Options = {}) =>
control,
input,
snapshot,
read,
remove,
shutdown,
handoff,
+5
View File
@@ -193,6 +193,11 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
event: {
subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)),
},
experimental: {
terminal: {
read: (input) => runtime.persistentPty.read(input.sessionID, input.lines),
},
},
generate: {
text: (input) => generate.text(input).pipe(Effect.map((text) => ({ text }))),
},
+8 -1
View File
@@ -7,6 +7,7 @@ import { Job } from "../job.js"
import { Location } from "../location.js"
import { LocationServiceMap } from "../location-service-map.js"
import { Mcp } from "../mcp/index.js"
import { PersistentPty } from "../persistent-pty.js"
import { Session } from "../session.js"
export interface Interface {
@@ -29,6 +30,7 @@ export interface Interface {
| "context"
>
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel" | "completeBackground">
readonly persistentPty: Pick<PersistentPty.Interface, "read">
readonly location: {
readonly agent: {
readonly list: (
@@ -90,6 +92,9 @@ export const layerWithCell = (cell: Cell) =>
completeBackground: (notificationID) =>
require(cell, (runtime) => runtime.job.completeBackground(notificationID)),
},
persistentPty: {
read: (sessionID, lines) => require(cell, (runtime) => runtime.persistentPty.read(sessionID, lines)),
},
location: {
agent: {
list: (ref) => require(cell, (runtime) => runtime.location.agent.list(ref)),
@@ -107,9 +112,11 @@ export const providerLayerWithCell = (cell: Cell) =>
const sessions = yield* Session.Service
const jobs = yield* Job.Service
const locations = yield* LocationServiceMap.Service
const persistentPty = yield* PersistentPty.Service
const runtime: Interface = {
session: sessions,
job: jobs,
persistentPty,
location: {
agent: {
list: (ref) =>
@@ -162,7 +169,7 @@ export const providerNodeWithCell = (cell: Cell) =>
makeGlobalNode({
name: "plugin-runtime-provider",
layer: providerLayerWithCell(cell),
deps: [node, Session.node, Job.node, LocationServiceMap.node],
deps: [node, Session.node, Job.node, LocationServiceMap.node, PersistentPty.node],
})
export const providerNode = providerNodeWithCell(defaultCell)
+50
View File
@@ -9,12 +9,14 @@ import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { Location } from "@opencode-ai/core/location"
import { PersistentPty } from "@opencode-ai/core/persistent-pty"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Tool } from "@opencode-ai/core/tool"
import { Vcs } from "@opencode-ai/core/vcs"
import { Pty } from "@opencode-ai/schema/pty"
import { testEffect } from "./lib/effect"
import { PluginTestLayer } from "./plugin/fixture"
@@ -25,6 +27,54 @@ class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSec
const versioned = <R>(plugin: EffectPlugin.Plugin<R>, version = "1") => ({ ...plugin, version })
describe("Plugin", () => {
it.effect("routes experimental terminal reads through the runtime cell without wrapping results", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const runtime = yield* PluginRuntime.Service
const cell = PluginRuntime.makeCell()
const host = yield* PluginHost.make(plugins).pipe(Effect.provide(PluginRuntime.layerWithCell(cell)))
const sessionID = Session.ID.make("ses_terminal")
const pending = host.experimental.terminal.read({ sessionID, lines: 3 })
const seen: unknown[] = []
const terminal = {
ptyID: Pty.ID.make("pty_terminal"),
title: "Build",
cwd: "/workspace",
foregroundProcess: null,
screen: { text: "one\ntwo\nthree", cols: 80, rows: 2, cursor: { x: 3, y: 1 } },
}
const error = new PersistentPty.UnavailableError({ message: "terminal daemon unavailable" })
cell.runtime = {
...runtime,
persistentPty: {
read: (id, lines) => {
seen.push({ sessionID: id, lines })
if (id === Session.ID.make("ses_failure")) return Effect.fail(error)
return Effect.succeed(id === sessionID ? terminal : null)
},
},
}
expect(Object.keys(host.experimental)).toEqual(["terminal"])
expect(Object.keys(host.experimental.terminal)).toEqual(["read"])
expect(yield* pending).toBe(terminal)
expect(yield* host.experimental.terminal.read({ sessionID })).toBe(terminal)
expect(yield* host.experimental.terminal.read({ sessionID: Session.ID.make("ses_empty") })).toBeNull()
expect(
yield* host.experimental.terminal.read({ sessionID: Session.ID.make("ses_failure") }).pipe(Effect.flip),
).toBe(error)
expect(seen).toEqual([
{ sessionID, lines: 3 },
{ sessionID, lines: undefined },
{ sessionID: Session.ID.make("ses_empty"), lines: undefined },
{ sessionID: Session.ID.make("ses_failure"), lines: undefined },
])
cell.runtime = undefined
expect(Exit.isFailure(yield* pending.pipe(Effect.exit))).toBe(true)
}),
)
it.effect("exposes the current location to activated plugins", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Bus } from "@opencode-ai/core/bus"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { PersistentPty } from "@opencode-ai/core/persistent-pty"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { Session } from "@opencode-ai/schema/session"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { tempGlobalLayer } from "../fixture/global"
import { testEffect } from "../lib/effect"
const cell = PluginRuntime.makeCell()
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Global.node,
Bus.node,
PersistentPty.node,
PluginRuntime.node,
PluginRuntime.providerNodeWithCell(cell),
]),
[
[Global.node, tempGlobalLayer],
[Watcher.node, Watcher.configured({ enabled: false })],
[SessionExecution.node, SessionExecution.noopLayer],
[PluginRuntime.node, PluginRuntime.layerWithCell(cell)],
[PersistentPty.node, PersistentPty.configured()],
],
),
)
describe("Plugin runtime terminal reads", () => {
it.live("shares the configured global PTY service and validates lines before an empty selection", () =>
Effect.gen(function* () {
const runtime = yield* PluginRuntime.Service
const persistentPty = yield* PersistentPty.Service
const sessionID = Session.ID.make("ses_no_terminal")
expect(cell.runtime?.persistentPty).toBe(persistentPty)
expect(yield* runtime.persistentPty.read(sessionID)).toBeNull()
expect(yield* runtime.persistentPty.read(sessionID, 1)).toBeNull()
expect(yield* runtime.persistentPty.read(sessionID, 65535)).toBeNull()
yield* Effect.forEach([0, -1, 1.5, 65536, NaN, Infinity], (lines) =>
Effect.gen(function* () {
const error = yield* runtime.persistentPty.read(sessionID, lines).pipe(Effect.flip)
expect(error).toBeInstanceOf(PersistentPty.UnavailableError)
expect(error.message).toContain("lines")
}),
)
}),
)
})
+5
View File
@@ -58,6 +58,11 @@ export function host(overrides: Overrides = {}): Plugin.Context {
event: overrides.event ?? {
subscribe: () => Stream.empty,
},
experimental: overrides.experimental ?? {
terminal: {
read: () => Effect.die("unused experimental.terminal.read"),
},
},
generate: overrides.generate ?? {
text: () => Effect.die("unused generate.text"),
},
+80
View File
@@ -22,6 +22,8 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { define } from "@opencode-ai/plugin/promise/plugin"
import type { Info } from "@opencode-ai/plugin/promise/tool"
import { Money } from "@opencode-ai/schema/money"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { Pty } from "@opencode-ai/schema/pty"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -30,6 +32,84 @@ import { host as testHost } from "./host"
const it = testEffect(PluginTestLayer)
describe("fromPromise", () => {
it.effect("validates and forwards experimental terminal reads through the protocol schema", () =>
Effect.gen(function* () {
const seen: unknown[] = []
const terminal = PersistentPty.ReadResult.make({
ptyID: Pty.ID.make("pty_terminal"),
title: "Build",
cwd: "/workspace",
foregroundProcess: "bun",
screen: { text: "one\ntwo\nthree", cols: 80, rows: 2, cursor: { x: 3, y: 1 } },
})
const host = testHost({
experimental: {
terminal: {
read: (input) => {
seen.push(input)
return Effect.succeed(terminal)
},
},
},
})
yield* PluginPromise.fromPromise(
define({
id: "promise-terminal-read",
setup: async (ctx) => {
expect(Object.keys(ctx.experimental)).toEqual(["terminal"])
expect(Object.keys(ctx.experimental.terminal)).toEqual(["read"])
for (const lines of [0, -1, 1.5, 65536, NaN, Infinity, "3"]) {
await expect(
Reflect.apply(ctx.experimental.terminal.read, undefined, [{ sessionID: "ses_terminal", lines }]),
).rejects.toBeDefined()
}
await expect(Reflect.apply(ctx.experimental.terminal.read, undefined, [{ lines: 3 }])).rejects.toBeDefined()
expect(seen).toEqual([])
expect(await ctx.experimental.terminal.read({ sessionID: "ses_terminal" })).toEqual(terminal)
expect(await ctx.experimental.terminal.read({ sessionID: "ses_terminal", lines: 3 })).toEqual(terminal)
await ctx.experimental.terminal.read({ sessionID: "ses_terminal", lines: 1 })
await ctx.experimental.terminal.read({ sessionID: "ses_terminal", lines: 65535 })
},
}),
).effect(host)
expect(seen).toEqual([
{ sessionID: Session.ID.make("ses_terminal") },
{ sessionID: Session.ID.make("ses_terminal"), lines: 3 },
{ sessionID: Session.ID.make("ses_terminal"), lines: 1 },
{ sessionID: Session.ID.make("ses_terminal"), lines: 65535 },
])
}),
)
it.effect("preserves null terminal reads and rejects daemon failures", () =>
Effect.gen(function* () {
const host = testHost({
experimental: {
terminal: {
read: (input) =>
input.sessionID === Session.ID.make("ses_failure")
? Effect.fail(new Error("terminal daemon unavailable"))
: Effect.succeed(null),
},
},
})
yield* PluginPromise.fromPromise(
define({
id: "promise-terminal-null",
setup: async (ctx) => {
expect(await ctx.experimental.terminal.read({ sessionID: "ses_empty" })).toBeNull()
await expect(ctx.experimental.terminal.read({ sessionID: "ses_failure" })).rejects.toThrow(
"terminal daemon unavailable",
)
},
}),
).effect(host)
}),
)
it.effect("exposes the host location including workspace and project metadata", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
+1
View File
@@ -9,6 +9,7 @@ export { Integration } from "@opencode-ai/schema/integration"
export { Location } from "@opencode-ai/schema/location"
export { Mcp } from "@opencode-ai/schema/mcp"
export { Model } from "@opencode-ai/schema/model"
export { PersistentPty } from "@opencode-ai/schema/persistent-pty"
export { Provider } from "@opencode-ai/schema/provider"
export { Reference } from "@opencode-ai/schema/reference"
export { Skill } from "@opencode-ai/schema/skill"
+4 -1
View File
@@ -1,4 +1,4 @@
import type { GenerateApi, PluginApi } from "@opencode-ai/client/effect/api"
import type { ExperimentalApi, GenerateApi, PluginApi } from "@opencode-ai/client/effect/api"
import type { Location } from "@opencode-ai/schema/location"
import type { Effect, Scope } from "effect"
import type { PluginOptions } from "../options.js"
@@ -30,6 +30,9 @@ export interface Context {
readonly catalog: CatalogDomain
readonly command: CommandDomain
readonly event: EventDomain
readonly experimental: {
readonly terminal: Pick<ExperimentalApi<unknown>["persistentPty"], "read">
}
readonly integration: IntegrationDomain
readonly mcp: MCPDomain
readonly generate: GenerateApi<unknown>
+6
View File
@@ -77,6 +77,7 @@ export function fromPromise(plugin: Plugin) {
)
const AgentEndpoints = ClientApi.groups["server.agent"].endpoints
const CommandEndpoints = ClientApi.groups["server.command"].endpoints
const ExperimentalEndpoints = ClientApi.groups["server.experimental"].endpoints
const GenerateEndpoints = ClientApi.groups["server.generate"].endpoints
const IntegrationEndpoints = ClientApi.groups["server.integration"].endpoints
const McpEndpoints = ClientApi.groups["server.mcp"].endpoints
@@ -188,6 +189,11 @@ export function fromPromise(plugin: Plugin) {
),
),
},
experimental: {
terminal: {
read: adaptApiMethod(ExperimentalEndpoints["persistentPty.read"], host.experimental.terminal.read),
},
},
generate: {
text: adaptApiMethod(GenerateEndpoints["generate.text"], host.generate.text),
},
+1
View File
@@ -10,6 +10,7 @@ export { Integration } from "@opencode-ai/schema/integration"
export { Location } from "@opencode-ai/schema/location"
export { Mcp } from "@opencode-ai/schema/mcp"
export { Model } from "@opencode-ai/schema/model"
export { PersistentPty } from "@opencode-ai/schema/persistent-pty"
export { Provider } from "@opencode-ai/schema/provider"
export { Reference } from "@opencode-ai/schema/reference"
export { Skill } from "@opencode-ai/schema/skill"
+4
View File
@@ -1,3 +1,4 @@
import type { OpenCodeClient } from "@opencode-ai/client"
import type { GenerateApi, PluginApi } from "@opencode-ai/client/promise/api"
import type { Location } from "@opencode-ai/schema/location"
import type { PluginOptions } from "../options.js"
@@ -29,6 +30,9 @@ export interface Context {
readonly catalog: CatalogDomain
readonly command: CommandDomain
readonly event: EventDomain
readonly experimental: {
readonly terminal: Pick<OpenCodeClient["experimental"]["persistentPty"], "read">
}
readonly integration: IntegrationDomain
readonly mcp: MCPDomain
readonly generate: GenerateApi
@@ -8,6 +8,7 @@ import { Integration } from "@opencode-ai/schema/integration"
import { Location } from "@opencode-ai/schema/location"
import { Mcp } from "@opencode-ai/schema/mcp"
import { Model } from "@opencode-ai/schema/model"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { Provider } from "@opencode-ai/schema/provider"
import { Reference } from "@opencode-ai/schema/reference"
import { Skill } from "@opencode-ai/schema/skill"
@@ -30,6 +31,7 @@ test.each([
expect(entrypoint.Location).toBe(Location)
expect(entrypoint.Mcp).toBe(Mcp)
expect(entrypoint.Model).toBe(Model)
expect(entrypoint.PersistentPty).toBe(PersistentPty)
expect(entrypoint.Provider).toBe(Provider)
expect(entrypoint.Reference).toBe(Reference)
expect(entrypoint.Skill).toBe(Skill)
@@ -44,6 +46,7 @@ test.each([
"Location",
"Mcp",
"Model",
"PersistentPty",
"Plugin",
"Provider",
"Reference",
@@ -19,6 +19,22 @@ const errors = [InvalidRequestError, ServiceUnavailableError] as const
const terminalErrors = [PtyNotFoundError, ServiceUnavailableError] as const
export const PersistentPtyGroup = HttpApiGroup.make("server.experimental")
.add(
HttpApiEndpoint.get("persistentPty.read", "/api/experimental/session/:sessionID/terminal/read", {
params: { sessionID: Session.ID },
query: {
lines: Schema.NumberFromString.pipe(Schema.decodeTo(PersistentPty.ReadLines), Schema.optional),
},
success: Schema.Struct({ data: Schema.NullOr(PersistentPty.ReadResult) }),
error: [ServiceUnavailableError],
}).annotateMerge(
OpenApi.annotations({
summary: "Read the session's most recently controlled terminal",
description:
"Read the last physical rows without changing selection or taking control. Omitted lines uses the live terminal height; larger counts include retained history. Blank rows are preserved. Screen dimensions and cursor remain relative to the live screen. Returns null when no current terminal exists. Selection is server-local and resets on restart. Experimental: may change without compatibility guarantees.",
}),
),
)
.add(
HttpApiEndpoint.get("persistentPty.list", "/api/experimental/session/:sessionID/terminal", {
params: { sessionID: Session.ID },
+18
View File
@@ -47,6 +47,24 @@ export const Snapshot = Schema.Struct({
}).annotate({ identifier: "PersistentPty.Snapshot" })
export interface Snapshot extends Schema.Schema.Type<typeof Snapshot> {}
export const ReadLines = PositiveInt.check(Schema.isLessThanOrEqualTo(65535)).annotate({
identifier: "PersistentPty.ReadLines",
})
export const ReadResult = Schema.Struct({
ptyID: Pty.ID,
title: Schema.String,
cwd: Schema.String,
foregroundProcess: Schema.NullOr(Schema.String),
screen: Schema.Struct({
text: Schema.String,
cols: PositiveInt,
rows: PositiveInt,
cursor: Snapshot.fields.cursor,
}),
}).annotate({ identifier: "PersistentPty.ReadResult" })
export interface ReadResult extends Schema.Schema.Type<typeof ReadResult> {}
export const Added = ephemeral({ type: "persistent-pty.added", schema: { sessionID: Session.ID, terminal: Info } })
export const Removed = ephemeral({ type: "persistent-pty.removed", schema: { sessionID: Session.ID, ptyID: Pty.ID } })
export const Event = { Added, Removed, Definitions: inventory(Added, Removed) }
@@ -20,6 +20,12 @@ export const PersistentPtyHandler = HttpApiBuilder.group(Api, "server.experiment
const pty = yield* PersistentPty.Service
return handlers
.handle(
"persistentPty.read",
Effect.fn(function* (ctx) {
return { data: yield* pty.read(ctx.params.sessionID, ctx.query.lines).pipe(mapUnavailable) }
}),
)
.handle(
"persistentPty.list",
Effect.fn(function* (ctx) {
+136
View File
@@ -7,12 +7,137 @@ import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { Session } from "@opencode-ai/schema/session"
import { Effect, Exit, Schema, Scope } from "effect"
import { HttpServer } from "effect/unstable/http"
import { OpenCode } from "../../client/src/promise/index"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
const binary = process.env.OPENCODE_PTY_BIN ?? "/root/projects/opencode-pty/target/debug/opencode-pty"
const smoke = existsSync(binary) ? it.live : it.live.skip
smoke(
"reads the latest controlled terminal with optional physical line counts through the SDK",
() =>
Effect.gen(function* () {
const fixture = yield* testDirectory("xdg")
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: fixture.database },
fs: { filewatcher: false },
})
const base = HttpServer.formatAddress(server.address)
const client = OpenCode.make({ baseUrl: base, headers: { authorization: `Basic ${btoa("opencode:secret")}` } })
const sessionID = "ses_terminal_read"
expect(yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID }))).toBeNull()
expect(existsSync(fixture.directory)).toBeFalse()
yield* Effect.promise(async () => {
for (const lines of ["0", "-1", "1.5", "65536", "nope"]) {
const response = await fetch(`${base}/api/experimental/session/${sessionID}/terminal/read?lines=${lines}`, {
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
})
expect(response.status).toBe(400)
await response.arrayBuffer()
}
})
expect(existsSync(fixture.directory)).toBeFalse()
const first = yield* Effect.promise(() =>
client.experimental.persistentPty.create({
sessionID,
command: "/bin/sh",
args: ["-c", "stty -echo; seq 1 20; exec cat"],
cwd: fixture.root,
title: "first",
env: {},
size: { cols: 40, rows: 4 },
}),
)
const second = yield* Effect.promise(() =>
client.experimental.persistentPty.create({
sessionID,
command: "/bin/sh",
args: ["-c", "printf second; exec cat"],
cwd: fixture.root,
title: "second",
env: {},
}),
)
expect(yield* waitForText(base, first.id, "20")).toContain("1\n2\n")
expect(yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID }))).toBeNull()
const controller = yield* Effect.acquireRelease(
Effect.promise(() => openTerminalSocket(base, first.id, "read-first")),
(connection) => Effect.sync(() => connection.socket.close()),
)
const observer = yield* Effect.acquireRelease(
Effect.promise(() => openTerminalSocket(base, second.id, "read-second", "observer")),
(connection) => Effect.sync(() => connection.socket.close()),
)
const current = yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID }))
if (!current) throw new Error("Expected the controller's terminal to be selected")
expect(current).toEqual({
ptyID: first.id,
title: "first",
cwd: fixture.root,
foregroundProcess: current.foregroundProcess,
screen: { text: "18\n19\n20\n", cols: 40, rows: 4, cursor: { x: 0, y: 3 } },
})
expect(current.foregroundProcess === null || typeof current.foregroundProcess === "string").toBeTrue()
expect(yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID: "ses_other" }))).toBeNull()
yield* Effect.promise(() => client.experimental.persistentPty.snapshot({ ptyID: second.id }))
for (const lines of [2, 6, 65535]) {
const value = yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID, lines }))
expect(value?.ptyID).toBe(first.id)
expect(value?.screen.rows).toBe(4)
const expected = Array.from({ length: 20 }, (_, index) => String(index + 1))
.concat("")
.slice(-lines)
expect(value?.screen.text.split("\n")).toEqual(expected)
}
observer.socket.send(controlFrame(30, 3))
expect((yield* Effect.promise(() => waitForRead(client, sessionID, second.id))).screen.rows).toBe(3)
yield* Effect.promise(() =>
client.experimental.persistentPty.update({
ptyID: first.id,
attachmentID: "read-first",
size: { cols: 42, rows: 5 },
}),
)
expect((yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID })))?.ptyID).toBe(first.id)
observer.socket.send(controlFrame(30, 3))
yield* Effect.promise(() => waitForRead(client, sessionID, second.id))
controller.socket.send(inputFrame(40, 4, "typed\n"))
yield* Effect.promise(() => waitForSocketOutput([controller], "typed"))
expect((yield* Effect.promise(() => waitForRead(client, sessionID, first.id))).screen.text).toContain("typed")
const takeover = yield* Effect.acquireRelease(
Effect.promise(() => openTerminalSocket(base, second.id, "read-takeover")),
(connection) => Effect.sync(() => connection.socket.close()),
)
expect((yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID })))?.ptyID).toBe(
second.id,
)
takeover.socket.close()
yield* Effect.promise(() => client.experimental.persistentPty.remove({ ptyID: first.id }))
expect((yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID })))?.ptyID).toBe(
second.id,
)
yield* Effect.promise(() => client.experimental.persistentPty.remove({ ptyID: second.id }))
expect(yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID }))).toBeNull()
yield* Effect.promise(() => client.experimental.persistentPty.shutdown())
expect(yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID }))).toBeNull()
}),
20_000,
)
async function waitForRead(client: ReturnType<typeof OpenCode.make>, sessionID: string, ptyID: string) {
for (let attempt = 0; attempt < 100; attempt++) {
const result = await client.experimental.persistentPty.read({ sessionID })
if (result?.ptyID === ptyID) return result
await Bun.sleep(20)
}
throw new Error(`Terminal ${ptyID} did not become current for ${sessionID}`)
}
smoke(
"creates two persistent terminals for one session through the client API",
() =>
@@ -216,10 +341,18 @@ smoke(
})).data,
)
expect(yield* waitForText(base, first.id, "before-restart")).toContain("before-restart")
yield* Effect.acquireRelease(
Effect.promise(() => openTerminalSocket(base, first.id, "before-restart")),
(connection) => Effect.sync(() => connection.socket.close()),
)
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal/read`)).data).toMatchObject({
ptyID: first.id,
})
const independent = yield* ServerProcess.start<never, never>(options)
const otherBase = HttpServer.formatAddress(independent.address)
expect((yield* request(otherBase, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toEqual([])
expect((yield* request(otherBase, "GET", `/api/experimental/session/${sessionID}/terminal/read`)).data).toBeNull()
const handoff = Schema.decodeUnknownSync(PersistentPty.Handoff)(
(yield* request(base, "POST", "/api/experimental/persistent-pty/handoff")).handoff,
@@ -240,6 +373,9 @@ smoke(
(yield* request(replacementBase, "GET", `/api/experimental/session/${sessionID}/terminal`)).data,
).toMatchObject([{ id: first.id, pid: first.pid }])
expect(yield* waitForText(replacementBase, first.id, "before-restart")).toContain("before-restart")
expect(
(yield* request(replacementBase, "GET", `/api/experimental/session/${sessionID}/terminal/read`)).data,
).toBeNull()
yield* Scope.close(replacementScope, Exit.void)
yield* waitForExit(registration.pid)