Compare commits

...

1 Commits

Author SHA1 Message Date
James Long f8c40005d9 feat(pty): add server-owned daemons and remote shell defaults 2026-08-27 12:35:53 +00:00
24 changed files with 1453 additions and 721 deletions
+7 -2
View File
@@ -27,8 +27,13 @@ export async function resolveOpencodePty(target: Target): Promise<OpencodePtyAss
.map((value) => (value === "glibc" ? "gnu" : value))
.join("-")
const name = `@opencode-ai/pty-${suffix}`
const source = pty.resolve(`${name}/bin/opencode-pty`)
const manifest: unknown = JSON.parse(await readFile(pty.resolve(`${name}/package.json`), "utf8"))
const local = process.env.OPENCODE_PTY_BIN
if (local && (target.platform !== process.platform || target.arch !== process.arch))
throw new Error("OPENCODE_PTY_BIN can only be embedded in a build for the current platform and architecture")
const source = local ? path.resolve(local) : pty.resolve(`${name}/bin/opencode-pty`)
const manifest: unknown = local
? { version: "local" }
: JSON.parse(await readFile(pty.resolve(`${name}/package.json`), "utf8"))
if (!manifest || typeof manifest !== "object" || !("version" in manifest) || typeof manifest.version !== "string")
throw new Error(`Invalid package metadata for ${name}`)
+11 -1
View File
@@ -7,7 +7,8 @@ import { Global } from "@opencode-ai/util/global"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto"
import { Effect, Option, Redacted, Schedule } from "effect"
import { Effect, Option, Redacted, Schedule, Schema } from "effect"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
@@ -40,6 +41,14 @@ export const run = Effect.fnUntraced(function* (options: Options) {
})
const processEffect = Effect.fnUntraced(function* (options: Options) {
const inherited = process.env.OPENCODE_PTY_HANDOFF
delete process.env.OPENCODE_PTY_HANDOFF
const handoff =
inherited === undefined
? undefined
: yield* Schema.decodeUnknownEffect(Schema.fromJsonString(PersistentPty.Handoff))(inherited).pipe(
Effect.mapError(() => new Error("Invalid PTY restart handoff")),
)
const global = yield* Global.Service
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
return yield* Effect.scoped(
@@ -80,6 +89,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
hostname,
port,
password,
pty: { handoff },
simulation: truthy(process.env.OPENCODE_SIMULATE),
database: {
path:
+16 -2
View File
@@ -1658,9 +1658,9 @@ export type ExperimentalPersistentPtyListOperation<E = never> = (
export type ExperimentalPersistentPtyCreateInput = {
readonly sessionID: Session.ID
readonly command: string
readonly command?: string | undefined
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly cwd?: string | undefined
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number } | undefined
@@ -1689,6 +1689,19 @@ export type ExperimentalPersistentPtyShutdownOperation<E = never> = () => Effect
E
>
export type ExperimentalPersistentPtyPrepareRestartOutput = {
readonly handoff: {
readonly directory: string
readonly instanceID: string
readonly ticket: string
readonly expiresAt: number
} | null
}
export type ExperimentalPersistentPtyPrepareRestartOperation<E = never> = () => Effect.Effect<
ExperimentalPersistentPtyPrepareRestartOutput,
E
>
export type ExperimentalPersistentPtyGetInput = { readonly ptyID: Pty.ID }
export type ExperimentalPersistentPtyGetOutput = {
readonly id: Pty.ID
@@ -1775,6 +1788,7 @@ export interface ExperimentalApi<E = never> {
readonly list: ExperimentalPersistentPtyListOperation<E>
readonly create: ExperimentalPersistentPtyCreateOperation<E>
readonly shutdown: ExperimentalPersistentPtyShutdownOperation<E>
readonly prepareRestart: ExperimentalPersistentPtyPrepareRestartOperation<E>
readonly get: ExperimentalPersistentPtyGetOperation<E>
readonly update: ExperimentalPersistentPtyUpdateOperation<E>
readonly snapshot: ExperimentalPersistentPtySnapshotOperation<E>
@@ -203,6 +203,7 @@ import type {
ExperimentalPersistentPtyCreateInput,
ExperimentalPersistentPtyCreateOutput,
ExperimentalPersistentPtyShutdownOutput,
ExperimentalPersistentPtyPrepareRestartOutput,
ExperimentalPersistentPtyGetInput,
ExperimentalPersistentPtyGetOutput,
ExperimentalPersistentPtyUpdateInput,
@@ -1265,6 +1266,11 @@ const EndpointExperimentalPersistentPtyShutdown = (raw: RawClient["server.experi
raw["persistentPty.shutdown"]({}).pipe(Effect.mapError(mapClientError)),
)
const EndpointExperimentalPersistentPtyPrepareRestart = (raw: RawClient["server.experimental"]) => () =>
preserveEffect<ExperimentalPersistentPtyPrepareRestartOutput>()(
raw["persistentPty.prepareRestart"]({}).pipe(Effect.mapError(mapClientError)),
)
const EndpointExperimentalPersistentPtyGet =
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyGetInput) =>
preserveEffect<ExperimentalPersistentPtyGetOutput>()(
@@ -1318,6 +1324,7 @@ const adaptGroupExperimental = (raw: RawClient["server.experimental"]) => ({
list: EndpointExperimentalPersistentPtyList(raw),
create: EndpointExperimentalPersistentPtyCreate(raw),
shutdown: EndpointExperimentalPersistentPtyShutdown(raw),
prepareRestart: EndpointExperimentalPersistentPtyPrepareRestart(raw),
get: EndpointExperimentalPersistentPtyGet(raw),
update: EndpointExperimentalPersistentPtyUpdate(raw),
snapshot: EndpointExperimentalPersistentPtySnapshot(raw),
+19 -2
View File
@@ -11,6 +11,7 @@ import {
} from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
import { ServiceHandoff } from "../service-handoff.js"
export * from "../service.js"
/** Contents of the local service registration file. */
@@ -65,9 +66,10 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
const spawnContender = Effect.gen(function* () {
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
const env = yield* Effect.tryPromise(() => ServiceHandoff.environment(options.file ?? fallback(), options.env))
return yield* Effect.try({
try: () => {
return spawnServiceContender(command, args, options.env)
return spawnServiceContender(command, args, env)
},
catch: (cause) => new Error("Failed to start server", { cause }),
})
@@ -83,6 +85,8 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
}
if (timeouts.count >= 3) {
yield* announce("missing")
yield* Effect.logWarning("Background service is unresponsive; recovery cannot preserve persistent terminals")
yield* Effect.tryPromise(() => ServiceHandoff.clear(options.file ?? fallback()))
yield* terminate(info, options, timing)
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
@@ -91,11 +95,23 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
if (service !== undefined) {
spawnDelay = timing.spawnDelay
const compatible = !service.legacy && matchesVersion(service.version, options)
if (compatible && service.state === "ready") return Option.some(service)
if (compatible && service.state === "ready") {
yield* Effect.tryPromise(() => ServiceHandoff.complete(options.file ?? fallback(), service.info))
return Option.some(service)
}
if (compatible && service.state === "failed")
return yield* Effect.fail(new Error("Background service failed to start"))
if (compatible) return Option.none<LocalService>()
yield* announce("version-mismatch", service.version)
if (!service.legacy && service.state === "ready")
yield* Effect.tryPromise(() =>
ServiceHandoff.prepare(options.file ?? fallback(), service.info, timing.requestTimeout),
)
else {
if (!service.legacy)
yield* Effect.logWarning("Background service is not ready; replacement cannot preserve persistent terminals")
yield* Effect.tryPromise(() => ServiceHandoff.clear(options.file ?? fallback()))
}
yield* terminate(service.info, options, timing).pipe(Effect.ignore)
lastSpawn = 0
return Option.none<LocalService>()
@@ -129,6 +145,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
/** Stop the registered local service. */
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
yield* Effect.tryPromise(() => ServiceHandoff.clear(options.file ?? fallback()))
const info = yield* read(options.file)
if (info !== undefined) yield* terminate(info, options, defaultEnsureTiming)
})
@@ -199,6 +199,7 @@ import type {
ExperimentalPersistentPtyCreateInput,
ExperimentalPersistentPtyCreateOutput,
ExperimentalPersistentPtyShutdownOutput,
ExperimentalPersistentPtyPrepareRestartOutput,
ExperimentalPersistentPtyGetInput,
ExperimentalPersistentPtyGetOutput,
ExperimentalPersistentPtyUpdateInput,
@@ -1707,7 +1708,7 @@ export function make(options: ClientOptions) {
size: input["size"],
},
successStatus: 200,
declaredStatuses: [400, 503, 401],
declaredStatuses: [400, 503, 404, 401],
empty: false,
},
requestOptions,
@@ -1723,6 +1724,17 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
prepareRestart: (requestOptions?: RequestOptions) =>
request<ExperimentalPersistentPtyPrepareRestartOutput>(
{
method: "POST",
path: `/api/experimental/persistent-pty/prepare-restart`,
successStatus: 200,
declaredStatuses: [503, 401, 400],
empty: false,
},
requestOptions,
),
get: (input: ExperimentalPersistentPtyGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ExperimentalPersistentPtyGetOutput }>(
{
+18 -14
View File
@@ -376,6 +376,8 @@ export type SessionStatus =
export type PtyTicketConnectToken = { ticket: string; expires_in: number }
export type PersistentPtyHandoff = { directory: string; instanceID: string; ticket: string; expiresAt: number }
export type ShellInfo1 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
@@ -5753,50 +5755,50 @@ export type ExperimentalPersistentPtyListOutput = { data: Array<PersistentPtyInf
export type ExperimentalPersistentPtyCreateInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly command: {
readonly command: string
readonly command?: {
readonly command?: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly cwd?: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number }
}["command"]
readonly args: {
readonly command: string
readonly command?: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly cwd?: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number }
}["args"]
readonly cwd: {
readonly command: string
readonly cwd?: {
readonly command?: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly cwd?: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number }
}["cwd"]
readonly title: {
readonly command: string
readonly command?: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly cwd?: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number }
}["title"]
readonly env: {
readonly command: string
readonly command?: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly cwd?: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number }
}["env"]
readonly size?: {
readonly command: string
readonly command?: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly cwd?: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number }
@@ -5807,6 +5809,8 @@ export type ExperimentalPersistentPtyCreateOutput = { data: PersistentPtyInfo }[
export type ExperimentalPersistentPtyShutdownOutput = void
export type ExperimentalPersistentPtyPrepareRestartOutput = { handoff: PersistentPtyHandoff | null }
export type ExperimentalPersistentPtyGetInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
export type ExperimentalPersistentPtyGetOutput = { data: PersistentPtyInfo }["data"]
+22 -4
View File
@@ -10,6 +10,7 @@ import {
} from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
import { ServiceHandoff } from "../service-handoff.js"
import type { ServiceHealth } from "./generated/types.js"
export * from "../service.js"
@@ -43,11 +44,15 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
announced = true
options.onStart?.(reason, previousVersion)
}
const spawnContender = () => {
const spawnContender = async () => {
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
if (command === undefined) throw new Error("Missing service command")
try {
return spawnServiceContender(command, args, options.env)
return spawnServiceContender(
command,
args,
await ServiceHandoff.environment(options.file ?? fallback(), options.env),
)
} catch (cause) {
throw new Error("Failed to start server", { cause })
}
@@ -64,6 +69,8 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
}
if (timeouts.count >= 3) {
announce("missing")
console.warn("Background service is unresponsive; recovery cannot preserve persistent terminals")
await ServiceHandoff.clear(options.file ?? fallback())
await terminate(registration.info, options, timing)
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
@@ -74,10 +81,20 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
spawnDelay = timing.spawnDelay
const service = registration.service
const compatible = !service.legacy && matchesVersion(service.version, options)
if (compatible && service.state === "ready") return service.endpoint
if (compatible && service.state === "ready") {
await ServiceHandoff.complete(options.file ?? fallback(), service.info)
return service.endpoint
}
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
if (!compatible) {
announce("version-mismatch", service.version)
if (!service.legacy && service.state === "ready")
await ServiceHandoff.prepare(options.file ?? fallback(), service.info, timing.requestTimeout)
else {
if (!service.legacy)
console.warn("Background service is not ready; replacement cannot preserve persistent terminals")
await ServiceHandoff.clear(options.file ?? fallback())
}
await terminate(service.info, options, timing).catch(() => undefined)
lastSpawn = 0
}
@@ -93,7 +110,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
announce("missing")
contenders.add(spawnContender())
contenders.add(await spawnContender())
lastSpawn = Date.now()
}
}
@@ -106,6 +123,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
/** Stop the registered local service. */
export async function stop(options: StopOptions = {}) {
await ServiceHandoff.clear(options.file ?? fallback())
const info = await read(options.file)
if (info !== undefined) await terminate(info, options, defaultEnsureTiming)
}
+1 -1
View File
@@ -13,7 +13,7 @@ const stderrLimit = 8 * 1024
export function spawnServiceContender(
command: string,
args: ReadonlyArray<string>,
env?: Readonly<Record<string, string>>,
env?: Readonly<Record<string, string | undefined>>,
): ServiceContender {
const child = spawn(command, args, {
detached: true,
+133
View File
@@ -0,0 +1,133 @@
export * as ServiceHandoff from "./service-handoff.js"
import type { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { readFile, rename, rm, writeFile } from "node:fs/promises"
import type { Info } from "./service.js"
type Sidecar = {
readonly source: Pick<Info, "id" | "pid" | "url">
readonly handoff: PersistentPty.Handoff | null
readonly expiresAt: number
}
/** Publish the ticket before stopping its owner so every replacement contender can adopt it. */
export async function prepare(file: string, info: Info, timeout: number) {
const existing = await read(file)
if (existing !== undefined && existing.expiresAt > Date.now() && same(existing.source, info)) return
const request = (operation: string) =>
fetch(new URL(`/api/experimental/persistent-pty/${operation}`, info.url), {
method: "POST",
headers:
info.password === undefined
? undefined
: { authorization: "Basic " + Buffer.from(`opencode:${info.password}`).toString("base64") },
signal: AbortSignal.timeout(timeout),
})
const result = await request("prepare-restart").then(
(response) => ({ response }),
(cause: unknown) => ({ cause }),
)
if ("cause" in result) {
// Another caller may already have prepared and stopped this server.
const concurrent = await read(file)
if (concurrent !== undefined && concurrent.expiresAt > Date.now() && same(concurrent.source, info)) return
throw new Error("Failed to prepare persistent terminals for service replacement", { cause: result.cause })
}
if (result.response.status === 404) {
console.warn("Background service cannot hand off persistent terminals; shutting them down before replacement")
const shutdown = await request("shutdown")
if (!shutdown.ok && shutdown.status !== 404)
throw new Error(`Failed to shut down persistent terminals before service replacement: HTTP ${shutdown.status}`)
await publish(file, info, null)
return
}
if (!result.response.ok)
throw new Error(`Failed to prepare persistent terminals for service replacement: HTTP ${result.response.status}`)
const body: unknown = await result.response.json()
if (typeof body !== "object" || body === null || !("handoff" in body))
throw new Error("Invalid persistent terminal handoff response")
if (body.handoff === null) {
await publish(file, info, null)
return
}
if (!isHandoff(body.handoff) || body.handoff.expiresAt <= Date.now())
throw new Error("Invalid or expired persistent terminal handoff")
await publish(file, info, body.handoff)
}
async function publish(file: string, info: Info, handoff: PersistentPty.Handoff | null) {
const temporary = `${file}.pty-handoff.${crypto.randomUUID()}.tmp`
await writeFile(
temporary,
JSON.stringify({
source: { id: info.id, pid: info.pid, url: info.url },
handoff,
expiresAt: handoff?.expiresAt ?? Date.now() + 30_000,
} satisfies Sidecar),
{ mode: 0o600, flag: "wx" },
)
await rename(temporary, file + ".pty-handoff").finally(() => rm(temporary, { force: true }))
}
export async function environment(file: string, env?: Readonly<Record<string, string>>) {
const record = await read(file)
const current: Info | undefined = await readFile(file, "utf8")
.then((text) => JSON.parse(text))
.catch(() => undefined)
const handoff =
record !== undefined && record.expiresAt > Date.now() && (current === undefined || same(record.source, current))
? record.handoff
: undefined
return { ...env, OPENCODE_PTY_HANDOFF: handoff == null ? undefined : JSON.stringify(handoff) }
}
export async function complete(file: string, info: Info) {
const record = await read(file)
if (record !== undefined && !same(record.source, info)) await clear(file)
}
export async function clear(file: string) {
await rm(file + ".pty-handoff", { force: true })
}
async function read(file: string): Promise<Sidecar | undefined> {
const value: unknown = await readFile(file + ".pty-handoff", "utf8")
.then((text) => JSON.parse(text))
.catch(() => undefined)
if (typeof value !== "object" || value === null || !("source" in value) || !("handoff" in value)) return
if (typeof value.source !== "object" || value.source === null) return
if (!("pid" in value.source) || typeof value.source.pid !== "number") return
if (!("url" in value.source) || typeof value.source.url !== "string") return
if ("id" in value.source && typeof value.source.id !== "string") return
if (value.handoff !== null && !isHandoff(value.handoff)) return
if (!("expiresAt" in value) || typeof value.expiresAt !== "number" || !Number.isFinite(value.expiresAt)) return
return {
source: {
id: "id" in value.source && typeof value.source.id === "string" ? value.source.id : undefined,
pid: value.source.pid,
url: value.source.url,
},
handoff: value.handoff,
expiresAt: value.expiresAt,
}
}
function same(left: Sidecar["source"], right: Info) {
return left.id === right.id && left.pid === right.pid && left.url === right.url
}
function isHandoff(value: unknown): value is PersistentPty.Handoff {
return (
typeof value === "object" &&
value !== null &&
"directory" in value &&
typeof value.directory === "string" &&
"instanceID" in value &&
typeof value.instanceID === "string" &&
"ticket" in value &&
typeof value.ticket === "string" &&
"expiresAt" in value &&
typeof value.expiresAt === "number" &&
Number.isFinite(value.expiresAt)
)
}
+38 -5
View File
@@ -11,8 +11,17 @@ if (mode === "record-start") {
await writeFile(registration + ".started", "")
process.exit(1)
}
if (mode === "environment")
await writeFile(registration + ".environment", process.env.OPENCODE_SERVICE_ENV_TEST ?? "")
if (mode === "environment") await writeFile(registration + ".environment", process.env.OPENCODE_SERVICE_ENV_TEST ?? "")
if (mode === "environment" || mode === "handoff-loser" || mode === "handoff-winner")
await appendFile(
registration + ".handoffs",
JSON.stringify({ handoff: process.env.OPENCODE_PTY_HANDOFF ?? null }) + "\n",
)
if (mode === "handoff-loser") {
await writeFile(registration + ".loser", "")
while (!(await Bun.file(registration + ".winner").exists())) await Bun.sleep(5)
process.exit(0)
}
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" || mode === "coordinated-failed-loser") {
@@ -30,14 +39,35 @@ if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" ||
let requests = 0
let version = "test"
if (mode === "old") version = "old"
if (mode === "old" || mode === "handoff" || mode === "handoff-null" || mode === "handoff-failed") version = "old"
if (mode === "incompatible") version = "1.9.0"
if (mode === "compatible" || mode === "delayed-compatible") version = "2.1.0-next.1"
const id = crypto.randomUUID()
const handoff = {
directory: registration + ".daemon",
instanceID: crypto.randomUUID(),
ticket: crypto.randomUUID(),
expiresAt: Date.now() + 30_000,
}
const server = Bun.serve({
port: 0,
async fetch(request) {
const pathname = new URL(request.url).pathname
if (pathname === "/api/experimental/persistent-pty/prepare-restart") {
await appendFile(registration + ".pty-requests", "prepare\n")
if (request.method !== "POST" || request.headers.get("authorization") !== "Basic " + btoa("opencode:private"))
return new Response(null, { status: 401 })
if (mode === "handoff-failed") return new Response(null, { status: 500 })
if (mode === "handoff" || mode === "handoff-null") {
await writeFile(registration + ".prepared", JSON.stringify(mode === "handoff-null" ? null : handoff))
return Response.json({ handoff: mode === "handoff-null" ? null : handoff })
}
return new Response(null, { status: 404 })
}
if (pathname === "/api/experimental/persistent-pty/shutdown") {
await appendFile(registration + ".pty-requests", "shutdown\n")
return new Response(null, { status: 204 })
}
if (pathname !== "/api/health") return new Response(null, { status: 404 })
requests += 1
if (mode === "starting") await writeFile(registration + ".health-request", "")
@@ -54,8 +84,7 @@ const server = Bun.serve({
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
return Response.json({ healthy: true, version, pid: process.pid }, { status: 503 })
if (mode === "failed-owner") return Response.json({ healthy: true, version, pid: process.pid }, { status: 500 })
if (mode === "starting" || mode === "graceful")
return Response.json({ healthy: true, version, pid: process.pid })
if (mode === "starting" || mode === "graceful") return Response.json({ healthy: true, version, pid: process.pid })
return Response.json({ healthy: true, version, pid: process.pid })
},
})
@@ -67,12 +96,16 @@ await writeFile(
version: mode === "legacy" ? undefined : version,
url: server.url.toString(),
pid: process.pid,
password: "private",
}),
{ mode: 0o600 },
)
await rename(registration + ".tmp", registration)
if (mode === "handoff-winner") await writeFile(registration + ".winner", "")
async function shutdown(signal?: NodeJS.Signals) {
if (mode === "handoff" || mode === "handoff-null")
await writeFile(registration + ".handoff-at-stop", await Bun.file(registration + ".pty-handoff").text())
if (signal !== undefined) await writeFile(registration + ".signal", signal)
server.stop(true)
process.exit()
+48 -1
View File
@@ -1,8 +1,9 @@
import { afterEach, expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Service, type EnsureReason } from "../src/promise/service"
import { ServiceHandoff } from "../src/service-handoff"
import { accelerate, waitForExit } from "./fixture/service-timing"
const fixture = join(import.meta.dir, "fixture/service.ts")
@@ -79,6 +80,38 @@ test("adds configured environment variables with native promises", async () => {
}
})
test.each(["handoff", "handoff-null", "old"])("replaces %s with the acknowledged terminal policy", async (mode) => {
const registration = await setup(mode)
await ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "environment"],
env: { OPENCODE_PTY_HANDOFF: "must-not-inherit" },
})
const replacement = await Bun.file(registration).json()
try {
const captured = JSON.parse((await Bun.file(registration + ".handoffs").text()).trim()).handoff
expect(captured === null ? null : JSON.parse(captured)).toEqual(
mode === "old" ? null : await Bun.file(registration + ".prepared").json(),
)
expect(await Bun.file(registration + ".pty-requests").text()).toBe(
mode === "old" ? "prepare\nshutdown\n" : "prepare\n",
)
expect(await Bun.file(registration + ".pty-handoff").exists()).toBe(false)
} finally {
process.kill(replacement.pid, "SIGTERM")
await waitForExit(replacement.pid)
}
})
test("does not terminate a healthy server when handoff preparation fails", async () => {
const registration = await setup("handoff-failed")
await expect(ensure({ file: registration, version: "test", command: [] })).rejects.toThrow(
"Failed to prepare persistent terminals for service replacement: HTTP 500",
)
expect(await Bun.file(registration + ".signal").exists()).toBe(false)
})
test("waits for a live contender when another native contender fails", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
@@ -148,11 +181,25 @@ test("evicts an unresponsive registered service before starting its replacement"
test("signals the registered service process", async () => {
const registration = await setup("graceful")
const source = await Bun.file(registration).json()
const handoff = { directory: "unused", instanceID: "daemon", ticket: "ticket", expiresAt: Date.now() + 30_000 }
await writeFile(registration + ".pty-handoff", JSON.stringify({ source, handoff, expiresAt: 0 }))
expect((await ServiceHandoff.environment(registration)).OPENCODE_PTY_HANDOFF).toBeUndefined()
await writeFile(
registration + ".pty-handoff",
JSON.stringify({
source: { ...source, id: "another-server" },
handoff,
expiresAt: handoff.expiresAt,
}),
)
expect((await ServiceHandoff.environment(registration)).OPENCODE_PTY_HANDOFF).toBeUndefined()
await Service.stop({ file: registration })
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
expect(await Bun.file(registration).exists()).toBe(false)
expect(await Bun.file(registration + ".pty-handoff").exists()).toBe(false)
})
async function setup(mode: string) {
+38 -1
View File
@@ -1,7 +1,7 @@
import { NodeFileSystem } from "@effect/platform-node"
import { afterEach, expect, test } from "bun:test"
import { Effect } from "effect"
import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { mkdtemp, rm, stat, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Service, type EnsureReason } from "../src/effect/service"
@@ -115,6 +115,43 @@ test("replaces an incompatible registered service", async () => {
await waitForExit(replacement.pid)
})
test("shares a prepared handoff when another client's contender wins replacement", async () => {
const promise = await import("../src/promise/service")
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "handoff")
await waitForFile(registration)
const first = run(
ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "handoff-loser"],
}),
)
await waitForFile(registration + ".loser")
if (process.platform !== "win32") expect((await stat(registration + ".pty-handoff")).mode & 0o777).toBe(0o600)
const second = accelerate(promise.ensure)({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "handoff-winner"],
})
const endpoints = await Promise.all([first, second])
const replacement = await Bun.file(registration).json()
try {
expect(await existing.exited).toBe(0)
expect(endpoints[0]).toEqual(endpoints[1])
const handoff = await Bun.file(registration + ".prepared").json()
expect((await Bun.file(registration + ".handoff-at-stop").json()).handoff).toEqual(handoff)
const contenders = (await Bun.file(registration + ".handoffs").text()).trim().split("\n")
expect(contenders.length).toBeGreaterThanOrEqual(2)
contenders.forEach((line) => expect(JSON.parse(JSON.parse(line).handoff)).toEqual(handoff))
expect(await Bun.file(registration + ".pty-handoff").exists()).toBe(false)
} finally {
process.kill(replacement.pid, "SIGTERM")
await waitForExit(replacement.pid)
}
})
test("waits for a registered service to finish starting", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
+142 -53
View File
@@ -3,8 +3,9 @@ import { readFile } from "node:fs/promises"
import net from "node:net"
import path from "node:path"
import { Data, Duration, Effect, Schema, Semaphore } from "effect"
import type { Handoff } from "@opencode-ai/schema/persistent-pty"
const ProtocolVersion = 6
const ProtocolVersion = 7
const MaxFrameBytes = 8 * 1024 * 1024
const Lifecycle = Schema.Union([
@@ -48,6 +49,8 @@ export const WireResponse = Schema.Union([
Schema.Struct({ type: Schema.Literal("created"), terminal: WireTerminal }),
Schema.Struct({ type: Schema.Literal("terminals"), terminals: Schema.Array(WireTerminal) }),
Schema.Struct({ type: Schema.Literal("ok") }),
Schema.Struct({ type: Schema.Literal("owned") }),
Schema.Struct({ type: Schema.Literal("handoff"), ticket: Schema.String, expires_at: Schema.Number }),
Schema.Struct({
type: Schema.Literal("snapshot"),
terminal: WireTerminal,
@@ -131,6 +134,7 @@ export interface DaemonTransport {
readonly request: (value: object, start?: boolean) => Effect.Effect<WireResponse, DaemonError>
readonly requestIfRunning: (value: object) => Effect.Effect<WireResponse | undefined, DaemonError>
readonly shutdown: Effect.Effect<WireResponse | undefined, DaemonError>
readonly prepareRestart: Effect.Effect<Handoff | null, DaemonError>
readonly subscribe: (
id: number,
input: {
@@ -147,19 +151,51 @@ export interface DaemonTransport {
export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport")(function* (
directory: string,
binary: () => Promise<string> = () => Promise.resolve(process.env.OPENCODE_PTY_BIN || "opencode-pty"),
handoff?: Handoff,
) {
const startup = Semaphore.makeUnsafe(1)
let registration: Registration | undefined
let owner: Awaited<ReturnType<typeof openOwner>> | undefined
let closed = false
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
closed = true
owner?.socket.destroy()
}),
)
const claim = (current: Registration, ticket?: string) =>
Effect.tryPromise({
try: async (signal) => {
const connection = await openOwner(current, ticket, signal)
if (closed || signal.aborted) {
connection.socket.destroy()
throw new Error("PTY owner scope is closed")
}
owner = connection
registration = current
connection.socket.once("close", () => {
if (owner !== connection) return
owner = undefined
registration = undefined
})
},
catch: (cause) => failure("connect", cause),
}).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(5),
orElse: () => Effect.fail(new DaemonError({ kind: "connect", message: "PTY ownership claim timed out" })),
}),
)
const discover = Effect.fn("PersistentPty.daemon.discover")(function* () {
const value = yield* Effect.tryPromise({
try: () => readFile(path.join(directory, "service.json"), "utf8"),
catch: (cause) => failure("connect", cause),
})
const decoded = yield* Effect.try({
try: () => Schema.decodeUnknownSync(Registration)(JSON.parse(value)),
catch: (cause) => failure("protocol", cause),
})
const decoded = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))(value).pipe(
Effect.mapError((cause) => failure("protocol", cause)),
)
if (decoded.protocol !== ProtocolVersion)
return yield* Effect.fail(
new DaemonError({
@@ -181,9 +217,9 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
const start = Effect.fn("PersistentPty.daemon.start")(function* () {
const executable = yield* Effect.tryPromise({ try: binary, catch: (cause) => failure("spawn", cause) })
yield* Effect.tryPromise({
const child = yield* Effect.tryPromise({
try: () =>
new Promise<void>((resolve, reject) => {
new Promise<ReturnType<typeof spawn>>((resolve, reject) => {
const child = spawn(executable, ["daemon"], {
detached: true,
stdio: "ignore",
@@ -191,43 +227,54 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
})
child.once("spawn", () => {
child.unref()
resolve()
resolve(child)
})
child.once("error", reject)
}),
catch: (cause) => failure("spawn", cause),
})
const deadline = Date.now() + 5_000
let last: DaemonError | undefined
while (Date.now() < deadline) {
const found = yield* discover().pipe(
Effect.map((value) => ({ value })),
Effect.catch((error) => {
last = error
return Effect.succeed(undefined)
}),
return yield* Effect.gen(function* () {
const deadline = Date.now() + 5_000
let last: DaemonError | undefined
while (Date.now() < deadline) {
const found = yield* discover().pipe(
Effect.map((value) => ({ value })),
Effect.catch((error) => {
last = error
return Effect.succeed(undefined)
}),
)
if (found) {
yield* claim(found.value)
return found.value
}
yield* Effect.sleep(50)
}
return yield* Effect.fail(
last ?? new DaemonError({ kind: "connect", message: "opencode-pty did not become ready" }),
)
if (found) return found.value
yield* Effect.sleep(50)
}
return yield* Effect.fail(
last ?? new DaemonError({ kind: "connect", message: "opencode-pty did not become ready" }),
}).pipe(
Effect.onError(() =>
Effect.sync(() => {
child.kill("SIGTERM")
}),
),
)
})
const connect = Effect.fn("PersistentPty.daemon.connect")(function* (shouldStart: boolean) {
if (closed) return yield* Effect.fail(new DaemonError({ kind: "connect", message: "PTY owner scope is closed" }))
if (registration) return registration
return yield* startup.withPermit(
Effect.gen(function* () {
if (registration) return registration
const found = yield* discover().pipe(
Effect.catch((error) => {
if (!shouldStart) return Effect.fail(error)
if (error.kind === "connect") return start()
if (error.kind !== "protocol" || error.pid === undefined) return Effect.fail(error)
return terminate(error.pid).pipe(Effect.andThen(start()))
if (shouldStart && error.kind === "connect") return start()
return Effect.fail(error)
}),
)
if (!owner) yield* claim(found)
registration = found
return found
}),
@@ -277,6 +324,27 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
return yield* Effect.fail(new DaemonError({ kind: "connect", message: "opencode-pty did not stop" }))
})
const prepareRestart = startup.withPermit(
Effect.gen(function* () {
const current = owner
const registered = registration
if (!current || !registered) return null
const response = yield* Effect.tryPromise({
try: (signal) => current.exchange({ op: "prepare_handoff" }, signal),
catch: (cause) => failure("response", cause),
}).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(5),
orElse: () =>
Effect.fail(new DaemonError({ kind: "response", message: "PTY handoff preparation timed out" })),
}),
)
if (response.type !== "handoff")
return yield* Effect.fail(new DaemonError({ kind: "protocol", message: "Expected PTY handoff ticket" }))
return { directory, instanceID: registered.instance_id, ticket: response.ticket, expiresAt: response.expires_at }
}),
)
const subscribe = Effect.fn("PersistentPty.daemon.subscribe")(function* (
id: number,
input: Parameters<DaemonTransport["subscribe"]>[1],
@@ -296,9 +364,56 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
return yield* attempt.pipe(Effect.catch((error) => (error.kind === "registration" ? attempt : Effect.fail(error))))
})
return { request, requestIfRunning, shutdown, subscribe } satisfies DaemonTransport
// A replacement must own its inherited daemon before the server becomes ready.
if (handoff) {
if (handoff.expiresAt <= Date.now())
return yield* Effect.fail(new DaemonError({ kind: "registration", message: "PTY restart handoff expired" }))
const current = yield* discover()
if (current.instance_id !== handoff.instanceID)
return yield* Effect.fail(new DaemonError({ kind: "registration", message: "PTY restart daemon changed" }))
yield* claim(current, handoff.ticket)
}
return { request, requestIfRunning, shutdown, prepareRestart, subscribe } satisfies DaemonTransport
})
async function openOwner(registration: Registration, ticket: string | undefined, signal: AbortSignal) {
const socket = net.createConnection({ path: registration.socket })
const frames = decoder(socket)
const abort = () => socket.destroy()
signal.addEventListener("abort", abort, { once: true })
const exchange = async (request: object, signal: AbortSignal) => {
if (signal.aborted) throw new Error("PTY ownership request interrupted")
signal.addEventListener("abort", abort, { once: true })
try {
socket.write(encode({ token: registration.token, request }))
const frame = await frames.next()
if (frame.done) throw new Error("PTY daemon closed its ownership connection")
const response = decode(frame.value)
if (response.type === "error") throw new Error(response.message)
return response
} finally {
signal.removeEventListener("abort", abort)
}
}
try {
await new Promise<void>((resolve, reject) => {
socket.once("connect", resolve)
socket.once("error", reject)
socket.once("close", () => reject(new Error("PTY ownership connection closed")))
})
const response = await exchange({ op: "own", instance_id: registration.instance_id, ticket }, signal)
if (response.type !== "owned") throw new Error("PTY daemon does not support server ownership")
socket.unref()
return { socket, exchange }
} catch (error) {
socket.destroy()
throw error
} finally {
signal.removeEventListener("abort", abort)
}
}
const oneShot = Effect.fn("PersistentPty.daemon.oneShot")(function* (registration: Registration, request: object) {
const payload = yield* Effect.try({
try: () => encode({ token: registration.token, request }),
@@ -507,29 +622,3 @@ function decode(payload: Uint8Array) {
function failure(kind: DaemonError["kind"], cause: unknown) {
return new DaemonError({ kind, message: cause instanceof Error ? cause.message : String(cause) })
}
const terminate = Effect.fn("PersistentPty.daemon.terminate-incompatible")(function* (pid: number) {
yield* Effect.logWarning("replacing incompatible opencode-pty daemon", { pid })
yield* Effect.try({ try: () => process.kill(pid, "SIGTERM"), catch: (cause) => failure("spawn", cause) }).pipe(
Effect.catch((error) => (isMissingProcess(error) ? Effect.void : Effect.fail(error))),
)
const deadline = Date.now() + 2_000
while (Date.now() < deadline && processRunning(pid)) yield* Effect.sleep(25)
if (!processRunning(pid)) return
yield* Effect.try({ try: () => process.kill(pid, "SIGKILL"), catch: (cause) => failure("spawn", cause) }).pipe(
Effect.catch((error) => (isMissingProcess(error) ? Effect.void : Effect.fail(error))),
)
})
function processRunning(pid: number) {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
function isMissingProcess(error: DaemonError) {
return error.message.includes("ESRCH") || error.message.includes("no such process")
}
+238 -222
View File
@@ -1,16 +1,16 @@
export * as PersistentPty from "./index.js"
import { createHash } from "node:crypto"
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, Removed } from "@opencode-ai/schema/persistent-pty"
import { Added, Handoff, Removed } from "@opencode-ai/schema/persistent-pty"
import { Session } from "@opencode-ai/schema/session"
import { Bus } from "../bus.js"
import { Database } from "../database/database.js"
import { Pty } from "@opencode-ai/schema/pty"
import { Global } from "@opencode-ai/util/global"
import { Location } from "../location.js"
import { ShellSelect } from "../shell/select.js"
import {
makeDaemonTransport,
type DaemonTransport,
@@ -22,6 +22,10 @@ import {
import { resolveBinary } from "#persistent-pty-binary"
export type { Role, StreamEvent } from "./daemon.js"
export { Handoff } from "@opencode-ai/schema/persistent-pty"
export const Options = Schema.Struct({ handoff: Schema.optional(Handoff) })
export type Options = typeof Options.Type
export type Info = Pty.Info & {
readonly sessionID: Session.ID
@@ -66,15 +70,15 @@ export interface Interface {
readonly create: (
sessionID: Session.ID,
input: {
readonly command: string
readonly command?: string
readonly args: readonly string[]
readonly cwd: string
readonly cwd?: string
readonly title: string
readonly env: Readonly<Record<string, string>>
readonly cols?: number
readonly rows?: number
},
) => Effect.Effect<Info, UnavailableError>
) => Effect.Effect<Info, UnavailableError, Location.Service | ShellSelect.Service>
readonly write: (
id: Pty.ID,
data: string,
@@ -102,6 +106,7 @@ export interface Interface {
readonly snapshot: (id: Pty.ID) => Effect.Effect<Snapshot, NotFoundError | UnavailableError>
readonly remove: (id: Pty.ID) => Effect.Effect<void, NotFoundError | UnavailableError>
readonly shutdown: () => Effect.Effect<void, UnavailableError>
readonly prepareRestart: () => Effect.Effect<Handoff | null, UnavailableError>
readonly attach: (
id: Pty.ID,
input: {
@@ -117,218 +122,240 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/PersistentPty") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const database = yield* Database.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(
runtimeDirectory(databasePath(database.db)),
() =>
(binary ??= resolveBinary(global.bin).catch((error) => {
binary = undefined
throw error
})),
)
const removing = new Set<Pty.ID>()
export const configured = (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>()
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 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 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: {
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
},
) {
const response = yield* request(
daemon,
{
op: "create",
program: input.command,
args: input.args,
cwd: input.cwd,
title: input.title,
group_id: sessionID,
env: input.env,
cols: input.cols ?? 80,
rows: input.rows ?? 24,
const create = Effect.fn("PersistentPty.create")(function* (
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
},
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,
) {
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)
return undefined
})
const control = Effect.fn("PersistentPty.control")(function* (
id: Pty.ID,
attachmentID: string,
cols: number,
rows: number,
) {
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)
return undefined
})
const input = Effect.fn("PersistentPty.input")(function* (
id: Pty.ID,
attachmentID: string,
cols: number,
rows: number,
data: Uint8Array,
) {
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)
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 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)
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))
if (!response) return
if (response.type !== "ok") return yield* unexpected(response)
})
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: {
readonly cursor: number
readonly attachmentID: string
readonly role: Role
readonly takeover?: boolean
readonly onEvent: (event: StreamEvent) => void
readonly onEnd: () => void
},
) {
yield* get(id)
const attachment = yield* daemon
.subscribe(fromID(id), {
...input,
onEvent: (event) => {
if (event.type === "exited") removeVisibleExit(id)
input.onEvent(event)
) {
const location = yield* Location.Service
const shell = yield* ShellSelect.Service
const command = input.command ?? (yield* shell.resolve({ priority: "config" }))
const response = yield* request(
daemon,
{
op: "create",
program: command,
args: input.args,
cwd: input.cwd ?? location.directory,
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"),
})
.pipe(Effect.mapError(unavailable))
return {
info: toInfo(attachment.terminal),
role: attachment.role,
generation: attachment.generation,
replay: attachment.replay,
activate: attachment.activate,
detach: attachment.detach,
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,
) {
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)
return undefined
})
const control = Effect.fn("PersistentPty.control")(function* (
id: Pty.ID,
attachmentID: string,
cols: number,
rows: number,
) {
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)
return undefined
})
const input = Effect.fn("PersistentPty.input")(function* (
id: Pty.ID,
attachmentID: string,
cols: number,
rows: number,
data: Uint8Array,
) {
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)
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 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)
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))
if (!response) return
if (response.type !== "ok") return yield* unexpected(response)
})
const prepareRestart = Effect.fn("PersistentPty.prepareRestart")(() =>
daemon.prepareRestart.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))),
),
)
}
})
return Service.of({ list, get, create, write, resize, control, input, snapshot, remove, shutdown, attach })
}),
)
const attach = Effect.fn("PersistentPty.attach")(function* (
id: Pty.ID,
input: {
readonly cursor: number
readonly attachmentID: string
readonly role: Role
readonly takeover?: boolean
readonly onEvent: (event: StreamEvent) => void
readonly onEnd: () => void
},
) {
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))
return {
info: toInfo(attachment.terminal),
role: attachment.role,
generation: attachment.generation,
replay: attachment.replay,
activate: attachment.activate,
detach: attachment.detach,
}
})
export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node, Database.node, Global.node] })
return Service.of({
list,
get,
create,
write,
resize,
control,
input,
snapshot,
remove,
shutdown,
prepareRestart,
attach,
})
}),
)
export const layer = configured()
export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node, Global.node] })
const request = (daemon: DaemonTransport, value: object, start = false) =>
daemon.request(value, start).pipe(Effect.mapError(unavailable))
@@ -342,17 +369,7 @@ const unexpected = (response: WireResponse) =>
const unavailable = (error: unknown) =>
new UnavailableError({ message: error instanceof Error ? error.message : String(error) })
function databasePath(db: Database.Interface["db"]) {
const client: unknown = db.$client
if ((typeof client !== "object" && typeof client !== "function") || client === null || !("config" in client))
return undefined
const config = client.config
if (typeof config !== "object" || config === null || !("filename" in config)) return undefined
if (typeof config.filename !== "string" || config.filename === ":memory:") return undefined
return path.resolve(config.filename)
}
const runtimeDirectory = (databasePath?: string) => {
const runtimeDirectory = () => {
const root =
process.env.OPENCODE_PTY_RUNTIME_DIR ??
(process.env.XDG_RUNTIME_DIR
@@ -361,8 +378,7 @@ const runtimeDirectory = (databasePath?: string) => {
os.tmpdir(),
`opencode-pty-${typeof process.getuid === "function" ? process.getuid() : process.env.USER || "unknown"}`,
))
const identity = databasePath ?? `memory:${crypto.randomUUID()}`
return path.join(root, createHash("sha256").update(identity).digest("hex").slice(0, 16))
return path.join(root, crypto.randomUUID())
}
function toInfo(value: WireTerminal): Info {
+218 -149
View File
@@ -1,4 +1,4 @@
import { expect, test } from "bun:test"
import { expect } from "bun:test"
import { spawn } from "node:child_process"
import { mkdtemp, rm, writeFile } from "node:fs/promises"
import net from "node:net"
@@ -6,198 +6,267 @@ import os from "node:os"
import path from "node:path"
import { Effect } from "effect"
import { makeDaemonTransport } from "../src/persistent-pty/daemon"
import { it } from "./lib/effect"
const pong = { type: "pong", instance_id: "test", pid: process.pid, protocol: 6 }
const pong = { type: "pong", instance_id: "test", pid: process.pid, protocol: 7 }
test("rediscovers a same-protocol daemon after its registration rotates", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-registration-"))
const socketPath = path.join(directory, "daemon.sock")
let token = "old-token"
let instance = "old-instance"
let creates = 0
const server = await listen(socketPath, (_socket, request, receivedToken) => {
if (receivedToken !== token) return { type: "error", message: "authentication failed" }
if (request.op === "ping") return { ...pong, instance_id: instance }
if (request.op === "create") creates++
return request.op === "list" ? { type: "terminals", terminals: [] } : { type: "ok" }
})
try {
await writeRegistration(directory, socketPath, instance, token)
const daemon = await Effect.runPromise(makeDaemonTransport(directory))
await Effect.runPromise(daemon.request({ op: "list" }))
it.live("rediscovers a same-protocol daemon after its registration rotates", () =>
Effect.gen(function* () {
const directory = yield* temporaryDirectory()
const socketPath = path.join(directory, "daemon.sock")
let token = "old-token"
let instance = "old-instance"
let creates = 0
yield* listen(socketPath, (_socket, request, receivedToken) => {
if (receivedToken !== token) return { type: "error", message: "authentication failed" }
if (request.op === "ping") return { ...pong, instance_id: instance }
if (request.op === "create") creates++
return request.op === "list" ? { type: "terminals", terminals: [] } : { type: "ok" }
})
yield* Effect.promise(() => writeRegistration(directory, socketPath, instance, token))
const daemon = yield* makeDaemonTransport(directory)
yield* daemon.request({ op: "list" })
token = "new-token"
instance = "new-instance"
await writeRegistration(directory, socketPath, instance, token)
yield* Effect.promise(() => writeRegistration(directory, socketPath, instance, token))
const running = await Effect.runPromise(daemon.requestIfRunning({ op: "list" }))
const running = yield* daemon.requestIfRunning({ op: "list" })
expect(running).toEqual({ type: "terminals", terminals: [] })
token = "newest-token"
instance = "newest-instance"
await writeRegistration(directory, socketPath, instance, token)
await Effect.runPromise(daemon.request({ op: "create" }, true))
yield* Effect.promise(() => writeRegistration(directory, socketPath, instance, token))
yield* daemon.request({ op: "create" }, true)
expect(creates).toBe(1)
} finally {
await close(server)
await rm(directory, { recursive: true, force: true })
}
})
}),
)
test("rediscovers a rotated registration when acquiring a subscription", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-subscription-"))
const socketPath = path.join(directory, "daemon.sock")
let token = "old-token"
let instance = "old-instance"
let subscriptions = 0
const server = await listen(socketPath, (_socket, request, receivedToken) => {
if (receivedToken !== token) return { type: "error", message: "authentication failed" }
if (request.op === "ping") return { ...pong, instance_id: instance }
if (request.op !== "subscribe") return { type: "terminals", terminals: [] }
subscriptions++
return {
type: "attached",
terminal: terminal(1),
role: "observer",
generation: 1,
requested_offset: 0,
available_offset: 0,
end_offset: 0,
truncated: false,
replay_base64: "",
}
})
try {
await writeRegistration(directory, socketPath, instance, token)
const daemon = await Effect.runPromise(makeDaemonTransport(directory))
await Effect.runPromise(daemon.request({ op: "list" }))
it.live("rediscovers a rotated registration when acquiring a subscription", () =>
Effect.gen(function* () {
const directory = yield* temporaryDirectory()
const socketPath = path.join(directory, "daemon.sock")
let token = "old-token"
let instance = "old-instance"
let subscriptions = 0
yield* listen(socketPath, (_socket, request, receivedToken) => {
if (receivedToken !== token) return { type: "error", message: "authentication failed" }
if (request.op === "ping") return { ...pong, instance_id: instance }
if (request.op !== "subscribe") return { type: "terminals", terminals: [] }
subscriptions++
return {
type: "attached",
terminal: terminal(1),
role: "observer",
generation: 1,
requested_offset: 0,
available_offset: 0,
end_offset: 0,
truncated: false,
replay_base64: "",
}
})
yield* Effect.promise(() => writeRegistration(directory, socketPath, instance, token))
const daemon = yield* makeDaemonTransport(directory)
yield* daemon.request({ op: "list" })
token = "new-token"
instance = "new-instance"
await writeRegistration(directory, socketPath, instance, token)
yield* Effect.promise(() => writeRegistration(directory, socketPath, instance, token))
const attachment = await Effect.runPromise(
daemon.subscribe(1, {
cursor: 0,
attachmentID: "attachment",
role: "observer",
onEvent: () => {},
onEnd: () => {},
}),
)
const attachment = yield* daemon.subscribe(1, {
cursor: 0,
attachmentID: "attachment",
role: "observer",
onEvent: () => {},
onEnd: () => {},
})
expect(attachment.terminal.id).toBe(1)
expect(subscriptions).toBe(1)
attachment.detach()
} finally {
await close(server)
await rm(directory, { recursive: true, force: true })
}
})
}),
)
test("retries a start-required request when connection fails before dispatch", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-retry-"))
const firstSocket = path.join(directory, "first.sock")
const secondSocket = path.join(directory, "second.sock")
const first = await listen(firstSocket, (_socket, request) => {
if (request.op === "ping") return pong
return { type: "terminals", terminals: [] }
})
try {
await writeRegistration(directory, firstSocket)
const daemon = await Effect.runPromise(makeDaemonTransport(directory))
await Effect.runPromise(daemon.request({ op: "list" }))
await close(first)
it.live("retries a start-required request when connection fails before dispatch", () =>
Effect.gen(function* () {
const directory = yield* temporaryDirectory()
const firstSocket = path.join(directory, "first.sock")
const secondSocket = path.join(directory, "second.sock")
const first = yield* listen(firstSocket, (_socket, request) => {
if (request.op === "ping") return pong
return { type: "terminals", terminals: [] }
})
yield* Effect.promise(() => writeRegistration(directory, firstSocket))
const daemon = yield* makeDaemonTransport(directory)
yield* daemon.request({ op: "list" })
yield* Effect.promise(first.close)
let creates = 0
const second = await listen(secondSocket, (_socket, request) => {
yield* listen(secondSocket, (_socket, request) => {
if (request.op === "ping") return pong
creates++
return { type: "ok" }
})
try {
await writeRegistration(directory, secondSocket)
await Effect.runPromise(daemon.request({ op: "create" }, true))
expect(creates).toBe(1)
} finally {
await close(second)
}
} finally {
await close(first)
await rm(directory, { recursive: true, force: true })
}
})
yield* Effect.promise(() => writeRegistration(directory, secondSocket))
yield* daemon.request({ op: "create" }, true)
expect(creates).toBe(1)
}),
)
test("does not replay a dispatched mutating request when its response is lost", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-response-"))
const socketPath = path.join(directory, "daemon.sock")
let creates = 0
const server = await listen(socketPath, (socket, request) => {
if (request.op === "ping") return pong
creates++
socket.destroy()
return undefined
})
try {
await writeRegistration(directory, socketPath)
const daemon = await Effect.runPromise(makeDaemonTransport(directory))
const error = await Effect.runPromise(Effect.flip(daemon.request({ op: "create" }, true)))
it.live("does not replay a dispatched mutating request when its response is lost", () =>
Effect.gen(function* () {
const directory = yield* temporaryDirectory()
const socketPath = path.join(directory, "daemon.sock")
let creates = 0
yield* listen(socketPath, (socket, request) => {
if (request.op === "ping") return pong
creates++
socket.destroy()
return undefined
})
yield* Effect.promise(() => writeRegistration(directory, socketPath))
const daemon = yield* makeDaemonTransport(directory)
const error = yield* Effect.flip(daemon.request({ op: "create" }, true))
expect(error.kind).toBe("response")
expect(creates).toBe(1)
} finally {
await close(server)
await rm(directory, { recursive: true, force: true })
}
})
}),
)
test("reports protocol mismatches until a start-required request replaces the daemon", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-mismatch-"))
const existing = spawn("sleep", ["30"])
const exited = new Promise<void>((resolve) => existing.once("exit", () => resolve()))
try {
it.live("rejects incompatible daemons without replacing or killing them", () =>
Effect.gen(function* () {
const directory = yield* temporaryDirectory()
const existing = yield* Effect.acquireRelease(
Effect.sync(() => spawn("sleep", ["30"])),
(child) =>
Effect.promise(async () => {
if (child.exitCode !== null || child.signalCode !== null) return
const exited = new Promise<void>((resolve) => child.once("exit", () => resolve()))
child.kill("SIGKILL")
await exited
}),
)
if (existing.pid === undefined) throw new Error("Expected fixture process PID")
await writeFile(
path.join(directory, "service.json"),
JSON.stringify({ instance_id: "old", pid: existing.pid, protocol: 5, socket: "/unused", token: "old" }),
)
const daemon = await Effect.runPromise(
makeDaemonTransport(directory, () => Promise.resolve("/missing/opencode-pty")),
yield* Effect.promise(() =>
writeFile(
path.join(directory, "service.json"),
JSON.stringify({ instance_id: "old", pid: existing.pid, protocol: 6, socket: "/unused", token: "old" }),
),
)
const daemon = yield* makeDaemonTransport(directory, () => Promise.resolve("/missing/opencode-pty"))
const optional = await Effect.runPromise(Effect.flip(daemon.requestIfRunning({ op: "list" })))
const optional = yield* Effect.flip(daemon.requestIfRunning({ op: "list" }))
expect(optional).toMatchObject({
kind: "protocol",
message: "opencode-pty protocol mismatch: daemon=5, client=6",
message: "opencode-pty protocol mismatch: daemon=6, client=7",
pid: existing.pid,
})
expect(existing.exitCode).toBeNull()
const starting = await Effect.runPromise(Effect.flip(daemon.request({ op: "create" }, true)))
await exited
expect(starting).toMatchObject({ kind: "spawn" })
expect(existing.signalCode).toBe(process.platform === "win32" ? null : "SIGTERM")
} finally {
existing.kill("SIGKILL")
await rm(directory, { recursive: true, force: true })
}
})
const starting = yield* Effect.flip(daemon.request({ op: "create" }, true))
expect(starting).toEqual(optional)
expect(existing.exitCode).toBeNull()
expect(existing.signalCode).toBeNull()
expect(process.kill(existing.pid, 0)).toBeTrue()
}),
)
it.live("preparing a restart does not start an unused daemon", () =>
Effect.gen(function* () {
const directory = yield* temporaryDirectory()
const daemon = yield* makeDaemonTransport(directory, () => Promise.reject(new Error("must not spawn")))
expect(yield* daemon.prepareRestart).toBeNull()
expect(yield* daemon.requestIfRunning({ op: "list" })).toBeUndefined()
expect(yield* daemon.prepareRestart).toBeNull()
}),
)
it.live("prepares handoff on the owner connection and releases ownership with its scope", () =>
Effect.gen(function* () {
const directory = yield* temporaryDirectory()
const socketPath = path.join(directory, "daemon.sock")
const expiresAt = Date.now() + 30_000
let owner: net.Socket | undefined
let ended = false
yield* listen(
socketPath,
(socket, request) => {
if (request.op === "ping") return pong
if (request.op === "own") {
owner = socket
socket.once("end", () => {
ended = true
})
return { type: "owned" }
}
if (request.op === "prepare_handoff") {
expect(owner).toBe(socket)
return { type: "handoff", ticket: "restart-ticket", expires_at: expiresAt }
}
return { type: "terminals", terminals: [] }
},
false,
)
yield* Effect.promise(() => writeRegistration(directory, socketPath))
yield* Effect.gen(function* () {
const daemon = yield* makeDaemonTransport(directory)
yield* daemon.request({ op: "list" })
expect(yield* daemon.prepareRestart).toEqual({
directory,
instanceID: "test",
ticket: "restart-ticket",
expiresAt,
})
expect(ended).toBeFalse()
}).pipe(Effect.scoped)
yield* Effect.promise(async () => {
for (let attempt = 0; attempt < 100 && !ended; attempt++) await Bun.sleep(10)
})
expect(ended).toBeTrue()
}),
)
function temporaryDirectory() {
return Effect.acquireRelease(
Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "opencode-pty-test-"))),
(directory) => Effect.promise(() => rm(directory, { recursive: true, force: true })),
)
}
function listen(
socketPath: string,
handle: (socket: net.Socket, request: Record<string, unknown>, token: string) => object | undefined,
autoOwn = true,
) {
const server = net.createServer((socket) => {
void readRequest(socket)
.then((envelope) => {
const response = handle(socket, envelope.request, envelope.token)
if (response) socket.write(frame(response))
return Effect.acquireRelease(
Effect.promise(async () => {
const sockets = new Set<net.Socket>()
const server = net.createServer((socket) => {
sockets.add(socket)
socket.once("close", () => sockets.delete(socket))
void (async () => {
while (!socket.destroyed) {
const envelope = await readRequest(socket)
const response =
autoOwn && envelope.request.op === "own"
? { type: "owned" }
: handle(socket, envelope.request, envelope.token)
if (response) socket.write(frame(response))
}
})().catch(() => socket.destroy())
})
.catch(() => socket.destroy())
})
return new Promise<net.Server>((resolve, reject) => {
server.once("error", reject)
server.listen(socketPath, () => resolve(server))
})
await new Promise<void>((resolve, reject) => {
server.once("error", reject)
server.listen(socketPath, resolve)
})
return {
close: () => {
sockets.forEach((socket) => socket.destroy())
return close(server)
},
}
}),
(server) => Effect.promise(server.close),
)
}
function readRequest(socket: net.Socket) {
@@ -246,7 +315,7 @@ function frame(value: object) {
function writeRegistration(directory: string, socket: string, instance = "test", token = "test") {
return writeFile(
path.join(directory, "service.json"),
JSON.stringify({ instance_id: instance, pid: process.pid, protocol: 6, socket, token }),
JSON.stringify({ instance_id: instance, pid: process.pid, protocol: 7, socket, token }),
)
}
+3 -3
View File
@@ -19,7 +19,7 @@ import { HealthGroup } from "./groups/health.js"
import { ServerGroup } from "./groups/server.js"
import { DebugGroup } from "./groups/debug.js"
import { PtyGroup } from "./groups/pty.js"
import { PersistentPtyGroup } from "./groups/persistent-pty.js"
import { makePersistentPtyGroup } from "./groups/persistent-pty.js"
import { ShellGroup } from "./groups/shell.js"
import { ReferenceGroup } from "./groups/reference.js"
import { Authorization } from "./middleware/authorization.js"
@@ -89,7 +89,7 @@ type ApiGroups<
| typeof WorktreeGroup
| typeof WorkspaceGroup
| typeof GenerateGroup
| typeof PersistentPtyGroup
| ReturnType<typeof makePersistentPtyGroup<SessionLocationId, SessionLocationService>>
| LocationGroups<LocationId>
| FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
| SessionGroups<SessionLocationId, SessionLocationService>
@@ -170,7 +170,7 @@ const makeApiFromGroup = <
.add(SkillGroup.middleware(locationMiddleware))
.add(eventGroup)
.add(PtyGroup.middleware(locationMiddleware))
.add(PersistentPtyGroup)
.add(makePersistentPtyGroup(sessionLocationMiddleware))
.add(ShellGroup.middleware(locationMiddleware))
.add(ReferenceGroup.middleware(locationMiddleware))
.add(WorktreeGroup)
+100 -85
View File
@@ -2,9 +2,15 @@ import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { Pty } from "@opencode-ai/schema/pty"
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
import { Session } from "@opencode-ai/schema/session"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { ForbiddenError, InvalidRequestError, PtyNotFoundError, ServiceUnavailableError } from "../errors.js"
import { Context, Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import {
ForbiddenError,
InvalidRequestError,
PtyNotFoundError,
ServiceUnavailableError,
SessionNotFoundError,
} 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 }
@@ -18,86 +24,95 @@ export function hasPersistentPtyConnectTicketURL(url: URL) {
const errors = [InvalidRequestError, ServiceUnavailableError] as const
const terminalErrors = [PtyNotFoundError, ServiceUnavailableError] as const
export const PersistentPtyGroup = HttpApiGroup.make("server.experimental")
.add(
HttpApiEndpoint.get("persistentPty.list", "/api/experimental/session/:sessionID/terminal", {
params: { sessionID: Session.ID },
success: Schema.Struct({ data: Schema.Array(PersistentPty.Info) }),
error: errors,
}),
)
.add(
HttpApiEndpoint.post("persistentPty.create", "/api/experimental/session/:sessionID/terminal", {
params: { sessionID: Session.ID },
payload: PersistentPty.CreateInput,
success: Schema.Struct({ data: PersistentPty.Info }),
error: errors,
}),
)
.add(
HttpApiEndpoint.post("persistentPty.shutdown", "/api/experimental/persistent-pty/shutdown", {
success: HttpApiSchema.NoContent,
error: [ServiceUnavailableError],
}),
)
.add(
HttpApiEndpoint.get("persistentPty.get", "/api/experimental/persistent-pty/:ptyID", {
params: { ptyID: Pty.ID },
success: Schema.Struct({ data: PersistentPty.Info }),
error: terminalErrors,
}),
)
.add(
HttpApiEndpoint.put("persistentPty.update", "/api/experimental/persistent-pty/:ptyID", {
params: { ptyID: Pty.ID },
payload: PersistentPty.UpdateInput,
success: Schema.Struct({ data: PersistentPty.Info }),
error: terminalErrors,
}),
)
.add(
HttpApiEndpoint.get("persistentPty.snapshot", "/api/experimental/persistent-pty/:ptyID/snapshot", {
params: { ptyID: Pty.ID },
success: Schema.Struct({ data: PersistentPty.Snapshot }),
error: terminalErrors,
}),
)
.add(
HttpApiEndpoint.delete("persistentPty.remove", "/api/experimental/persistent-pty/:ptyID", {
params: { ptyID: Pty.ID },
success: HttpApiSchema.NoContent,
error: terminalErrors,
}),
)
.add(
HttpApiEndpoint.post("persistentPty.connectToken", "/api/experimental/persistent-pty/:ptyID/connect-token", {
params: { ptyID: Pty.ID },
headers: Schema.Struct({ [PTY_CONNECT_TOKEN_HEADER]: Schema.optional(Schema.String) }),
success: Schema.Struct({ data: PtyTicket.ConnectToken }),
error: [ForbiddenError, PtyNotFoundError, ServiceUnavailableError],
}),
)
.add(
HttpApiEndpoint.get("persistentPty.connect", "/api/experimental/persistent-pty/:ptyID/connect", {
params: { ptyID: Pty.ID },
success: Schema.Boolean,
error: [ForbiddenError, PtyNotFoundError, ServiceUnavailableError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.persistentPty.connect",
summary: "Connect to a persistent PTY",
description: "Stream persistent PTY output through the OpenCode server.",
transform: (operation) => ({
...operation,
"x-websocket": true,
parameters: [
...(operation.parameters ?? []),
...["cursor", "role", "attachment_id", "takeover", "input_protocol", PTY_CONNECT_TICKET_QUERY].map(
(name) => ({ in: "query", name, schema: { type: "string" } }),
),
],
}),
export const makePersistentPtyGroup = <I extends HttpApiMiddleware.AnyId, S>(
sessionLocationMiddleware: Context.Key<I, S>,
) =>
HttpApiGroup.make("server.experimental")
.add(
HttpApiEndpoint.get("persistentPty.list", "/api/experimental/session/:sessionID/terminal", {
params: { sessionID: Session.ID },
success: Schema.Struct({ data: Schema.Array(PersistentPty.Info) }),
error: errors,
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "persistentPty", description: "Prototype persistent PTY routes." }))
)
.add(
HttpApiEndpoint.post("persistentPty.create", "/api/experimental/session/:sessionID/terminal", {
params: { sessionID: Session.ID },
payload: PersistentPty.CreateInput,
success: Schema.Struct({ data: PersistentPty.Info }),
error: [...errors, SessionNotFoundError],
}).middleware(sessionLocationMiddleware),
)
.add(
HttpApiEndpoint.post("persistentPty.shutdown", "/api/experimental/persistent-pty/shutdown", {
success: HttpApiSchema.NoContent,
error: [ServiceUnavailableError],
}),
)
.add(
HttpApiEndpoint.post("persistentPty.prepareRestart", "/api/experimental/persistent-pty/prepare-restart", {
success: Schema.Struct({ handoff: Schema.NullOr(PersistentPty.Handoff) }),
error: [ServiceUnavailableError],
}),
)
.add(
HttpApiEndpoint.get("persistentPty.get", "/api/experimental/persistent-pty/:ptyID", {
params: { ptyID: Pty.ID },
success: Schema.Struct({ data: PersistentPty.Info }),
error: terminalErrors,
}),
)
.add(
HttpApiEndpoint.put("persistentPty.update", "/api/experimental/persistent-pty/:ptyID", {
params: { ptyID: Pty.ID },
payload: PersistentPty.UpdateInput,
success: Schema.Struct({ data: PersistentPty.Info }),
error: terminalErrors,
}),
)
.add(
HttpApiEndpoint.get("persistentPty.snapshot", "/api/experimental/persistent-pty/:ptyID/snapshot", {
params: { ptyID: Pty.ID },
success: Schema.Struct({ data: PersistentPty.Snapshot }),
error: terminalErrors,
}),
)
.add(
HttpApiEndpoint.delete("persistentPty.remove", "/api/experimental/persistent-pty/:ptyID", {
params: { ptyID: Pty.ID },
success: HttpApiSchema.NoContent,
error: terminalErrors,
}),
)
.add(
HttpApiEndpoint.post("persistentPty.connectToken", "/api/experimental/persistent-pty/:ptyID/connect-token", {
params: { ptyID: Pty.ID },
headers: Schema.Struct({ [PTY_CONNECT_TOKEN_HEADER]: Schema.optional(Schema.String) }),
success: Schema.Struct({ data: PtyTicket.ConnectToken }),
error: [ForbiddenError, PtyNotFoundError, ServiceUnavailableError],
}),
)
.add(
HttpApiEndpoint.get("persistentPty.connect", "/api/experimental/persistent-pty/:ptyID/connect", {
params: { ptyID: Pty.ID },
success: Schema.Boolean,
error: [ForbiddenError, PtyNotFoundError, ServiceUnavailableError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.persistentPty.connect",
summary: "Connect to a persistent PTY",
description: "Stream persistent PTY output through the OpenCode server.",
transform: (operation) => ({
...operation,
"x-websocket": true,
parameters: [
...(operation.parameters ?? []),
...["cursor", "role", "attachment_id", "takeover", "input_protocol", PTY_CONNECT_TICKET_QUERY].map(
(name) => ({ in: "query", name, schema: { type: "string" } }),
),
],
}),
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "persistentPty", description: "Prototype persistent PTY routes." }))
+10 -2
View File
@@ -15,10 +15,18 @@ export const Info = Schema.Struct({
}).annotate({ identifier: "PersistentPty.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Handoff = Schema.Struct({
directory: Schema.String,
instanceID: Schema.String,
ticket: Schema.String,
expiresAt: Schema.Number,
}).annotate({ identifier: "PersistentPty.Handoff" })
export interface Handoff extends Schema.Schema.Type<typeof Handoff> {}
export const CreateInput = Schema.Struct({
command: Schema.String,
command: optional(Schema.String),
args: Schema.Array(Schema.String),
cwd: Schema.String,
cwd: optional(Schema.String),
title: Schema.String,
env: Schema.Record(Schema.String, Schema.String),
size: optional(Schema.Struct({ cols: PositiveInt, rows: PositiveInt })),
@@ -1,5 +1,6 @@
import { PersistentPty } from "@opencode-ai/core/persistent-pty"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { ForbiddenError, PtyNotFoundError, ServiceUnavailableError } from "@opencode-ai/protocol/errors"
import {
PTY_CONNECT_TICKET_QUERY,
@@ -29,6 +30,8 @@ export const PersistentPtyHandler = HttpApiBuilder.group(Api, "server.experiment
.handle(
"persistentPty.create",
Effect.fn(function* (ctx) {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return {
data: yield* pty
.create(ctx.params.sessionID, {
@@ -51,6 +54,12 @@ export const PersistentPtyHandler = HttpApiBuilder.group(Api, "server.experiment
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"persistentPty.prepareRestart",
Effect.fn(function* () {
return { handoff: yield* pty.prepareRestart().pipe(mapUnavailable) }
}),
)
.handle(
"persistentPty.get",
Effect.fn(function* (ctx) {
+2
View File
@@ -1,5 +1,6 @@
import { Database } from "@opencode-ai/core/database/database"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { PersistentPty } from "@opencode-ai/core/persistent-pty"
import { Schema } from "effect"
export const ServerOptions = Schema.Struct({
@@ -15,6 +16,7 @@ export const ServerOptions = Schema.Struct({
password: Schema.optional(Schema.String),
simulation: Schema.optional(Schema.Boolean),
database: Schema.optional(Database.Options),
pty: Schema.optional(PersistentPty.Options),
events: Schema.optional(
Schema.Struct({
persist: Schema.optional(Schema.Boolean),
+1
View File
@@ -100,6 +100,7 @@ function makeRoutes<AuthError, AuthServices>(
const pluginRuntimeCell = PluginRuntime.makeCell()
const standard: LayerNode.Replacements = [
[Database.node, Database.configured(options.database)],
[PersistentPty.node, PersistentPty.configured(options.pty)],
[Bus.node, Bus.configured({ persist: options.events?.persist })],
[App.node, App.configured(options.app)],
[ModelsDev.node, ModelsDev.configured(options.models)],
+359 -168
View File
@@ -1,12 +1,12 @@
import { existsSync } from "node:fs"
import fs from "node:fs/promises"
import { createHash } from "node:crypto"
import os from "node:os"
import path from "node:path"
import { expect } from "bun:test"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { Session } from "@opencode-ai/schema/session"
import { Effect, Schema } from "effect"
import { Effect, Exit, Schema, Scope } from "effect"
import { HttpServer } from "effect/unstable/http"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
@@ -17,179 +17,370 @@ const smoke = existsSync(binary) ? it.live : it.live.skip
smoke(
"creates two persistent terminals for one session through the client API",
() =>
Effect.acquireUseRelease(
Effect.promise(async () => {
const environment = {
binary: process.env.OPENCODE_PTY_BIN,
runtime: process.env.OPENCODE_PTY_RUNTIME_DIR,
xdg: process.env.XDG_RUNTIME_DIR,
Effect.gen(function* () {
const fixture = yield* testDirectory("xdg")
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: fixture.database },
fs: { filewatcher: false },
})
const base = HttpServer.formatAddress(server.address)
const sessionID = Session.ID.make("ses_persistent_pty_test")
yield* request(base, "POST", "/api/session", { id: sessionID, location: { directory: fixture.root } })
const events = yield* Effect.promise(() => openEventStream(base))
expect(existsSync(fixture.directory)).toBeFalse()
expect(yield* request(base, "POST", "/api/experimental/persistent-pty/prepare-restart")).toEqual({
handoff: null,
})
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toEqual([])
expect(existsSync(fixture.directory)).toBeFalse()
const first = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
command: "/usr/bin/env",
args: ["/bin/sh", "-c", "stty -echo; printf 'terminal-one:%s\\n' \"$PWD\"; cat"],
cwd: process.cwd(),
title: "first",
env: {},
})).data,
)
expect(yield* Effect.promise(() => events.next("persistent-pty.added"))).toMatchObject({
data: { sessionID, terminal: { id: first.id } },
})
expect(first.size).toEqual({ cols: 80, rows: 24 })
expect(first).toMatchObject({
command: "/usr/bin/env",
args: ["/bin/sh", "-c", "stty -echo; printf 'terminal-one:%s\\n' \"$PWD\"; cat"],
cwd: process.cwd(),
})
expect(first.cwd).not.toBe(fixture.root)
const directories = yield* Effect.promise(() => fs.readdir(fixture.directory))
expect(directories).toHaveLength(1)
expect(directories[0]).toMatch(/^[0-9a-f-]{36}$/)
if (!directories[0]) throw new Error("Missing daemon directory")
expect(existsSync(path.join(fixture.directory, directories[0], "service.json"))).toBeTrue()
const second = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
command: "/bin/sh",
args: ["-c", "printf terminal-two; sleep 30"],
cwd: process.cwd(),
title: "second",
env: {},
})).data,
)
const terminals = Schema.decodeUnknownSync(Schema.Array(PersistentPty.Info))(
(yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data,
)
expect(terminals.map((terminal) => terminal.id).sort()).toEqual([first.id, second.id].sort())
expect(yield* waitForText(base, first.id, "terminal-one")).toContain(`terminal-one:${process.cwd()}`)
expect(yield* waitForText(base, second.id, "terminal-two")).toContain("terminal-two")
yield* Effect.promise(() => verifySharedControl(base, first.id))
const snapshot = yield* request(base, "GET", `/api/experimental/persistent-pty/${first.id}/snapshot`)
if (
!isRecord(snapshot.data) ||
typeof snapshot.data.checkpoint !== "string" ||
!isRecord(snapshot.data.info) ||
!isRecord(snapshot.data.info.output) ||
typeof snapshot.data.info.output.tail !== "number"
)
throw new Error("Persistent PTY snapshot response was invalid")
expect(Buffer.from(snapshot.data.checkpoint, "base64").byteLength).toBeGreaterThan(0)
expect(snapshot.data.info.output.tail).toBeGreaterThan(0)
const ticket = yield* request(
base,
"POST",
`/api/experimental/persistent-pty/${first.id}/connect-token`,
undefined,
{
"x-opencode-ticket": "1",
},
)
if (!isRecord(ticket.data) || typeof ticket.data.ticket !== "string") throw new Error("Invalid connect ticket")
const connectTicket = ticket.data.ticket
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${first.id}`)
yield* Effect.promise(async () => {
const url = new URL(`/api/experimental/persistent-pty/${first.id}/connect`, base)
url.searchParams.set("ticket", "invalid")
expect((await fetch(url)).status).toBe(403)
url.protocol = "ws:"
url.searchParams.set("ticket", connectTicket)
const socket = new WebSocket(url)
try {
const closed = await new Promise<CloseEvent>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Removed terminal socket did not close")), 5_000)
socket.addEventListener("close", (event) => {
clearTimeout(timeout)
resolve(event)
})
socket.addEventListener("error", () => {
clearTimeout(timeout)
reject(new Error("Valid ticket should upgrade before the missing terminal is reported"))
})
})
expect(closed.code).toBe(4404)
expect(closed.reason).toBe("terminal unavailable")
} finally {
socket.close()
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-pty-server-test-"))
const database = path.join(root, "opencode.db")
const runtime = path.join(root, "runtime")
process.env.OPENCODE_PTY_BIN = binary
delete process.env.OPENCODE_PTY_RUNTIME_DIR
process.env.XDG_RUNTIME_DIR = runtime
return {
database,
directory: path.join(
runtime,
"opencode-pty",
createHash("sha256").update(database).digest("hex").slice(0, 16),
),
environment,
root,
}
}),
(fixture) =>
Effect.gen(function* () {
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: fixture.database },
fs: { filewatcher: false },
})
const base = HttpServer.formatAddress(server.address)
const sessionID = Session.ID.make("ses_persistent_pty_test")
const events = yield* Effect.promise(() => openEventStream(base))
expect(existsSync(path.join(fixture.directory, "service.json"))).toBeFalse()
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toEqual([])
expect(existsSync(path.join(fixture.directory, "service.json"))).toBeFalse()
const first = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
command: "/bin/sh",
args: ["-c", "stty -echo; printf terminal-one; cat"],
cwd: process.cwd(),
title: "first",
env: {},
})).data,
)
expect(yield* Effect.promise(() => events.next("persistent-pty.added"))).toMatchObject({
data: { sessionID, terminal: { id: first.id } },
})
expect(first.size).toEqual({ cols: 80, rows: 24 })
expect(existsSync(path.join(fixture.directory, "service.json"))).toBeTrue()
const second = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
command: "/bin/sh",
args: ["-c", "printf terminal-two; sleep 30"],
cwd: process.cwd(),
title: "second",
env: {},
})).data,
)
})
expect(yield* Effect.promise(() => events.next("persistent-pty.removed"))).toMatchObject({
data: { sessionID, ptyID: first.id },
})
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${second.id}`)
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toEqual([])
const terminals = Schema.decodeUnknownSync(Schema.Array(PersistentPty.Info))(
(yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data,
)
expect(terminals.map((terminal) => terminal.id).sort()).toEqual([first.id, second.id].sort())
expect(yield* waitForText(base, first.id, "terminal-one")).toContain("terminal-one")
expect(yield* waitForText(base, second.id, "terminal-two")).toContain("terminal-two")
yield* Effect.promise(() => verifySharedControl(base, first.id))
const snapshot = yield* request(base, "GET", `/api/experimental/persistent-pty/${first.id}/snapshot`)
if (
!isRecord(snapshot.data) ||
typeof snapshot.data.checkpoint !== "string" ||
!isRecord(snapshot.data.info) ||
!isRecord(snapshot.data.info.output) ||
typeof snapshot.data.info.output.tail !== "number"
)
throw new Error("Persistent PTY snapshot response was invalid")
expect(Buffer.from(snapshot.data.checkpoint, "base64").byteLength).toBeGreaterThan(0)
expect(snapshot.data.info.output.tail).toBeGreaterThan(0)
yield* request(base, "POST", "/api/experimental/persistent-pty/shutdown")
const ticket = yield* request(
base,
"POST",
`/api/experimental/persistent-pty/${first.id}/connect-token`,
undefined,
{
"x-opencode-ticket": "1",
},
)
if (!isRecord(ticket.data) || typeof ticket.data.ticket !== "string")
throw new Error("Invalid connect ticket")
const connectTicket = ticket.data.ticket
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${first.id}`)
yield* Effect.promise(async () => {
const url = new URL(`/api/experimental/persistent-pty/${first.id}/connect`, base)
url.searchParams.set("ticket", "invalid")
expect((await fetch(url)).status).toBe(403)
url.protocol = "ws:"
url.searchParams.set("ticket", connectTicket)
const socket = new WebSocket(url)
try {
const closed = await new Promise<CloseEvent>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Removed terminal socket did not close")), 5_000)
socket.addEventListener("close", (event) => {
clearTimeout(timeout)
resolve(event)
})
socket.addEventListener("error", () => {
clearTimeout(timeout)
reject(new Error("Valid ticket should upgrade before the missing terminal is reported"))
})
})
expect(closed.code).toBe(4404)
expect(closed.reason).toBe("terminal unavailable")
} finally {
socket.close()
}
})
expect(yield* Effect.promise(() => events.next("persistent-pty.removed"))).toMatchObject({
data: { sessionID, ptyID: first.id },
})
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${second.id}`)
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toEqual([])
const unattended = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
command: "/bin/sh",
args: ["-c", "exit 7"],
cwd: process.cwd(),
title: "unattended",
env: {},
})).data,
)
yield* waitForStatus(base, unattended.id, "exited")
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toMatchObject([
{ id: unattended.id, status: "exited" },
])
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${unattended.id}`)
yield* request(base, "POST", "/api/experimental/persistent-pty/shutdown")
const unattended = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
command: "/bin/sh",
args: ["-c", "exit 7"],
cwd: process.cwd(),
title: "unattended",
env: {},
})).data,
)
yield* waitForStatus(base, unattended.id, "exited")
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toMatchObject([
{ id: unattended.id, status: "exited" },
])
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${unattended.id}`)
const visible = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
command: "/bin/sh",
args: ["-c", "read value"],
cwd: process.cwd(),
title: "visible",
env: {},
})).data,
)
yield* attachAndExit(base, visible.id)
yield* waitForTerminals(base, sessionID, [])
yield* Effect.promise(() => events.close())
}),
(fixture) =>
Effect.promise(async () => {
await Bun.spawn([binary, "stop"], {
env: { ...process.env, OPENCODE_PTY_RUNTIME_DIR: fixture.directory },
stdout: "ignore",
stderr: "ignore",
}).exited
await fs.rm(fixture.root, { recursive: true, force: true })
restore("OPENCODE_PTY_BIN", fixture.environment.binary)
restore("OPENCODE_PTY_RUNTIME_DIR", fixture.environment.runtime)
restore("XDG_RUNTIME_DIR", fixture.environment.xdg)
}),
),
const visible = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
command: "/bin/sh",
args: ["-c", "read value"],
cwd: process.cwd(),
title: "visible",
env: {},
})).data,
)
yield* attachAndExit(base, visible.id)
yield* waitForTerminals(base, sessionID, [])
yield* Effect.promise(() => events.close())
}),
20_000,
)
smoke(
"isolates servers sharing a database and preserves terminals only through explicit restart handoff",
() =>
Effect.gen(function* () {
const fixture = yield* testDirectory("override")
const scope = yield* Scope.Scope
const originalScope = yield* Scope.fork(scope)
const options = {
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: fixture.database },
fs: { filewatcher: false },
}
const original = yield* ServerProcess.start<never, never>(options).pipe(
Effect.provideService(Scope.Scope, originalScope),
)
const base = HttpServer.formatAddress(original.address)
const sessionID = Session.ID.make("ses_persistent_pty_restart")
yield* request(base, "POST", "/api/session", { id: sessionID, location: { directory: fixture.root } })
const first = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
command: "/bin/sh",
args: ["-c", "stty -echo; printf before-restart; exec cat"],
cwd: process.cwd(),
title: "survivor",
env: {},
})).data,
)
expect(yield* waitForText(base, first.id, "before-restart")).toContain("before-restart")
const independentScope = yield* Scope.fork(scope)
const independent = yield* ServerProcess.start<never, never>(options).pipe(
Effect.provideService(Scope.Scope, independentScope),
)
const otherBase = HttpServer.formatAddress(independent.address)
expect((yield* request(otherBase, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toEqual([])
expect(yield* request(otherBase, "POST", "/api/experimental/persistent-pty/prepare-restart")).toEqual({
handoff: null,
})
expect(yield* Effect.promise(() => fs.readdir(fixture.directory))).toHaveLength(1)
const second = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(otherBase, "POST", `/api/experimental/session/${sessionID}/terminal`, {
command: "/bin/sh",
args: ["-c", "printf isolated; exec cat"],
cwd: process.cwd(),
title: "independent",
env: {},
})).data,
)
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toMatchObject([
{ id: first.id, pid: first.pid },
])
expect((yield* request(otherBase, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toMatchObject([
{ id: second.id, pid: second.pid },
])
expect(second.pid).not.toBe(first.pid)
expect(yield* Effect.promise(() => fs.readdir(fixture.directory))).toHaveLength(2)
const handoff = Schema.decodeUnknownSync(PersistentPty.Handoff)(
(yield* request(base, "POST", "/api/experimental/persistent-pty/prepare-restart")).handoff,
)
expect(path.dirname(handoff.directory)).toBe(fixture.directory)
expect(handoff.expiresAt).toBeGreaterThan(Date.now())
const registration = Schema.decodeUnknownSync(Schema.Struct({ pid: Schema.Number, instance_id: Schema.String }))(
yield* Effect.promise(() => Bun.file(path.join(handoff.directory, "service.json")).json()),
)
expect(registration.instance_id).toBe(handoff.instanceID)
yield* Scope.close(originalScope, Exit.void)
expect(process.kill(registration.pid, 0)).toBeTrue()
expect(process.kill(first.pid, 0)).toBeTrue()
const replacementScope = yield* Scope.fork(scope)
const replacement = yield* ServerProcess.start<never, never>({ ...options, pty: { handoff } }).pipe(
Effect.provideService(Scope.Scope, replacementScope),
)
const replacementBase = HttpServer.formatAddress(replacement.address)
expect(
(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")
yield* Effect.promise(async () => {
const attachment = await openTerminalSocket(replacementBase, first.id, "replacement")
try {
attachment.socket.send(inputFrame(80, 24, "after-restart\n"))
await waitForSocketOutput([attachment], "after-restart")
} finally {
attachment.socket.close()
}
})
expect(yield* waitForText(replacementBase, first.id, "after-restart")).toContain("before-restart")
expect(
Schema.decodeUnknownSync(Schema.Struct({ pid: Schema.Number }))(
yield* Effect.promise(() => Bun.file(path.join(handoff.directory, "service.json")).json()),
).pid,
).toBe(registration.pid)
yield* Scope.close(replacementScope, Exit.void)
yield* waitForExit(registration.pid)
yield* waitForExit(first.pid)
expect(existsSync(path.join(handoff.directory, "service.json"))).toBeFalse()
expect(yield* waitForText(otherBase, second.id, "isolated")).toContain("isolated")
const remaining = yield* Effect.promise(async () => {
const directories = await fs.readdir(fixture.directory)
const directory = directories.find((name) => path.join(fixture.directory, name) !== handoff.directory)
if (!directory) throw new Error("Missing independent daemon directory")
return Schema.decodeUnknownSync(Schema.Struct({ pid: Schema.Number }))(
await Bun.file(path.join(fixture.directory, directory, "service.json")).json(),
)
})
yield* Scope.close(independentScope, Exit.void)
yield* waitForExit(remaining.pid)
yield* waitForExit(second.pid)
}),
30_000,
)
for (const shell of ["/bin/sh", "/missing/server/zsh"]) {
smoke(
`defaults persistent terminals to the session directory and server shell (${shell})`,
() =>
Effect.gen(function* () {
const fixture = yield* testDirectory("xdg", shell)
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: fixture.database },
fs: { filewatcher: false },
})
const base = HttpServer.formatAddress(server.address)
const sessionID = Session.ID.make("ses_persistent_pty_defaults")
const input = {
args: ["-c", "printf 'cwd:%s\\n' \"$PWD\"; cat"],
title: "default shell",
env: { SHELL: "/missing/client/zsh" },
}
const missing = yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, input).pipe(
Effect.flip,
)
expect(missing.message).toContain("(404)")
expect(existsSync(fixture.directory)).toBeFalse()
yield* request(base, "POST", "/api/session", { id: sessionID, location: { directory: fixture.root } })
const terminal = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, input)).data,
)
expect(terminal).toMatchObject({ cwd: fixture.root, args: input.args, status: "running" })
expect(terminal.cwd).not.toBe(process.cwd())
if (shell === "/bin/sh") expect(terminal.command).toBe("/bin/sh")
expect(existsSync(terminal.command)).toBeTrue()
expect(terminal.command).not.toBe("/missing/server/zsh")
expect(terminal.command).not.toBe(input.env.SHELL)
expect(yield* waitForText(base, terminal.id, `cwd:${fixture.root}`)).toContain(`cwd:${fixture.root}`)
}),
20_000,
)
}
function testDirectory(mode: "xdg" | "override", shell?: string) {
return Effect.acquireRelease(
Effect.promise(async () => {
const environment = {
binary: process.env.OPENCODE_PTY_BIN,
runtime: process.env.OPENCODE_PTY_RUNTIME_DIR,
xdg: process.env.XDG_RUNTIME_DIR,
shell: process.env.SHELL,
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-pty-server-test-"))
const runtime = path.join(root, "runtime")
process.env.OPENCODE_PTY_BIN = binary
delete process.env.OPENCODE_PTY_RUNTIME_DIR
process.env.XDG_RUNTIME_DIR = runtime
if (mode === "override") process.env.OPENCODE_PTY_RUNTIME_DIR = runtime
if (shell !== undefined) {
process.env.SHELL = shell
ShellSelect.resolve.reset()
}
return {
database: path.join(root, "opencode.db"),
directory: mode === "override" ? runtime : path.join(runtime, "opencode-pty"),
environment,
root,
}
}),
(fixture) =>
Effect.promise(async () => {
await fs.rm(fixture.root, { recursive: true, force: true })
restore("OPENCODE_PTY_BIN", fixture.environment.binary)
restore("OPENCODE_PTY_RUNTIME_DIR", fixture.environment.runtime)
restore("XDG_RUNTIME_DIR", fixture.environment.xdg)
if (shell !== undefined) {
restore("SHELL", fixture.environment.shell)
ShellSelect.resolve.reset()
}
}),
)
}
function waitForExit(pid: number) {
return Effect.promise(async () => {
for (let attempt = 0; attempt < 100; attempt++) {
try {
process.kill(pid, 0)
} catch (error) {
if (isRecord(error) && error.code === "ESRCH") return
throw error
}
await Bun.sleep(20)
}
throw new Error(`Process ${pid} survived its server scope`)
})
}
function request(base: string, method: string, pathname: string, body?: unknown, headers?: Record<string, string>) {
return Effect.tryPromise({
try: async () => {
@@ -3,7 +3,6 @@ import { createSignal, onCleanup } from "solid-js"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
import { useConfig } from "../config"
import { useData } from "./data"
import { useEvent } from "./event"
import { useStorage } from "./storage"
@@ -16,7 +15,6 @@ export const { use: useSessionTerminals, provider: SessionTerminalsProvider } =
init: () => {
const client = useClient()
const config = useConfig().data
const data = useData()
const event = useEvent()
const [focus, setFocus] = createSignal<string>()
const storage = useStorage()
@@ -76,12 +74,9 @@ export const { use: useSessionTerminals, provider: SessionTerminalsProvider } =
refresh,
selectTerminal,
async newTerminal(sessionID: string): Promise<PersistentPtyInfo> {
const session = data.session.get(sessionID)
const terminal = await client.api.experimental.persistentPty.create({
sessionID,
command: process.env.SHELL || "/bin/sh",
args: [],
cwd: session?.location.directory ?? process.cwd(),
title: "Terminal",
env: {},
})