refactor(core): consolidate pty service (#30537)

This commit is contained in:
Shoubhit Dash
2026-06-03 15:17:46 +05:30
committed by GitHub
parent c6f684366a
commit 932fb6c9ec
28 changed files with 504 additions and 576 deletions
@@ -44,8 +44,6 @@ import { Vcs } from "@/project/vcs"
import { Reference } from "@/reference/reference"
import { Workspace } from "@/control-plane/workspace"
import { Worktree } from "@/worktree"
import { Pty } from "@/pty"
import { PtyTicket } from "@/pty/ticket"
import { Installation } from "@/installation"
import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session"
@@ -101,8 +99,6 @@ export const AppLayer = Layer.mergeAll(
Reference.defaultLayer,
Workspace.defaultLayer,
Worktree.appLayer,
Pty.defaultLayer,
PtyTicket.defaultLayer,
Installation.defaultLayer,
ShareNext.defaultLayer,
SessionShare.defaultLayer,
+30
View File
@@ -0,0 +1,30 @@
export * as PtyPreparation from "./pty-preparation"
import { Config } from "@/config/config"
import * as InstanceState from "@/effect/instance-state"
import { Plugin } from "@/plugin"
import { Shell } from "@/shell/shell"
import { Pty } from "@opencode-ai/core/pty"
import { Effect } from "effect"
export const prepareCreate = Effect.fn("PtyPreparation.prepareCreate")(function* (input: Pty.CreateInput) {
const config = yield* Config.Service
const plugin = yield* Plugin.Service
const command = input.command || Shell.preferred((yield* config.get()).shell)
const args = Shell.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
const cwd = input.cwd || (yield* InstanceState.context).directory
const shell = yield* plugin.trigger("shell.env", { cwd }, { env: {} })
const env = {
...process.env,
...input.env,
...shell.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
} as Record<string, string>
if (process.platform === "win32") {
env.LC_ALL = "C.UTF-8"
env.LC_CTYPE = "C.UTF-8"
env.LANG = "C.UTF-8"
}
return { command, args, cwd, title: input.title, env }
})
-377
View File
@@ -1,377 +0,0 @@
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { EffectBridge } from "@/effect/bridge"
import { lazy } from "@opencode-ai/core/util/lazy"
import { Plugin } from "@/plugin"
import { Shell } from "@/shell/shell"
import type { Proc } from "#pty"
import * as Log from "@opencode-ai/core/util/log"
import { PtyID } from "./schema"
import { Effect, Layer, Context, Schema, Types } from "effect"
import { NonNegativeInt, PositiveInt } from "@opencode-ai/core/schema"
const log = Log.create({ service: "pty" })
const BUFFER_LIMIT = 1024 * 1024 * 2
const BUFFER_CHUNK = 64 * 1024
const encoder = new TextEncoder()
type Socket = {
readyState: number
data?: unknown
send: (data: string | Uint8Array | ArrayBuffer) => void
close: (code?: number, reason?: string) => void
}
const sock = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
type Active = {
info: Info
process: Proc
buffer: string
bufferCursor: number
cursor: number
subscribers: Map<unknown, Socket>
}
type State = {
dir: string
sessions: Map<PtyID, Active>
}
// WebSocket control frame: 0x00 + UTF-8 JSON.
const meta = (cursor: number) => {
const json = JSON.stringify({ cursor })
const bytes = encoder.encode(json)
const out = new Uint8Array(bytes.length + 1)
out[0] = 0
out.set(bytes, 1)
return out
}
const pty = lazy(() => import("#pty"))
export const Info = Schema.Struct({
id: PtyID,
title: Schema.String,
command: Schema.String,
args: Schema.Array(Schema.String),
cwd: Schema.String,
status: Schema.Literals(["running", "exited"]),
// Windows ConPTY (@lydell/node-pty >= 1.2.0-beta.12) assigns the child pid
// asynchronously, so `proc.pid` is 0 at the synchronous spawn point and only
// resolves a tick later. `create` snapshots it immediately, so 0 is a valid
// "pid not yet assigned" value here.
pid: NonNegativeInt,
}).annotate({ identifier: "Pty" })
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
export const CreateInput = Schema.Struct({
command: Schema.optional(Schema.String),
args: Schema.optional(Schema.Array(Schema.String)),
cwd: Schema.optional(Schema.String),
title: Schema.optional(Schema.String),
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
})
export type CreateInput = Types.DeepMutable<Schema.Schema.Type<typeof CreateInput>>
export const UpdateInput = Schema.Struct({
title: Schema.optional(Schema.String),
size: Schema.optional(
Schema.Struct({
rows: PositiveInt,
cols: PositiveInt,
}),
),
})
export type UpdateInput = Types.DeepMutable<Schema.Schema.Type<typeof UpdateInput>>
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Pty.NotFoundError", {
ptyID: PtyID,
}) {}
export const Event = {
Created: EventV2.define({ type: "pty.created", schema: { info: Info } }),
Updated: EventV2.define({ type: "pty.updated", schema: { info: Info } }),
Exited: EventV2.define({ type: "pty.exited", schema: { id: PtyID, exitCode: NonNegativeInt } }),
Deleted: EventV2.define({ type: "pty.deleted", schema: { id: PtyID } }),
}
export interface Interface {
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: PtyID) => Effect.Effect<Info, NotFoundError>
readonly create: (input: CreateInput) => Effect.Effect<Info>
readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info, NotFoundError>
readonly remove: (id: PtyID) => Effect.Effect<void, NotFoundError>
readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect<void, NotFoundError>
readonly write: (id: PtyID, data: string) => Effect.Effect<void, NotFoundError>
readonly connect: (
id: PtyID,
ws: Socket,
cursor?: number,
) => Effect.Effect<
{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined,
NotFoundError
>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Pty") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const events = yield* EventV2Bridge.Service
const plugin = yield* Plugin.Service
function teardown(session: Active) {
try {
session.process.kill()
} catch {}
for (const [sub, ws] of session.subscribers.entries()) {
try {
if (sock(ws) === sub) ws.close()
} catch {}
}
session.subscribers.clear()
}
const state = yield* InstanceState.make<State>(
Effect.fn("Pty.state")(function* (ctx) {
const state = {
dir: ctx.directory,
sessions: new Map<PtyID, Active>(),
}
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const session of state.sessions.values()) {
teardown(session)
}
state.sessions.clear()
}),
)
return state
}),
)
const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) {
const session = (yield* InstanceState.get(state)).sessions.get(id)
if (!session) return yield* new NotFoundError({ ptyID: id })
return session
})
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
const s = yield* InstanceState.get(state)
const session = yield* requireSession(id)
s.sessions.delete(id)
log.info("removing session", { id })
teardown(session)
yield* events.publish(Event.Deleted, { id: session.info.id })
})
const list = Effect.fn("Pty.list")(function* () {
const s = yield* InstanceState.get(state)
return Array.from(s.sessions.values()).map((session) => session.info)
})
const get = Effect.fn("Pty.get")(function* (id: PtyID) {
return (yield* requireSession(id)).info
})
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const s = yield* InstanceState.get(state)
const bridge = yield* EffectBridge.make()
const cfg = yield* config.get()
const id = PtyID.ascending()
const command = input.command || Shell.preferred(cfg.shell)
const args = input.args || []
if (Shell.login(command)) {
args.push("-l")
}
const cwd = input.cwd || s.dir
const shell = yield* plugin.trigger("shell.env", { cwd }, { env: {} })
const env = {
...process.env,
...input.env,
...shell.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
} as Record<string, string>
if (process.platform === "win32") {
env.LC_ALL = "C.UTF-8"
env.LC_CTYPE = "C.UTF-8"
env.LANG = "C.UTF-8"
}
log.info("creating session", { id, cmd: command, args, cwd })
const { spawn } = yield* Effect.promise(() => pty())
const proc = yield* Effect.sync(() =>
spawn(command, args, {
name: "xterm-256color",
cwd,
env,
}),
)
const info = {
id,
title: input.title || `Terminal ${id.slice(-4)}`,
command,
args,
cwd,
status: "running",
pid: proc.pid,
} as const
const session: Active = {
info,
process: proc,
buffer: "",
bufferCursor: 0,
cursor: 0,
subscribers: new Map(),
}
s.sessions.set(id, session)
proc.onData((chunk) => {
session.cursor += chunk.length
for (const [key, ws] of session.subscribers.entries()) {
if (ws.readyState !== 1) {
session.subscribers.delete(key)
continue
}
if (sock(ws) !== key) {
session.subscribers.delete(key)
continue
}
try {
ws.send(chunk)
} catch {
session.subscribers.delete(key)
}
}
session.buffer += chunk
if (session.buffer.length <= BUFFER_LIMIT) return
const excess = session.buffer.length - BUFFER_LIMIT
session.buffer = session.buffer.slice(excess)
session.bufferCursor += excess
})
proc.onExit(({ exitCode }) => {
if (session.info.status === "exited") return
log.info("session exited", { id, exitCode })
session.info.status = "exited"
bridge.fork(events.publish(Event.Exited, { id, exitCode }))
bridge.fork(remove(id))
})
yield* events.publish(Event.Created, { info })
return info
})
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
const session = yield* requireSession(id)
if (input.title) {
session.info.title = input.title
}
if (input.size) {
session.process.resize(input.size.cols, input.size.rows)
}
yield* events.publish(Event.Updated, { info: session.info })
return session.info
})
const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) {
const session = yield* requireSession(id)
if (session.info.status === "running") {
session.process.resize(cols, rows)
}
})
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
const session = yield* requireSession(id)
if (session.info.status === "running") {
session.process.write(data)
}
})
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
const session = yield* requireSession(id).pipe(
Effect.tapError(() =>
Effect.sync(() => {
ws.close()
}),
),
)
log.info("client connected to session", { id })
const sub = sock(ws)
session.subscribers.delete(sub)
session.subscribers.set(sub, ws)
const cleanup = () => {
session.subscribers.delete(sub)
}
const start = session.bufferCursor
const end = session.cursor
const from =
cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0
const data = (() => {
if (!session.buffer) return ""
if (from >= end) return ""
const offset = Math.max(0, from - start)
if (offset >= session.buffer.length) return ""
return session.buffer.slice(offset)
})()
if (data) {
try {
for (let i = 0; i < data.length; i += BUFFER_CHUNK) {
ws.send(data.slice(i, i + BUFFER_CHUNK))
}
} catch {
cleanup()
ws.close()
return
}
}
try {
ws.send(meta(end))
} catch {
cleanup()
ws.close()
return
}
return {
onMessage: (message: string | ArrayBuffer) => {
session.process.write(typeof message === "string" ? message : new TextDecoder().decode(message))
},
onClose: () => {
log.info("client disconnected from session", { id })
cleanup()
},
}
})
return Service.of({ list, get, create, update, remove, resize, write, connect })
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Config.defaultLayer),
)
export * as Pty from "."
-24
View File
@@ -1,24 +0,0 @@
import { Effect } from "effect"
const inputDecoder = new TextDecoder("utf-8", { fatal: true })
export function handlePtyInput(
handler: { onMessage: (message: string | ArrayBuffer) => void },
message: string | Uint8Array,
) {
if (typeof message === "string") {
handler.onMessage(message)
return Effect.void
}
return Effect.try({
try: () => inputDecoder.decode(message),
catch: () => new Error("invalid PTY websocket input"),
}).pipe(
Effect.catch(() => Effect.succeed(undefined)),
Effect.flatMap((decoded) => {
if (decoded === undefined) return Effect.void
handler.onMessage(decoded)
return Effect.void
}),
)
}
-26
View File
@@ -1,26 +0,0 @@
import { spawn as create } from "bun-pty"
import type { Opts, Proc } from "./pty"
export type { Disp, Exit, Opts, Proc } from "./pty"
export function spawn(file: string, args: string[], opts: Opts): Proc {
const pty = create(file, args, opts)
return {
pid: pty.pid,
onData(listener) {
return pty.onData(listener)
},
onExit(listener) {
return pty.onExit(listener)
},
write(data) {
pty.write(data)
},
resize(cols, rows) {
pty.resize(cols, rows)
},
kill(signal) {
pty.kill(signal)
},
}
}
-26
View File
@@ -1,26 +0,0 @@
import * as pty from "@lydell/node-pty"
import type { Opts, Proc } from "./pty"
export type { Disp, Exit, Opts, Proc } from "./pty"
export function spawn(file: string, args: string[], opts: Opts): Proc {
const proc = pty.spawn(file, args, opts)
return {
pid: proc.pid,
onData(listener) {
return proc.onData(listener)
},
onExit(listener) {
return proc.onExit(listener)
},
write(data) {
proc.write(data)
},
resize(cols, rows) {
proc.resize(cols, rows)
},
kill(signal) {
proc.kill(signal)
},
}
}
-25
View File
@@ -1,25 +0,0 @@
export type Disp = {
dispose(): void
}
export type Exit = {
exitCode: number
signal?: number | string
}
export type Opts = {
name: string
cols?: number
rows?: number
cwd?: string
env?: Record<string, string>
}
export type Proc = {
pid: number
onData(listener: (data: string) => void): Disp
onExit(listener: (event: Exit) => void): Disp
write(data: string): void
resize(cols: number, rows: number): void
kill(signal?: string): void
}
-14
View File
@@ -1,14 +0,0 @@
import { Schema } from "effect"
import { Identifier } from "@/id/id"
import { withStatics } from "@opencode-ai/core/schema"
const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID"))
export type PtyID = typeof ptyIdSchema.Type
export const PtyID = ptyIdSchema.pipe(
withStatics((schema: typeof ptyIdSchema) => ({
ascending: (id?: string) => schema.make(Identifier.ascending("pty", id)),
})),
)
-68
View File
@@ -1,68 +0,0 @@
export * as PtyTicket from "./ticket"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { PtyID } from "@/pty/schema"
import { PositiveInt } from "@opencode-ai/core/schema"
import { Cache, Context, Duration, Effect, Layer, Schema } from "effect"
const DEFAULT_TTL = Duration.seconds(60)
const CAPACITY = 10_000
export const ConnectToken = Schema.Struct({
ticket: Schema.String,
expires_in: PositiveInt,
})
export type Scope = {
readonly ptyID: PtyID
readonly directory?: string
readonly workspaceID?: WorkspaceV2.ID
}
export interface Interface {
issue(input: Scope): Effect.Effect<typeof ConnectToken.Type>
consume(input: Scope & { readonly ticket: string }): Effect.Effect<boolean>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PtyTicket") {}
function matches(record: Scope, input: Scope) {
return (
record.ptyID === input.ptyID && record.directory === input.directory && record.workspaceID === input.workspaceID
)
}
// Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is
// never invoked; it dies if it ever is, which would signal a misuse of the Service interface.
const noLookup = () => Effect.die("PtyTicket cache must be used via set/invalidateWhen, never get")
// Visible for tests so the TTL can be shortened. Production uses `layer` with the default TTL.
export const make = (ttl: Duration.Input = DEFAULT_TTL) =>
Effect.gen(function* () {
const cache = yield* Cache.make<string, Scope>({ capacity: CAPACITY, lookup: noLookup, timeToLive: ttl })
const expiresIn = Math.max(1, Math.round(Duration.toSeconds(Duration.fromInputUnsafe(ttl))))
return Service.of({
issue: Effect.fn("PtyTicket.issue")(function* (input) {
const ticket = crypto.randomUUID()
yield* Cache.set(cache, ticket, input)
return { ticket, expires_in: expiresIn }
}),
consume: Effect.fn("PtyTicket.consume")(function* (input) {
return yield* Cache.invalidateWhen(cache, input.ticket, (stored) => matches(stored, input))
}),
})
})
export const layer = Layer.effect(Service, make())
export const defaultLayer = layer
export const scope = Effect.gen(function* () {
const instance = yield* InstanceRef
const workspaceID = yield* WorkspaceRef
return {
directory: instance?.directory,
workspaceID,
}
})
@@ -1,6 +1,6 @@
import { Pty } from "@/pty"
import { PtyTicket } from "@/pty/ticket"
import { PtyID } from "@/pty/schema"
import { Pty } from "@opencode-ai/core/pty"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { PtyID } from "@opencode-ai/core/pty/schema"
import { PTY_CONNECT_TICKET_QUERY } from "@/server/shared/pty-ticket"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
@@ -1,7 +1,13 @@
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { PtyTicket } from "@/pty/ticket"
import { handlePtyInput } from "@/pty/input"
import * as InstanceState from "@/effect/instance-state"
import { registerDisposer } from "@/effect/instance-registry"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { PtyPreparation } from "@/pty-preparation"
import { Pty } from "@opencode-ai/core/pty"
import { handlePtyInput } from "@opencode-ai/core/pty/input"
import { PtyID } from "@opencode-ai/core/pty/schema"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Shell } from "@/shell/shell"
import { EffectBridge } from "@/effect/bridge"
import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@/server/cors"
@@ -10,7 +16,7 @@ import {
PTY_CONNECT_TOKEN_HEADER,
PTY_CONNECT_TOKEN_HEADER_VALUE,
} from "@/server/shared/pty-ticket"
import { Effect, Option, Schema } from "effect"
import { Effect, Layer, Option, Schema } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
@@ -23,30 +29,53 @@ function validOrigin(request: HttpServerRequest.HttpServerRequest, opts: CorsOpt
return isAllowedRequestOrigin(request.headers.origin, request.headers.host, opts)
}
const ticketScope = Effect.gen(function* () {
const instance = yield* InstanceRef
const workspaceID = yield* WorkspaceRef
return { directory: instance?.directory, workspaceID }
})
export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const tickets = yield* PtyTicket.Service
const cors = yield* CorsConfig
const locations = yield* LocationServiceMap
const unregister = registerDisposer((directory) =>
Effect.runPromise(locations.invalidate({ directory: AbsolutePath.make(directory) })),
)
yield* Effect.addFinalizer(() => Effect.sync(unregister))
const pty = Effect.fnUntraced(function* <A, E, R>(effect: Effect.Effect<A, E, R>) {
return yield* effect.pipe(
Effect.provide(locations.get({ directory: AbsolutePath.make((yield* InstanceState.context).directory) })),
)
})
const shells = Effect.fn("PtyHttpApi.shells")(function* () {
return yield* Effect.promise(() => Shell.list())
})
const list = Effect.fn("PtyHttpApi.list")(function* () {
return yield* pty.list()
return yield* pty(Pty.Service.use((service) => service.list()))
})
const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) {
return yield* pty.create({
...ctx.payload,
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
})
return yield* pty(
Pty.Service.use((service) =>
Effect.flatMap(
PtyPreparation.prepareCreate({
...ctx.payload,
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
}),
service.create,
),
),
)
})
const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
return yield* pty.get(ctx.params.ptyID).pipe(
return yield* pty(Pty.Service.use((service) => service.get(ctx.params.ptyID))).pipe(
Effect.catchTag("Pty.NotFoundError", (error) =>
Effect.fail(
new ApiError.PtyNotFoundError({
@@ -62,25 +91,27 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
params: { ptyID: PtyID }
payload: typeof Pty.UpdateInput.Type
}) {
return yield* pty
.update(ctx.params.ptyID, {
...ctx.payload,
size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
})
.pipe(
Effect.catchTag("Pty.NotFoundError", (error) =>
Effect.fail(
new ApiError.PtyNotFoundError({
ptyID: error.ptyID,
message: `PTY session not found: ${error.ptyID}`,
}),
),
return yield* pty(
Pty.Service.use((service) =>
service.update(ctx.params.ptyID, {
...ctx.payload,
size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
}),
),
).pipe(
Effect.catchTag("Pty.NotFoundError", (error) =>
Effect.fail(
new ApiError.PtyNotFoundError({
ptyID: error.ptyID,
message: `PTY session not found: ${error.ptyID}`,
}),
),
)
),
)
})
const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) {
yield* pty.remove(ctx.params.ptyID).pipe(
yield* pty(Pty.Service.use((service) => service.remove(ctx.params.ptyID))).pipe(
Effect.catchTag("Pty.NotFoundError", (error) =>
Effect.fail(
new ApiError.PtyNotFoundError({
@@ -97,7 +128,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
const request = yield* HttpServerRequest.HttpServerRequest
if (request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(request, cors))
return yield* new ApiError.PtyForbiddenError({ message: "Invalid PTY connect token request" })
yield* pty.get(ctx.params.ptyID).pipe(
yield* pty(Pty.Service.use((service) => service.get(ctx.params.ptyID))).pipe(
Effect.catchTag("Pty.NotFoundError", (error) =>
Effect.fail(
new ApiError.PtyNotFoundError({
@@ -107,7 +138,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
),
),
)
return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) })
return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* ticketScope) })
})
return handlers
@@ -119,13 +150,23 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
.handle("remove", remove)
.handle("connectToken", connectToken)
}),
)
).pipe(Layer.provide(LocationServiceMap.layer))
export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const tickets = yield* PtyTicket.Service
const cors = yield* CorsConfig
const locations = yield* LocationServiceMap
const unregister = registerDisposer((directory) =>
Effect.runPromise(locations.invalidate({ directory: AbsolutePath.make(directory) })),
)
yield* Effect.addFinalizer(() => Effect.sync(unregister))
const pty = Effect.fnUntraced(function* <A, E, R>(effect: Effect.Effect<A, E, R>) {
return yield* effect.pipe(
Effect.provide(locations.get({ directory: AbsolutePath.make((yield* InstanceState.context).directory) })),
)
})
return handlers.handleRaw(
"connect",
@@ -133,7 +174,7 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne
params: { ptyID: PtyID }
request: HttpServerRequest.HttpServerRequest
}) {
const exists = yield* pty.get(ctx.params.ptyID).pipe(
const exists = yield* pty(Pty.Service.use((service) => service.get(ctx.params.ptyID))).pipe(
Effect.as(true),
Effect.catchTag("Pty.NotFoundError", () => Effect.succeed(false)),
)
@@ -144,7 +185,7 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne
const ticket = new URL(ctx.request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY)
if (ticket) {
const valid = validOrigin(ctx.request, cors)
? yield* tickets.consume({ ticket, ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) })
? yield* tickets.consume({ ticket, ptyID: ctx.params.ptyID, ...(yield* ticketScope) })
: false
if (!valid) return HttpServerResponse.empty({ status: 403 })
}
@@ -187,13 +228,13 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne
writeScoped(write(new Socket.CloseEvent(code, reason)))
},
}
const handler = yield* pty
.connect(ctx.params.ptyID, adapter, cursor)
.pipe(
Effect.catchTag("Pty.NotFoundError", () =>
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
),
)
const handler = yield* pty(
Pty.Service.use((service) => service.connect(ctx.params.ptyID, adapter, cursor)),
).pipe(
Effect.catchTag("Pty.NotFoundError", () =>
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
),
)
if (!handler) return HttpServerResponse.empty()
// The handshake runs inside `socket.runRaw`, after the input callback is
@@ -214,4 +255,4 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne
}),
)
}),
)
).pipe(Layer.provide(LocationServiceMap.layer))
@@ -31,8 +31,7 @@ import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { ProviderAuth } from "@/provider/auth"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Provider } from "@/provider/provider"
import { Pty } from "@/pty"
import { PtyTicket } from "@/pty/ticket"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Question } from "@/question"
import { Session } from "@/session/session"
import { SessionCompaction } from "@/session/compaction"
@@ -212,7 +211,6 @@ export function createRoutes(
ProjectCopy.defaultLayer,
ProviderAuth.defaultLayer,
Provider.defaultLayer,
Pty.defaultLayer,
PtyTicket.defaultLayer,
Question.defaultLayer,
Ripgrep.defaultLayer,