feat(core): rename persistent terminals (#60329)

This commit is contained in:
James Long
2026-10-30 09:29:04 -04:00
committed by GitHub
Unverified
parent 8f0fc21e71
commit 65c0d83f4d
17 changed files with 468 additions and 69 deletions
+8 -1
View File
@@ -2123,6 +2123,7 @@ export type EventSubscribeInput = {
| "pty.deleted"
| "persistent-pty.added"
| "persistent-pty.removed"
| "persistent-pty.updated"
| "shell.created"
| "shell.exited"
| "shell.deleted"
@@ -2238,6 +2239,7 @@ export type ExperimentalPersistentPtyListOutput = ReadonlyArray<{
readonly pid: number
readonly exitCode?: number | undefined
readonly sessionID: Session.ID
readonly userTitle: string | null
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
@@ -2265,6 +2267,7 @@ export type ExperimentalPersistentPtyCreateOutput = {
readonly pid: number
readonly exitCode?: number | undefined
readonly sessionID: Session.ID
readonly userTitle: string | null
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
@@ -2303,6 +2306,7 @@ export type ExperimentalPersistentPtyGetOutput = {
readonly pid: number
readonly exitCode?: number | undefined
readonly sessionID: Session.ID
readonly userTitle: string | null
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
@@ -2314,7 +2318,8 @@ export type ExperimentalPersistentPtyGetOperation<E = never> = (
export type ExperimentalPersistentPtyUpdateInput = {
readonly ptyID: Pty.ID
readonly attachmentID?: string | undefined
readonly size: { readonly cols: number; readonly rows: number }
readonly size?: { readonly cols: number; readonly rows: number } | undefined
readonly title?: string | null | undefined
}
export type ExperimentalPersistentPtyUpdateOutput = {
readonly id: Pty.ID
@@ -2326,6 +2331,7 @@ export type ExperimentalPersistentPtyUpdateOutput = {
readonly pid: number
readonly exitCode?: number | undefined
readonly sessionID: Session.ID
readonly userTitle: string | null
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
@@ -2346,6 +2352,7 @@ export type ExperimentalPersistentPtySnapshotOutput = {
readonly pid: number
readonly exitCode?: number | undefined
readonly sessionID: Session.ID
readonly userTitle: string | null
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
+1 -1
View File
@@ -1546,7 +1546,7 @@ const EndpointExperimentalPersistentPtyUpdate =
preserveEffect<ExperimentalPersistentPtyUpdateOutput>()(
raw["persistentPty.update"]({
params: { ptyID: input["ptyID"] },
payload: { attachmentID: input["attachmentID"], size: input["size"] },
payload: { attachmentID: input["attachmentID"], size: input["size"], title: input["title"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
+2 -2
View File
@@ -1447,9 +1447,9 @@ export function make(options: ClientOptions) {
{
method: "PUT",
path: `/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}`,
body: { attachmentID: input["attachmentID"], size: input["size"] },
body: { attachmentID: input["attachmentID"], size: input["size"], title: input["title"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 503],
declaredStatuses: [400, 401, 404, 501, 503],
empty: false,
},
requestOptions,
+22 -1
View File
@@ -826,6 +826,7 @@ export type PersistentPtyInfo = {
pid: number
exitCode?: number
sessionID: SessionID
userTitle: string | null
foregroundProcess: string | null
size: { cols: number; rows: number }
output: { head: number; tail: number }
@@ -2151,6 +2152,15 @@ export type PersistentPtyAdded = {
data: { sessionID: SessionID; terminal: PersistentPtyInfo }
}
export type PersistentPtyUpdated = {
id: EventID
created: number
metadata?: { [x: string]: any }
type: "persistent-pty.updated"
location?: LocationRef
data: { sessionID: SessionID; terminal: PersistentPtyInfo }
}
export type PersistentPtySnapshot = {
info: PersistentPtyInfo
text: string
@@ -2857,6 +2867,7 @@ export type V2Event =
| PtyDeleted
| PersistentPtyAdded
| PersistentPtyRemoved
| PersistentPtyUpdated
| ShellCreated
| ShellExited
| ShellDeleted
@@ -3153,6 +3164,14 @@ export type PtyNotFoundError = { readonly _tag: "PtyNotFoundError"; readonly pty
export const isPtyNotFoundError = (value: unknown): value is PtyNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PtyNotFoundError"
export type PtyFeatureUnsupportedError = {
readonly _tag: "PtyFeatureUnsupportedError"
readonly feature: string
readonly message: string
}
export const isPtyFeatureUnsupportedError = (value: unknown): value is PtyFeatureUnsupportedError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PtyFeatureUnsupportedError"
export type ScheduleNotFoundError = {
readonly _tag: "ScheduleNotFoundError"
readonly scheduleID: string
@@ -4338,6 +4357,7 @@ export type EventSubscribeInput = {
| "pty.deleted"
| "persistent-pty.added"
| "persistent-pty.removed"
| "persistent-pty.updated"
| "shell.created"
| "shell.exited"
| "shell.deleted"
@@ -4441,7 +4461,8 @@ export type ExperimentalPersistentPtyGetOutput = { data: PersistentPtyInfo }["da
export type ExperimentalPersistentPtyUpdateInput = {
readonly ptyID: string
readonly attachmentID?: string | undefined
readonly size: { readonly cols: number; readonly rows: number }
readonly size?: { readonly cols: number; readonly rows: number } | undefined
readonly title?: string | null | undefined
}
export type ExperimentalPersistentPtyUpdateOutput = { data: PersistentPtyInfo }["data"]
+12 -2
View File
@@ -19,6 +19,7 @@ export const WireTerminal = Schema.Struct({
id: Schema.Number,
pid: Schema.NullOr(Schema.Number),
title: Schema.String,
user_title: Schema.optional(Schema.NullOr(Schema.String)),
foreground_process: Schema.NullOr(Schema.String),
group_id: Session.ID,
command: Schema.Array(Schema.String),
@@ -37,6 +38,7 @@ const Registration = Schema.Struct({
protocol: Schema.Number,
socket: Schema.String,
token: Schema.String,
features: Schema.optional(Schema.Array(Schema.String)),
})
type Registration = typeof Registration.Type
@@ -46,6 +48,7 @@ export const WireResponse = Schema.Union([
instance_id: Schema.String,
pid: Schema.Number,
protocol: Schema.Number,
features: Schema.optional(Schema.Array(Schema.String)),
}),
Schema.Struct({ type: Schema.Literal("created"), terminal: WireTerminal }),
Schema.Struct({ type: Schema.Literal("terminals"), terminals: Schema.Array(WireTerminal) }),
@@ -141,6 +144,7 @@ export class DaemonError extends Data.TaggedError("PersistentPty.DaemonError")<{
export interface DaemonTransport {
readonly request: (value: object, start?: boolean) => Effect.Effect<WireResponse, DaemonError>
readonly requestIfRunning: (value: object) => Effect.Effect<WireResponse | undefined, DaemonError>
readonly supports: (feature: string) => Effect.Effect<boolean, DaemonError>
readonly shutdown: Effect.Effect<WireResponse | undefined, DaemonError>
readonly handoff: Effect.Effect<Handoff | null, DaemonError>
readonly subscribe: (
@@ -223,7 +227,8 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
response.protocol !== ProtocolVersion
)
return yield* Effect.fail(new DaemonError({ kind: "protocol", message: "opencode-pty registration mismatch" }))
return decoded
// Daemons older than the features list omit it; trust the live daemon's answer over the file.
return { ...decoded, features: response.features ?? [] }
})
const spawnDaemon = Effect.fn("PersistentPty.daemon.spawn")(function* () {
@@ -338,6 +343,11 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
Effect.catch((error) => (error.kind === "connect" ? Effect.succeed(undefined) : Effect.fail(error))),
)
const supports = Effect.fn("PersistentPty.daemon.supports")(function* (feature: string) {
const current = yield* connect(false)
return current.features?.includes(feature) ?? false
})
const shutdown = Effect.gen(function* () {
const response = yield* requestIfRunning({ op: "shutdown" })
registration = undefined
@@ -428,7 +438,7 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
)
}
return { request, requestIfRunning, shutdown, handoff, subscribe } satisfies DaemonTransport
return { request, requestIfRunning, supports, shutdown, handoff, subscribe } satisfies DaemonTransport
})
// Older releases kept registrations in purgeable temp directories. Renaming keeps the lock file's
+31 -1
View File
@@ -4,7 +4,15 @@ import { stat } from "node:fs/promises"
import path from "node:path"
import { Context, Effect, Layer, Schema, Stream } from "effect"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { Added, Handoff, PersistentPty, ReadLines, Removed, type ReadResult } from "@opencode/schema/persistent-pty"
import {
Added,
Handoff,
PersistentPty,
ReadLines,
Removed,
Updated,
type ReadResult,
} from "@opencode/schema/persistent-pty"
import { Session } from "@opencode/schema/session"
import { SessionEvent } from "@opencode/schema/session-event"
import { Bus } from "../bus.js"
@@ -64,6 +72,10 @@ export class InvalidCwdError extends Schema.TaggedError<InvalidCwdError>()("Pers
message: Schema.String,
}) {}
export class UnsupportedError extends Schema.TaggedError<UnsupportedError>()("PersistentPty.UnsupportedError", {
feature: Schema.String,
}) {}
export interface Interface {
readonly list: (sessionID?: Session.ID) => Effect.Effect<Info[], UnavailableError>
readonly get: (id: Pty.ID) => Effect.Effect<Info, NotFoundError | UnavailableError>
@@ -103,6 +115,10 @@ export interface Interface {
rows: number,
data: Uint8Array,
) => Effect.Effect<void, NotFoundError | UnavailableError>
readonly rename: (
id: Pty.ID,
title: string | null,
) => Effect.Effect<Info, NotFoundError | UnsupportedError | 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>
@@ -266,6 +282,18 @@ const makeLayer = (options: Options = {}) =>
return undefined
})
const rename = Effect.fn("PersistentPty.rename")(function* (id: Pty.ID, title: string | null) {
const terminal = yield* get(id)
// Daemons without the feature drop the connection on unknown requests.
if (!(yield* daemon.supports("rename").pipe(Effect.mapError(unavailable))))
return yield* new UnsupportedError({ feature: "rename" })
const response = yield* request(daemon, { op: "rename", id: fromID(id), title })
if (response.type !== "ok") return yield* unexpected(response)
const renamed = yield* get(id)
yield* bus.publish(Updated, { sessionID: terminal.sessionID, terminal: renamed })
return renamed
})
const snapshot = Effect.fn("PersistentPty.snapshot")(function* (id: Pty.ID) {
yield* get(id)
const response = yield* request(daemon, { op: "snapshot", id: fromID(id) })
@@ -404,6 +432,7 @@ const makeLayer = (options: Options = {}) =>
resize,
control,
input,
rename,
snapshot,
read,
remove,
@@ -449,6 +478,7 @@ function toInfo(value: WireTerminal): Info {
...(status === "exited" ? { exitCode: value.lifecycle.exit_code ?? undefined } : {}),
}),
sessionID: value.group_id,
userTitle: value.user_title ?? null,
foregroundProcess: value.foreground_process,
size: { cols: value.cols, rows: value.rows },
output: { head: value.output_head, tail: value.output_tail },
@@ -1,4 +1,4 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
import net from "node:net"
import os from "node:os"
import path from "node:path"
@@ -89,13 +89,77 @@ function frame(value: object) {
return output
}
export function writeRegistration(directory: string, socket: string, instance = "test", token = "test") {
export function writeRegistration(
directory: string,
socket: string,
instance = "test",
token = "test",
features?: string[],
) {
return writeFile(
path.join(directory, "service.json"),
JSON.stringify({ instance_id: instance, pid: process.pid, protocol: 7, socket, token }),
JSON.stringify({ instance_id: instance, pid: process.pid, protocol: 7, socket, token, features }),
)
}
// An in-process opencode-pty that a server adopts through a handoff, so no daemon is spawned. Without
// features it answers like a daemon that predates the features list.
export const fakeDaemon = Effect.fn(function* (features?: string[]) {
const base = yield* temporaryDirectory()
const root = path.join(base, "root")
const directory = path.join(root, "inherited")
const socket = path.join(base, "daemon.sock")
const terminals: Array<ReturnType<typeof terminal> & { user_title?: string | null }> = []
const failTerminate = new Set<string>()
const ops: string[] = []
let nextID = 1
yield* listen(socket, (connection, request) => {
ops.push(String(request.op))
if (request.op === "ping") return features ? { ...pong, features } : pong
if (request.op === "list") return { type: "terminals", terminals }
if (request.op === "create") {
const base = terminal(nextID++, String(request.group_id))
// A daemon that advertises features always serializes user_title.
const created = features ? { ...base, user_title: null } : base
terminals.push(created)
return { type: "created", terminal: created }
}
if (request.op === "control") return { type: "ok" }
if (request.op === "terminate") {
if (failTerminate.has(`pty_persistent_${request.id}`)) return { type: "error", message: "terminate failed" }
const index = terminals.findIndex((item) => item.id === request.id)
if (index === -1) return { type: "error", message: `terminal not found: ${String(request.id)}` }
terminals.splice(index, 1)
return { type: "ok" }
}
if (request.op === "rename" && features?.includes("rename")) {
const found = terminals.find((item) => item.id === request.id)
if (!found) return { type: "error", message: "terminal not found" }
found.user_title = typeof request.title === "string" && request.title !== "" ? request.title : null
found.title = found.user_title ?? "test"
return { type: "ok" }
}
// Old daemons cannot parse unknown requests and drop the connection.
if (request.op === "rename") {
connection.destroy()
return undefined
}
return { type: "error", message: `unexpected request: ${String(request.op)}` }
})
yield* Effect.promise(async () => {
await mkdir(directory, { recursive: true })
await writeRegistration(directory, socket, "test", "test", features)
})
return {
failTerminate,
ops,
options: {
root,
handoff: { directory, instanceID: "test", ticket: "ticket", expiresAt: Date.now() + 60_000 },
},
}
})
export function terminal(id: number, groupID = "ses_test") {
return {
id,
@@ -85,6 +85,33 @@ it.live("rediscovers a rotated registration when acquiring a subscription", () =
}),
)
it.live("refreshes the daemon's features when its registration rotates", () =>
Effect.gen(function* () {
const { root, directory } = yield* temporaryRuntime()
const socketPath = path.join(directory, "daemon.sock")
let token = "old-token"
let instance = "old-instance"
let features: string[] | undefined
yield* listen(socketPath, (_socket, request, receivedToken) => {
if (receivedToken !== token) return { type: "error", message: "authentication failed" }
if (request.op === "ping") return { ...pong, instance_id: instance, ...(features ? { features } : {}) }
return { type: "terminals", terminals: [] }
})
yield* Effect.promise(() => writeRegistration(directory, socketPath, instance, token))
const daemon = yield* makeDaemonTransport(root, "daemon")
expect(yield* daemon.supports("rename")).toBeFalse()
token = "new-token"
instance = "new-instance"
features = ["rename"]
yield* Effect.promise(() => writeRegistration(directory, socketPath, instance, token, features))
// The cached registration is replaced once a request is rejected, as rename's lookup does first.
yield* daemon.request({ op: "list" })
expect(yield* daemon.supports("rename")).toBeTrue()
}),
)
it.live("retries a start-required request when connection fails before dispatch", () =>
Effect.gen(function* () {
const { root, directory } = yield* temporaryRuntime()
@@ -1,6 +1,4 @@
import { describe, expect } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { Effect, Fiber, Layer, Option, Stream } from "effect"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { LayerNode } from "@opencode/util/effect/layer-node"
@@ -18,8 +16,8 @@ import { SessionProjector } from "@opencode/core/session/projector"
import { SessionStore } from "@opencode/core/session/store"
import { SessionEnvironment } from "@opencode/core/session/environment"
import { LocationServiceMap } from "@opencode/core/location-services"
import { Removed } from "@opencode/schema/persistent-pty"
import { listen, pong, temporaryDirectory, terminal, writeRegistration } from "./fixture/persistent-pty-daemon"
import { Removed, Updated } from "@opencode/schema/persistent-pty"
import { fakeDaemon } from "./fixture/persistent-pty-daemon"
import { it } from "./lib/effect"
import { globalProjectNode } from "./lib/project"
import { offlineModels } from "./fixture/models"
@@ -88,49 +86,62 @@ describe("PersistentPty session cleanup", () => {
)
})
const nextRemoved = (bus: Bus.Interface) =>
bus.subscribe(Removed).pipe(Stream.runHead, Effect.map(Option.getOrThrow), Effect.forkScoped({ startImmediately: true }))
describe("PersistentPty rename", () => {
it.live("renames a terminal, publishes the update and clears the user title", () =>
Effect.gen(function* () {
const daemon = yield* fakeDaemon(["rename"])
yield* Effect.gen(function* () {
const pty = yield* PersistentPty.Service
const bus = yield* Bus.Service
const directory = yield* tmpdirScoped()
const sessionID = Session.ID.make("ses_rename")
const terminal = yield* pty.create(sessionID, { args: [], cwd: directory.path, title: "test", env: {} })
expect(terminal.userTitle).toBeNull()
const updated = yield* nextUpdated(bus)
// An in-process opencode-pty that the layer adopts through a handoff, so no daemon is spawned.
const fakeDaemon = Effect.fn(function* () {
const base = yield* temporaryDirectory()
const root = path.join(base, "root")
const directory = path.join(root, "inherited")
const socket = path.join(base, "daemon.sock")
const terminals: ReturnType<typeof terminal>[] = []
const failTerminate = new Set<string>()
let nextID = 1
yield* listen(socket, (_socket, request) => {
if (request.op === "ping") return pong
if (request.op === "list") return { type: "terminals", terminals }
if (request.op === "create") {
const created = terminal(nextID++, String(request.group_id))
terminals.push(created)
return { type: "created", terminal: created }
}
if (request.op === "control") return { type: "ok" }
if (request.op === "terminate") {
if (failTerminate.has(`pty_persistent_${request.id}`)) return { type: "error", message: "terminate failed" }
const index = terminals.findIndex((item) => item.id === request.id)
if (index === -1) return { type: "error", message: `terminal not found: ${String(request.id)}` }
terminals.splice(index, 1)
return { type: "ok" }
}
return { type: "error", message: `unexpected request: ${String(request.op)}` }
})
yield* Effect.promise(async () => {
await mkdir(directory, { recursive: true })
await writeRegistration(directory, socket)
})
return {
failTerminate,
options: {
root,
handoff: { directory, instanceID: "test", ticket: "ticket", expiresAt: Date.now() + 60_000 },
},
}
const renamed = yield* pty.rename(terminal.id, "build")
expect(renamed).toMatchObject({ id: terminal.id, title: "build", userTitle: "build" })
expect((yield* Fiber.join(updated)).data).toEqual({ sessionID, terminal: renamed })
expect(yield* pty.rename(terminal.id, null)).toMatchObject({ title: "test", userTitle: null })
}).pipe(Effect.provide(layer(daemon)))
}),
)
it.live("refuses to rename on a daemon without the rename feature and never sends it", () =>
Effect.gen(function* () {
const daemon = yield* fakeDaemon()
yield* Effect.gen(function* () {
const pty = yield* PersistentPty.Service
const directory = yield* tmpdirScoped()
const terminal = yield* pty.create(Session.ID.make("ses_rename"), {
args: [],
cwd: directory.path,
title: "test",
env: {},
})
expect(terminal.userTitle).toBeNull()
const error = yield* Effect.flip(pty.rename(terminal.id, "build"))
expect(error).toEqual(new PersistentPty.UnsupportedError({ feature: "rename" }))
expect(daemon.ops).not.toContain("rename")
expect((yield* pty.get(terminal.id)).title).toBe("test")
}).pipe(Effect.provide(layer(daemon)))
}),
)
})
const nextUpdated = (bus: Bus.Interface) =>
bus
.subscribe(Updated)
.pipe(Stream.runHead, Effect.map(Option.getOrThrow), Effect.forkScoped({ startImmediately: true }))
const nextRemoved = (bus: Bus.Interface) =>
bus
.subscribe(Removed)
.pipe(Stream.runHead, Effect.map(Option.getOrThrow), Effect.forkScoped({ startImmediately: true }))
const layer = (daemon: { readonly options: PersistentPty.Options }) =>
AppNodeBuilder.build(
LayerNode.group([
+51 -1
View File
@@ -10668,6 +10668,7 @@
"pty.deleted",
"persistent-pty.added",
"persistent-pty.removed",
"persistent-pty.updated",
"shell.created",
"shell.exited",
"shell.deleted",
@@ -10776,6 +10777,7 @@
"pty.deleted",
"persistent-pty.added",
"persistent-pty.removed",
"persistent-pty.updated",
"shell.created",
"shell.exited",
"shell.deleted",
@@ -12198,6 +12200,16 @@
}
}
},
"501": {
"description": "PtyFeatureUnsupportedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PtyFeatureUnsupportedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
@@ -12209,6 +12221,7 @@
}
}
},
"description": "Resize when size is present and rename when title is present; a null title clears the user title so the shell's title shows again. Renaming fails with 501 while the terminal service predates renaming.",
"requestBody": {
"content": {
"application/json": {
@@ -21144,6 +21157,16 @@
"sessionID": {
"$ref": "#/components/schemas/Session.ID"
},
"userTitle": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"foregroundProcess": {
"anyOf": [
{
@@ -21198,6 +21221,7 @@
"status",
"pid",
"sessionID",
"userTitle",
"foregroundProcess",
"size",
"output"
@@ -21327,9 +21351,18 @@
},
"required": ["cols", "rows"],
"additionalProperties": false
},
"title": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"required": ["size"],
"additionalProperties": false
},
"Plugin.Features": {
@@ -22061,6 +22094,23 @@
"type": "string",
"pattern": "^pty"
},
"PtyFeatureUnsupportedErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["PtyFeatureUnsupportedError"]
},
"feature": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": ["_tag", "feature", "message"],
"additionalProperties": false
},
"PtyNotFoundErrorEncoded": {
"type": "object",
"properties": {
+9
View File
@@ -274,6 +274,15 @@ export class PtyNotFoundError extends Schema.TaggedError<PtyNotFoundError>()(
{ httpApiStatus: 404 },
) {}
export class PtyFeatureUnsupportedError extends Schema.TaggedError<PtyFeatureUnsupportedError>()(
"PtyFeatureUnsupportedError",
{
feature: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 501 },
) {}
export class ShellNotFoundError extends Schema.TaggedError<ShellNotFoundError>()(
"ShellNotFoundError",
{
+15 -3
View File
@@ -4,7 +4,13 @@ import { PtyTicket } from "@opencode/schema/pty-ticket"
import { Session } from "@opencode/schema/session"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/http-api"
import { ForbiddenError, InvalidRequestError, PtyNotFoundError, ServiceUnavailableError } from "../errors.js"
import {
ForbiddenError,
InvalidRequestError,
PtyFeatureUnsupportedError,
PtyNotFoundError,
ServiceUnavailableError,
} from "../errors.js"
import { PTY_CONNECT_TICKET_QUERY, PTY_CONNECT_TOKEN_HEADER, PTY_CONNECT_TOKEN_HEADER_VALUE } from "./pty.js"
export { PTY_CONNECT_TICKET_QUERY, PTY_CONNECT_TOKEN_HEADER, PTY_CONNECT_TOKEN_HEADER_VALUE }
@@ -77,8 +83,14 @@ export const PersistentPtyGroup = HttpApiGroup.make("server.experimental")
params: { ptyID: Pty.ID },
payload: PersistentPty.UpdateInput,
success: Schema.Struct({ data: PersistentPty.Info }),
error: terminalErrors,
}).annotateMerge(OpenApi.annotations({ identifier: "experimental.persistentPty.update" })),
error: [...terminalErrors, PtyFeatureUnsupportedError],
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.persistentPty.update",
description:
"Resize when size is present and rename when title is present; a null title clears the user title so the shell's title shows again. Renaming fails with 501 while the terminal service predates renaming.",
}),
),
)
.add(
HttpApiEndpoint.get("persistentPty.snapshot", "/api/experimental/persistent-pty/:ptyID/snapshot", {
+5 -2
View File
@@ -9,6 +9,7 @@ import { Session } from "./session.js"
export const Info = Schema.Struct({
...Pty.Info.fields,
sessionID: Session.ID,
userTitle: Schema.NullOr(Schema.String),
foregroundProcess: Schema.NullOr(Schema.String),
size: Schema.Struct({ cols: PositiveInt, rows: PositiveInt }),
output: Schema.Struct({ head: NonNegativeInt, tail: NonNegativeInt }),
@@ -35,7 +36,8 @@ export interface CreateInput extends Schema.Schema.Type<typeof CreateInput> {}
export const UpdateInput = Schema.Struct({
attachmentID: optional(Schema.String),
size: Schema.Struct({ cols: PositiveInt, rows: PositiveInt }),
size: optional(Schema.Struct({ cols: PositiveInt, rows: PositiveInt })),
title: optional(Schema.NullOr(Schema.String)),
}).annotate({ identifier: "PersistentPty.UpdateInput" })
export interface UpdateInput extends Schema.Schema.Type<typeof UpdateInput> {}
@@ -67,4 +69,5 @@ 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) }
export const Updated = ephemeral({ type: "persistent-pty.updated", schema: { sessionID: Session.ID, terminal: Info } })
export const Event = { Added, Removed, Updated, Definitions: inventory(Added, Removed, Updated) }
+5 -1
View File
@@ -61,7 +61,11 @@ describe("public event manifest", () => {
expect(FileSystemV1.Event.Definitions).toEqual([FileSystemV1.Event.Edited])
expect(Integration.Event.Definitions).toEqual([Integration.Event.Updated])
expect(Permission.Event.Definitions).toEqual([Permission.Event.Asked, Permission.Event.Replied])
expect(PersistentPty.Event.Definitions).toEqual([PersistentPty.Event.Added, PersistentPty.Event.Removed])
expect(PersistentPty.Event.Definitions).toEqual([
PersistentPty.Event.Added,
PersistentPty.Event.Removed,
PersistentPty.Event.Updated,
])
expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled])
expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated])
expect(McpEvent.Definitions).toEqual([McpEvent.ToolsChanged, McpEvent.ResourcesChanged, McpEvent.StatusChanged])
+23 -6
View File
@@ -3,6 +3,7 @@ import { PtyTicket } from "@opencode/core/pty/ticket"
import {
ForbiddenError,
InvalidRequestError,
PtyFeatureUnsupportedError,
PtyNotFoundError,
ServiceUnavailableError,
} from "@opencode/protocol/errors"
@@ -104,10 +105,24 @@ export const PersistentPtyHandler = HttpApiBuilder.group(Api, "server.experiment
.handle(
"persistentPty.update",
Effect.fn(function* (ctx) {
yield* pty
.resize(ctx.params.ptyID, ctx.payload.size.cols, ctx.payload.size.rows, ctx.payload.attachmentID)
.pipe(mapTerminalError)
return { data: yield* pty.get(ctx.params.ptyID).pipe(mapTerminalError) }
if (ctx.payload.size)
yield* pty
.resize(ctx.params.ptyID, ctx.payload.size.cols, ctx.payload.size.rows, ctx.payload.attachmentID)
.pipe(mapTerminalError)
if (ctx.payload.title === undefined) return { data: yield* pty.get(ctx.params.ptyID).pipe(mapTerminalError) }
return {
data: yield* pty.rename(ctx.params.ptyID, ctx.payload.title).pipe(
Effect.catchTags({
"PersistentPty.NotFoundError": notFound,
"PersistentPty.UnsupportedError": (error) =>
new PtyFeatureUnsupportedError({
feature: error.feature,
message: "Terminal renaming will be available after OpenCode's terminal service restarts",
}),
"PersistentPty.UnavailableError": unavailable,
}),
),
}
}),
)
.handle(
@@ -246,11 +261,13 @@ const mapUnavailable = <A>(effect: Effect.Effect<A, PersistentPty.UnavailableErr
const mapTerminalError = <A>(effect: Effect.Effect<A, PersistentPty.NotFoundError | PersistentPty.UnavailableError>) =>
effect.pipe(
Effect.catchTags({
"PersistentPty.NotFoundError": (error) =>
new PtyNotFoundError({ ptyID: error.ptyID, message: `PTY session not found: ${error.ptyID}` }),
"PersistentPty.NotFoundError": notFound,
"PersistentPty.UnavailableError": unavailable,
}),
)
const notFound = (error: PersistentPty.NotFoundError) =>
new PtyNotFoundError({ ptyID: error.ptyID, message: `PTY session not found: ${error.ptyID}` })
const unavailable = (error: PersistentPty.UnavailableError) =>
new ServiceUnavailableError({ message: error.message, service: "opencode-pty" })
+134
View File
@@ -9,12 +9,15 @@ import { Session } from "@opencode/schema/session"
import { Effect, Exit, Schema, Scope } from "effect"
import { HttpServer } from "effect/http"
import { OpenCode } from "../../client/src/promise/index"
import { fakeDaemon } from "../../core/test/fixture/persistent-pty-daemon"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
// These tests drive Unix shell commands, so they skip Windows; the package resolves no binary on unsupported platforms.
const binary = process.platform === "win32" ? undefined : (process.env.OPENCODE_PTY_BIN ?? binaryPath)
const smoke = binary !== undefined && existsSync(binary) ? it.live : it.live.skip
// The published @opencode/pty predates renaming, so only a local build set through OPENCODE_PTY_BIN can rename.
const renameSmoke = process.env.OPENCODE_PTY_BIN ? smoke : it.live.skip
smoke(
"reads the latest controlled terminal with optional physical line counts through the SDK",
@@ -132,6 +135,137 @@ smoke(
20_000,
)
renameSmoke(
"renames and resizes a terminal independently through the SDK",
() =>
Effect.gen(function* () {
const fixture = yield* testDirectory()
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: fixture.database },
pty: { root: fixture.directory },
fs: { filewatcher: false },
})
const base = HttpServer.formatAddress(server.address)
const client = OpenCode.make({ baseUrl: base, headers: { authorization: `Basic ${btoa("opencode:secret")}` } })
const sessionID = Session.ID.make("ses_persistent_pty_rename")
const terminal = yield* Effect.promise(() =>
client.experimental.persistentPty.create({
sessionID,
command: "/bin/sh",
args: ["-c", "stty -echo; printf '\\033]2;from-shell\\007ready'; exec cat"],
cwd: fixture.root,
title: "created",
env: {},
size: { cols: 40, rows: 10 },
}),
)
expect(terminal.userTitle).toBeNull()
yield* waitForText(base, terminal.id, "ready")
yield* Effect.promise(() => waitForTitle(client, terminal.id, "from-shell"))
const events = yield* Effect.acquireRelease(
Effect.promise(() => openEventStream(base)),
(stream) => Effect.promise(() => stream.close()),
)
const renamed = yield* Effect.promise(() =>
client.experimental.persistentPty.update({ ptyID: terminal.id, title: "build" }),
)
expect(renamed).toMatchObject({ title: "build", userTitle: "build", size: { cols: 40, rows: 10 } })
expect(yield* Effect.promise(() => events.next("persistent-pty.updated"))).toMatchObject({
data: { sessionID, terminal: { id: terminal.id, title: "build", userTitle: "build" } },
})
expect(yield* Effect.promise(() => client.experimental.persistentPty.list({ sessionID }))).toMatchObject([
{ id: terminal.id, title: "build", userTitle: "build" },
])
const resized = yield* Effect.promise(() =>
client.experimental.persistentPty.update({ ptyID: terminal.id, size: { cols: 50, rows: 12 } }),
)
expect(resized).toMatchObject({ title: "build", userTitle: "build", size: { cols: 50, rows: 12 } })
const cleared = yield* Effect.promise(() =>
client.experimental.persistentPty.update({ ptyID: terminal.id, title: null }),
)
expect(cleared).toMatchObject({ title: "from-shell", userTitle: null, size: { cols: 50, rows: 12 } })
expect(yield* Effect.promise(() => events.next("persistent-pty.updated"))).toMatchObject({
data: { sessionID, terminal: { id: terminal.id, title: "from-shell", userTitle: null } },
})
const missing = yield* Effect.promise(async () => {
const response = await fetch(new URL("/api/experimental/persistent-pty/pty_persistent_1", base), {
method: "PUT",
headers: { authorization: `Basic ${btoa("opencode:secret")}`, "content-type": "application/json" },
body: JSON.stringify({ title: "nope" }),
})
await response.arrayBuffer()
return response.status
})
expect(missing).toBe(404)
yield* Effect.promise(() => client.experimental.persistentPty.shutdown())
}),
20_000,
)
it.live("reports renaming as unsupported on a daemon without the rename feature", () =>
Effect.gen(function* () {
const fixture = yield* testDirectory()
const daemon = yield* fakeDaemon()
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: fixture.database },
pty: daemon.options,
fs: { filewatcher: false },
})
const base = HttpServer.formatAddress(server.address)
const created = yield* request(base, "POST", "/api/experimental/session/ses_old_daemon/terminal", {
args: [],
cwd: fixture.root,
title: "created",
env: {},
})
const terminal = Schema.decodeUnknownSync(PersistentPty.Info)(created.data)
expect(terminal.userTitle).toBeNull()
const response = yield* Effect.promise(async () => {
const response = await fetch(new URL(`/api/experimental/persistent-pty/${terminal.id}`, base), {
method: "PUT",
headers: { authorization: `Basic ${btoa("opencode:secret")}`, "content-type": "application/json" },
body: JSON.stringify({ title: "build" }),
})
return { status: response.status, body: await response.json() }
})
expect(response).toEqual({
status: 501,
body: {
_tag: "PtyFeatureUnsupportedError",
feature: "rename",
message: "Terminal renaming will be available after OpenCode's terminal service restarts",
},
})
expect(daemon.ops).not.toContain("rename")
expect((yield* request(base, "GET", `/api/experimental/persistent-pty/${terminal.id}`)).data).toMatchObject({
title: "test",
userTitle: null,
})
}),
)
async function waitForTitle(client: ReturnType<typeof OpenCode.make>, ptyID: string, title: string) {
for (let attempt = 0; attempt < 100; attempt++) {
if ((await client.experimental.persistentPty.get({ ptyID })).title === title) return
await Bun.sleep(20)
}
throw new Error(`Terminal ${ptyID} did not report the title ${title}`)
}
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 })
@@ -53,7 +53,7 @@ export const { use: useSessionTerminals, provider: SessionTerminalsProvider } =
})
}
for (const type of ["persistent-pty.added", "persistent-pty.removed"] as const) {
for (const type of ["persistent-pty.added", "persistent-pty.removed", "persistent-pty.updated"] as const) {
onCleanup(
event.on(type, (evt) => {
if (!available() || !terminals[evt.data.sessionID]) return