diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 2e2a0b9651d..9998568b229 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -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 = ( 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 } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index a129b28e93e..e837128e444 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -1546,7 +1546,7 @@ const EndpointExperimentalPersistentPtyUpdate = preserveEffect()( 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), diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 42b961eacc7..3777b5848a1 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -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, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index ac80fd23e80..a3330d78099 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -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"] diff --git a/packages/core/src/persistent-pty/daemon.ts b/packages/core/src/persistent-pty/daemon.ts index 21bcaab2a37..50d2284340c 100644 --- a/packages/core/src/persistent-pty/daemon.ts +++ b/packages/core/src/persistent-pty/daemon.ts @@ -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 readonly requestIfRunning: (value: object) => Effect.Effect + readonly supports: (feature: string) => Effect.Effect readonly shutdown: Effect.Effect readonly handoff: Effect.Effect 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 diff --git a/packages/core/src/persistent-pty/index.ts b/packages/core/src/persistent-pty/index.ts index ed32f155d9d..22f38818ee6 100644 --- a/packages/core/src/persistent-pty/index.ts +++ b/packages/core/src/persistent-pty/index.ts @@ -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()("Pers message: Schema.String, }) {} +export class UnsupportedError extends Schema.TaggedError()("PersistentPty.UnsupportedError", { + feature: Schema.String, +}) {} + export interface Interface { readonly list: (sessionID?: Session.ID) => Effect.Effect readonly get: (id: Pty.ID) => Effect.Effect @@ -103,6 +115,10 @@ export interface Interface { rows: number, data: Uint8Array, ) => Effect.Effect + readonly rename: ( + id: Pty.ID, + title: string | null, + ) => 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 @@ -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 }, diff --git a/packages/core/test/fixture/persistent-pty-daemon.ts b/packages/core/test/fixture/persistent-pty-daemon.ts index 0555dd28699..c4a5e57794d 100644 --- a/packages/core/test/fixture/persistent-pty-daemon.ts +++ b/packages/core/test/fixture/persistent-pty-daemon.ts @@ -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 & { user_title?: string | null }> = [] + const failTerminate = new Set() + 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, diff --git a/packages/core/test/persistent-pty-daemon.test.ts b/packages/core/test/persistent-pty-daemon.test.ts index 931abbb613c..70eb640737c 100644 --- a/packages/core/test/persistent-pty-daemon.test.ts +++ b/packages/core/test/persistent-pty-daemon.test.ts @@ -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() diff --git a/packages/core/test/persistent-pty-session.test.ts b/packages/core/test/persistent-pty-session.test.ts index 16a36db7dca..e154ebe85b5 100644 --- a/packages/core/test/persistent-pty-session.test.ts +++ b/packages/core/test/persistent-pty-session.test.ts @@ -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[] = [] - const failTerminate = new Set() - 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([ diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 93984c663db..d5f7cd24d33 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -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": { diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index c9f8fa5c3d5..5b9937c646f 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -274,6 +274,15 @@ export class PtyNotFoundError extends Schema.TaggedError()( { httpApiStatus: 404 }, ) {} +export class PtyFeatureUnsupportedError extends Schema.TaggedError()( + "PtyFeatureUnsupportedError", + { + feature: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 501 }, +) {} + export class ShellNotFoundError extends Schema.TaggedError()( "ShellNotFoundError", { diff --git a/packages/protocol/src/groups/persistent-pty.ts b/packages/protocol/src/groups/persistent-pty.ts index 720ea75bd86..65e50998502 100644 --- a/packages/protocol/src/groups/persistent-pty.ts +++ b/packages/protocol/src/groups/persistent-pty.ts @@ -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", { diff --git a/packages/schema/src/persistent-pty.ts b/packages/schema/src/persistent-pty.ts index cef23f59806..c75586b4b96 100644 --- a/packages/schema/src/persistent-pty.ts +++ b/packages/schema/src/persistent-pty.ts @@ -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 {} 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 {} @@ -67,4 +69,5 @@ 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) } +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) } diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index d7c07244eda..e3d3c583714 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -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]) diff --git a/packages/server/src/handlers/persistent-pty.ts b/packages/server/src/handlers/persistent-pty.ts index b8c918c9bc4..f5297bb4720 100644 --- a/packages/server/src/handlers/persistent-pty.ts +++ b/packages/server/src/handlers/persistent-pty.ts @@ -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 = (effect: Effect.Effect(effect: Effect.Effect) => 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" }) diff --git a/packages/server/test/persistent-pty.test.ts b/packages/server/test/persistent-pty.test.ts index 0ecfe5c2d07..93b1e380c63 100644 --- a/packages/server/test/persistent-pty.test.ts +++ b/packages/server/test/persistent-pty.test.ts @@ -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({ + 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({ + 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, 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, sessionID: string, ptyID: string) { for (let attempt = 0; attempt < 100; attempt++) { const result = await client.experimental.persistentPty.read({ sessionID }) diff --git a/packages/tui/src/context/session-terminals.tsx b/packages/tui/src/context/session-terminals.tsx index 0e01dededd9..e85a6fcb6a5 100644 --- a/packages/tui/src/context/session-terminals.tsx +++ b/packages/tui/src/context/session-terminals.tsx @@ -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