Something went wrong. Try again.
opencode v2 mirror
Something went wrong. Try again.
15 kB · 426 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427export * as PersistentPty from "./index.js"
import os from "node:os"import path from "node:path"import { Context, Effect, Layer, Schema } from "effect"import { makeGlobalNode } from "@opencode/util/effect/app-node"import { Added, Handoff, PersistentPty, ReadLines, Removed, type ReadResult } from "@opencode/schema/persistent-pty"import { Session } from "@opencode/schema/session"import { Bus } from "../bus.js"import { Pty } from "@opencode/schema/pty"import { Global } from "@opencode/util/global"import { ShellSelect } from "../shell/select.js"import { makeDaemonTransport, type DaemonTransport, type Role, type StreamEvent, type WireResponse, type WireTerminal,} from "./daemon.js"import { resolveBinary } from "#persistent-pty-binary"
export type { Role, StreamEvent } from "./daemon.js"export { Handoff } from "@opencode/schema/persistent-pty"
export const Options = Schema.Struct({ handoff: Schema.optional(Handoff) })export type Options = typeof Options.Type
export type Info = PersistentPty.Info
export type Snapshot = PersistentPty.Snapshot
export type Attachment = { readonly info: Info readonly role: Role readonly generation: number readonly replay: { readonly requestedOffset: number readonly availableOffset: number readonly endOffset: number readonly truncated: boolean readonly data: Uint8Array } readonly activate: () => void readonly detach: () => void}
export class UnavailableError extends Schema.TaggedError<UnavailableError>()("PersistentPty.UnavailableError", { message: Schema.String,}) {}
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("PersistentPty.NotFoundError", { ptyID: Pty.ID,}) {}
export interface Interface { readonly list: (sessionID?: Session.ID) => Effect.Effect<Info[], UnavailableError> readonly get: (id: Pty.ID) => Effect.Effect<Info, NotFoundError | UnavailableError> readonly create: ( sessionID: Session.ID, input: { readonly command?: string readonly args: readonly string[] readonly cwd?: string readonly title: string readonly env: Readonly<Record<string, string>> readonly cols?: number readonly rows?: number }, ) => Effect.Effect<Info, UnavailableError> readonly write: ( id: Pty.ID, data: string, attachmentID?: string, ) => Effect.Effect<void, NotFoundError | UnavailableError> readonly resize: ( id: Pty.ID, cols: number, rows: number, attachmentID?: string, ) => Effect.Effect<void, NotFoundError | UnavailableError> readonly control: ( id: Pty.ID, attachmentID: string, cols: number, rows: number, ) => Effect.Effect<void, NotFoundError | UnavailableError> readonly input: ( id: Pty.ID, attachmentID: string, cols: number, rows: number, data: Uint8Array, ) => Effect.Effect<void, NotFoundError | UnavailableError> readonly snapshot: (id: Pty.ID) => Effect.Effect<Snapshot, NotFoundError | UnavailableError> readonly read: (sessionID: Session.ID, lines?: number) => Effect.Effect<ReadResult | null, UnavailableError> readonly remove: (id: Pty.ID) => Effect.Effect<void, NotFoundError | UnavailableError> readonly shutdown: () => Effect.Effect<void, UnavailableError> readonly handoff: () => Effect.Effect<Handoff | null, UnavailableError> readonly attach: ( id: Pty.ID, input: { readonly cursor: number readonly attachmentID: string readonly role: Role readonly takeover?: boolean readonly onEvent: (event: StreamEvent) => void readonly onEnd: () => void }, ) => Effect.Effect<Attachment, NotFoundError | UnavailableError>}
export class Service extends Context.Service<Service, Interface>()("@opencode/PersistentPty") {}
const makeLayer = (options: Options = {}) => Layer.effect( Service, Effect.gen(function* () { const bus = yield* Bus.Service const global = yield* Global.Service const context = yield* Effect.context() const runFork = Effect.runForkWith(context) let binary: Promise<string> | undefined const daemon = yield* makeDaemonTransport( options.handoff?.directory ?? runtimeDirectory(), () => (binary ??= resolveBinary(global.bin).catch((error) => { binary = undefined throw error })), options.handoff, ).pipe(Effect.mapError(unavailable)) const removing = new Set<Pty.ID>() // Controller activity selects a terminal; observer reads and pane visibility do not. const current = new Map<Session.ID, Pty.ID>()
const list = Effect.fn("PersistentPty.list")(function* (sessionID?: Session.ID) { const response = yield* optionalRequest(daemon, { op: "list" }) if (!response) return [] if (response.type !== "terminals") return yield* unexpected(response) return response.terminals .map(toInfo) .filter((terminal) => sessionID === undefined || terminal.sessionID === sessionID) })
const get = Effect.fn("PersistentPty.get")(function* (id: Pty.ID) { const found = (yield* list()).find((terminal) => terminal.id === id) if (!found) return yield* new NotFoundError({ ptyID: id }) return found })
const create = Effect.fn("PersistentPty.create")(function* ( sessionID: Session.ID, input: Parameters<Interface["create"]>[1], ) { const response = yield* request( daemon, { op: "create", program: input.command ?? ShellSelect.environment(global.bin), args: input.args, cwd: input.cwd ?? path.resolve("/"), title: input.title, group_id: sessionID, env: input.env, cols: input.cols ?? 80, rows: input.rows ?? 24, }, true, ) if (response.type !== "created") return yield* unexpected(response) const terminal = toInfo(response.terminal) yield* bus.publish(Added, { sessionID, terminal }) return terminal })
const write = Effect.fn("PersistentPty.write")(function* (id: Pty.ID, data: string, attachmentID?: string) { yield* get(id) const response = yield* request(daemon, { op: "write", id: fromID(id), attachment_id: attachmentID ?? null, data_base64: Buffer.from(data).toString("base64"), }) if (response.type !== "ok") return yield* unexpected(response) return undefined })
const resize = Effect.fn("PersistentPty.resize")(function* ( id: Pty.ID, cols: number, rows: number, attachmentID?: string, ) { const terminal = yield* get(id) const response = yield* request(daemon, { op: "resize", id: fromID(id), attachment_id: attachmentID ?? null, cols, rows, }) if (response.type !== "ok") return yield* unexpected(response) current.set(terminal.sessionID, id) return undefined })
const control = Effect.fn("PersistentPty.control")(function* ( id: Pty.ID, attachmentID: string, cols: number, rows: number, ) { const terminal = yield* get(id) const response = yield* request(daemon, { op: "control", id: fromID(id), attachment_id: attachmentID, cols, rows, }) if (response.type !== "ok") return yield* unexpected(response) current.set(terminal.sessionID, id) return undefined })
const input = Effect.fn("PersistentPty.input")(function* ( id: Pty.ID, attachmentID: string, cols: number, rows: number, data: Uint8Array, ) { const terminal = yield* get(id) const response = yield* request(daemon, { op: "input", id: fromID(id), attachment_id: attachmentID, cols, rows, data_base64: Buffer.from(data).toString("base64"), }) if (response.type !== "ok") return yield* unexpected(response) current.set(terminal.sessionID, id) return undefined })
const snapshot = Effect.fn("PersistentPty.snapshot")(function* (id: Pty.ID) { yield* get(id) const response = yield* request(daemon, { op: "snapshot", id: fromID(id) }) if (response.type !== "snapshot") return yield* unexpected(response) return { info: toInfo(response.terminal), text: response.text, checkpoint: Buffer.from(response.checkpoint_base64, "base64"), cursor: { x: response.cursor_x, y: response.cursor_y }, } })
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) })
const handoff = Effect.fn("PersistentPty.handoff")(() => daemon.handoff.pipe(Effect.mapError(unavailable)))
const removeVisibleExit = (id: Pty.ID) => { if (removing.has(id)) return removing.add(id) runFork( remove(id).pipe( Effect.catchTags({ "PersistentPty.NotFoundError": () => Effect.void, "PersistentPty.UnavailableError": (error) => Effect.logWarning("failed to remove visible exited terminal", { id, error: error.message }), }), Effect.ensuring(Effect.sync(() => removing.delete(id))), ), ) }
const attach = Effect.fn("PersistentPty.attach")(function* ( id: Pty.ID, input: Parameters<Interface["attach"]>[1], ) { yield* get(id) const attachment = yield* daemon .subscribe(fromID(id), { ...input, onEvent: (event) => { if (event.type === "exited") removeVisibleExit(id) input.onEvent(event) }, }) .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, generation: attachment.generation, replay: attachment.replay, activate: attachment.activate, detach: attachment.detach, } })
return Service.of({ list, get, create, write, resize, control, input, snapshot, read, remove, shutdown, handoff, attach, }) }), )
export const layer = makeLayer()export const configured = (options?: Options) => makeGlobalNode({ service: Service, layer: options === undefined ? layer : makeLayer(options), deps: [Bus.node, Global.node], })export const node = configured()
const request = (daemon: DaemonTransport, value: object, start = false) => daemon.request(value, start).pipe(Effect.mapError(unavailable))
const optionalRequest = (daemon: DaemonTransport, value: object) => daemon.requestIfRunning(value).pipe(Effect.mapError(unavailable))
const unexpected = (response: WireResponse) => Effect.fail(new UnavailableError({ message: `unexpected opencode-pty response: ${response.type}` }))
const unavailable = (error: unknown) => new UnavailableError({ message: error instanceof Error ? error.message : String(error) })
const runtimeDirectory = () => { const root = process.env.OPENCODE_PTY_RUNTIME_DIR ?? (process.env.XDG_RUNTIME_DIR ? path.join(process.env.XDG_RUNTIME_DIR, "opencode-pty") : path.join( os.tmpdir(), `opencode-pty-${typeof process.getuid === "function" ? process.getuid() : process.env.USER || "unknown"}`, )) return path.join(root, crypto.randomUUID())}
function toInfo(value: WireTerminal): Info { const status = value.lifecycle.status return { ...Pty.Info.make({ id: toID(value.id), title: value.title, command: value.command[0] || "", args: value.command.slice(1), cwd: value.cwd, status: status === "running" ? "running" : "exited", pid: value.pid ?? 0, ...(status === "exited" ? { exitCode: value.lifecycle.exit_code ?? undefined } : {}), }), sessionID: Session.ID.make(value.group_id), foregroundProcess: value.foreground_process, size: { cols: value.cols, rows: value.rows }, output: { head: value.output_head, tail: value.output_tail }, }}
function toID(value: number) { return Pty.ID.make(`pty_persistent_${value}`)}
function fromID(value: Pty.ID) { if (!value.startsWith("pty_persistent_")) throw new Error(`invalid persistent PTY ID: ${value}`) const parsed = Number(value.slice("pty_persistent_".length)) if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`invalid persistent PTY ID: ${value}`) return parsed}