From ca4794947542846ae3b4e039e38f1d49c770cb4b Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 28 Aug 2026 08:03:35 -0400 Subject: [PATCH] feat(plugin): expose experimental terminal reads (#45792) --- packages/client/src/effect/api/api.ts | 18 +++ .../client/src/effect/generated/client.ts | 12 ++ .../client/src/promise/generated/client.ts | 14 ++ .../client/src/promise/generated/types.ts | 15 ++ packages/client/test/promise.test.ts | 2 + packages/core/src/persistent-pty/daemon.ts | 7 + packages/core/src/persistent-pty/index.ts | 46 +++++- packages/core/src/plugin/host.ts | 5 + packages/core/src/plugin/runtime.ts | 9 +- packages/core/test/plugin.test.ts | 50 +++++++ packages/core/test/plugin/host.test.ts | 55 +++++++ packages/core/test/plugin/host.ts | 5 + packages/core/test/plugin/promise.test.ts | 80 +++++++++++ packages/plugin/src/effect/index.ts | 1 + packages/plugin/src/effect/plugin.ts | 5 +- packages/plugin/src/promise/adapter.ts | 6 + packages/plugin/src/promise/index.ts | 1 + packages/plugin/src/promise/plugin.ts | 4 + .../plugin/test/contract-identity.test.ts | 3 + .../protocol/src/groups/persistent-pty.ts | 16 +++ packages/schema/src/persistent-pty.ts | 18 +++ .../server/src/handlers/persistent-pty.ts | 6 + packages/server/test/persistent-pty.test.ts | 136 ++++++++++++++++++ 23 files changed, 508 insertions(+), 6 deletions(-) create mode 100644 packages/core/test/plugin/host.test.ts diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 46b5419b92d..ef0fe870e26 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -1639,6 +1639,23 @@ export interface PtyApi { readonly connect: { readonly token: PtyConnectTokenOperation } } +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 = ( + input: ExperimentalPersistentPtyReadInput, +) => Effect.Effect + export type ExperimentalPersistentPtyListInput = { readonly sessionID: Session.ID } export type ExperimentalPersistentPtyListOutput = ReadonlyArray<{ readonly id: Pty.ID @@ -1787,6 +1804,7 @@ export type ExperimentalPersistentPtyConnectTokenOperation = ( export interface ExperimentalApi { readonly persistentPty: { + readonly read: ExperimentalPersistentPtyReadOperation readonly list: ExperimentalPersistentPtyListOperation readonly create: ExperimentalPersistentPtyCreateOperation readonly shutdown: ExperimentalPersistentPtyShutdownOperation diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index e667eb6c665..cec86772e00 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -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()( + 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()( @@ -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), diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index b5abafe6d2e..2d7bf3140e5 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -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 }>( { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index c204555fc3f..ac01aa94a7f 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -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 }["data"] diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index a0d2f3f6242..d8f6e50ec08 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -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"]) diff --git a/packages/core/src/persistent-pty/daemon.ts b/packages/core/src/persistent-pty/daemon.ts index dc351fedc5c..4310c080de7 100644 --- a/packages/core/src/persistent-pty/daemon.ts +++ b/packages/core/src/persistent-pty/daemon.ts @@ -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, diff --git a/packages/core/src/persistent-pty/index.ts b/packages/core/src/persistent-pty/index.ts index 6e6e028f304..95d54cd0c36 100644 --- a/packages/core/src/persistent-pty/index.ts +++ b/packages/core/src/persistent-pty/index.ts @@ -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 readonly snapshot: (id: Pty.ID) => Effect.Effect + readonly read: (sessionID: Session.ID, lines?: number) => Effect.Effect readonly remove: (id: Pty.ID) => Effect.Effect readonly shutdown: () => Effect.Effect readonly handoff: () => Effect.Effect @@ -140,6 +141,8 @@ export const configured = (options: Options = {}) => options.handoff, ).pipe(Effect.mapError(unavailable)) const removing = new Set() + // Controller activity selects a terminal; observer reads and pane visibility do not. + const current = new Map() 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, diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index ef7e34f4f5b..fdb539151de 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -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 }))), }, diff --git a/packages/core/src/plugin/runtime.ts b/packages/core/src/plugin/runtime.ts index 688076a7aba..89cf606da05 100644 --- a/packages/core/src/plugin/runtime.ts +++ b/packages/core/src/plugin/runtime.ts @@ -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 + readonly persistentPty: Pick 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) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 14549f234b2..b73f8e31172 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -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()("@opencode/test/PluginSec const versioned = (plugin: EffectPlugin.Plugin, 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 diff --git a/packages/core/test/plugin/host.test.ts b/packages/core/test/plugin/host.test.ts new file mode 100644 index 00000000000..4b63883c813 --- /dev/null +++ b/packages/core/test/plugin/host.test.ts @@ -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") + }), + ) + }), + ) +}) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 2be4ed23b44..45a577ea3e4 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -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"), }, diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index 627579a4b14..72651e41347 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -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 diff --git a/packages/plugin/src/effect/index.ts b/packages/plugin/src/effect/index.ts index fbf4e2e014b..5d16ebbef93 100644 --- a/packages/plugin/src/effect/index.ts +++ b/packages/plugin/src/effect/index.ts @@ -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" diff --git a/packages/plugin/src/effect/plugin.ts b/packages/plugin/src/effect/plugin.ts index a8a45143e10..7a504b086c0 100644 --- a/packages/plugin/src/effect/plugin.ts +++ b/packages/plugin/src/effect/plugin.ts @@ -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["persistentPty"], "read"> + } readonly integration: IntegrationDomain readonly mcp: MCPDomain readonly generate: GenerateApi diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index 9b0d3c6faab..f45a8367f16 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -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), }, diff --git a/packages/plugin/src/promise/index.ts b/packages/plugin/src/promise/index.ts index 9c799b700da..a7d3e3f674f 100644 --- a/packages/plugin/src/promise/index.ts +++ b/packages/plugin/src/promise/index.ts @@ -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" diff --git a/packages/plugin/src/promise/plugin.ts b/packages/plugin/src/promise/plugin.ts index 27fa5c484a7..68c13eb179a 100644 --- a/packages/plugin/src/promise/plugin.ts +++ b/packages/plugin/src/promise/plugin.ts @@ -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 + } readonly integration: IntegrationDomain readonly mcp: MCPDomain readonly generate: GenerateApi diff --git a/packages/plugin/test/contract-identity.test.ts b/packages/plugin/test/contract-identity.test.ts index 54ca5123df2..1d56bffa445 100644 --- a/packages/plugin/test/contract-identity.test.ts +++ b/packages/plugin/test/contract-identity.test.ts @@ -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", diff --git a/packages/protocol/src/groups/persistent-pty.ts b/packages/protocol/src/groups/persistent-pty.ts index 1079f77267b..f14111d4cb9 100644 --- a/packages/protocol/src/groups/persistent-pty.ts +++ b/packages/protocol/src/groups/persistent-pty.ts @@ -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 }, diff --git a/packages/schema/src/persistent-pty.ts b/packages/schema/src/persistent-pty.ts index 82d7a02aeae..cef23f59806 100644 --- a/packages/schema/src/persistent-pty.ts +++ b/packages/schema/src/persistent-pty.ts @@ -47,6 +47,24 @@ export const Snapshot = Schema.Struct({ }).annotate({ identifier: "PersistentPty.Snapshot" }) export interface Snapshot extends Schema.Schema.Type {} +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 {} + 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) } diff --git a/packages/server/src/handlers/persistent-pty.ts b/packages/server/src/handlers/persistent-pty.ts index ab1476a9bf4..8e620a7fe29 100644 --- a/packages/server/src/handlers/persistent-pty.ts +++ b/packages/server/src/handlers/persistent-pty.ts @@ -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) { diff --git a/packages/server/test/persistent-pty.test.ts b/packages/server/test/persistent-pty.test.ts index 6ab8e358422..6a1708a67ac 100644 --- a/packages/server/test/persistent-pty.test.ts +++ b/packages/server/test/persistent-pty.test.ts @@ -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({ + 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, 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(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)