pty.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. export * as Pty from "./pty"
  2. import type { Disp, Proc } from "#pty"
  3. import { Context, Effect, Layer, Schema, Types } from "effect"
  4. import { EventV2 } from "./event"
  5. import { Location } from "./location"
  6. import { NonNegativeInt, PositiveInt } from "./schema"
  7. import { PtyID } from "./pty/schema"
  8. import { lazy } from "./util/lazy"
  9. const BUFFER_LIMIT = 1024 * 1024 * 2
  10. const BUFFER_CHUNK = 64 * 1024
  11. const encoder = new TextEncoder()
  12. const pty = lazy(() => import("#pty"))
  13. type Socket = {
  14. readyState: number
  15. data?: unknown
  16. send: (data: string | Uint8Array | ArrayBuffer) => void
  17. close: (code?: number, reason?: string) => void
  18. }
  19. type Active = {
  20. info: Info
  21. process: Proc
  22. buffer: string
  23. bufferCursor: number
  24. cursor: number
  25. subscribers: Map<unknown, Socket>
  26. listeners: Disp[]
  27. }
  28. const sock = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
  29. // WebSocket control frame: 0x00 + UTF-8 JSON.
  30. const meta = (cursor: number) => {
  31. const json = JSON.stringify({ cursor })
  32. const bytes = encoder.encode(json)
  33. const out = new Uint8Array(bytes.length + 1)
  34. out[0] = 0
  35. out.set(bytes, 1)
  36. return out
  37. }
  38. export const Info = Schema.Struct({
  39. id: PtyID,
  40. title: Schema.String,
  41. command: Schema.String,
  42. args: Schema.Array(Schema.String),
  43. cwd: Schema.String,
  44. status: Schema.Literals(["running", "exited"]),
  45. // Windows ConPTY assigns the child pid asynchronously, so 0 is valid at spawn time.
  46. pid: NonNegativeInt,
  47. }).annotate({ identifier: "Pty" })
  48. export type Info = Types.DeepMutable<typeof Info.Type>
  49. export const CreateInput = Schema.Struct({
  50. command: Schema.optional(Schema.String),
  51. args: Schema.optional(Schema.Array(Schema.String)),
  52. cwd: Schema.optional(Schema.String),
  53. title: Schema.optional(Schema.String),
  54. env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
  55. })
  56. export type CreateInput = Types.DeepMutable<typeof CreateInput.Type>
  57. export type PreparedCreate = {
  58. readonly command: string
  59. readonly args: string[]
  60. readonly cwd: string
  61. readonly title?: string
  62. readonly env: Record<string, string>
  63. }
  64. export const UpdateInput = Schema.Struct({
  65. title: Schema.optional(Schema.String),
  66. size: Schema.optional(
  67. Schema.Struct({
  68. rows: PositiveInt,
  69. cols: PositiveInt,
  70. }),
  71. ),
  72. })
  73. export type UpdateInput = Types.DeepMutable<typeof UpdateInput.Type>
  74. export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Pty.NotFoundError", {
  75. ptyID: PtyID,
  76. }) {}
  77. export const Event = {
  78. Created: EventV2.define({ type: "pty.created", schema: { info: Info } }),
  79. Updated: EventV2.define({ type: "pty.updated", schema: { info: Info } }),
  80. Exited: EventV2.define({ type: "pty.exited", schema: { id: PtyID, exitCode: NonNegativeInt } }),
  81. Deleted: EventV2.define({ type: "pty.deleted", schema: { id: PtyID } }),
  82. }
  83. export interface Interface {
  84. readonly list: () => Effect.Effect<Info[]>
  85. readonly get: (id: PtyID) => Effect.Effect<Info, NotFoundError>
  86. readonly create: (input: PreparedCreate) => Effect.Effect<Info>
  87. readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info, NotFoundError>
  88. readonly remove: (id: PtyID) => Effect.Effect<void, NotFoundError>
  89. readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect<void, NotFoundError>
  90. readonly write: (id: PtyID, data: string) => Effect.Effect<void, NotFoundError>
  91. readonly connect: (
  92. id: PtyID,
  93. ws: Socket,
  94. cursor?: number,
  95. ) => Effect.Effect<
  96. { onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined,
  97. NotFoundError
  98. >
  99. }
  100. export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Pty") {}
  101. export const layer = Layer.effect(
  102. Service,
  103. Effect.gen(function* () {
  104. const events = yield* EventV2.Service
  105. const location = yield* Location.Service
  106. const context = yield* Effect.context()
  107. const runFork = Effect.runForkWith(context)
  108. const sessions = new Map<PtyID, Active>()
  109. function teardown(session: Active) {
  110. for (const listener of session.listeners) listener.dispose()
  111. session.listeners.length = 0
  112. try {
  113. session.process.kill()
  114. } catch {}
  115. for (const [sub, ws] of session.subscribers.entries()) {
  116. try {
  117. if (sock(ws) === sub) ws.close()
  118. } catch {}
  119. }
  120. session.subscribers.clear()
  121. }
  122. yield* Effect.addFinalizer(() =>
  123. Effect.sync(() => {
  124. for (const session of sessions.values()) teardown(session)
  125. sessions.clear()
  126. }),
  127. )
  128. const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) {
  129. const session = sessions.get(id)
  130. if (!session) return yield* new NotFoundError({ ptyID: id })
  131. return session
  132. })
  133. const removeSession = Effect.fnUntraced(function* (id: PtyID) {
  134. const session = sessions.get(id)
  135. if (!session) return false
  136. sessions.delete(id)
  137. yield* Effect.logInfo("removing session", { id })
  138. teardown(session)
  139. yield* events.publish(Event.Deleted, { id: session.info.id })
  140. return true
  141. })
  142. const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
  143. yield* requireSession(id)
  144. yield* removeSession(id)
  145. })
  146. const list = Effect.fn("Pty.list")(function* () {
  147. return Array.from(sessions.values()).map((session) => session.info)
  148. })
  149. const get = Effect.fn("Pty.get")(function* (id: PtyID) {
  150. return (yield* requireSession(id)).info
  151. })
  152. const create = Effect.fn("Pty.create")(function* (input: PreparedCreate) {
  153. const id = PtyID.ascending()
  154. yield* Effect.logInfo("creating session", { id, cmd: input.command, args: input.args, cwd: input.cwd })
  155. const { spawn } = yield* Effect.promise(() => pty())
  156. const proc = yield* Effect.sync(() =>
  157. spawn(input.command, input.args, {
  158. name: "xterm-256color",
  159. cwd: input.cwd,
  160. env: input.env,
  161. }),
  162. )
  163. const info = {
  164. id,
  165. title: input.title || `Terminal ${id.slice(-4)}`,
  166. command: input.command,
  167. args: input.args,
  168. cwd: input.cwd,
  169. status: "running",
  170. pid: proc.pid,
  171. } as const
  172. const session: Active = {
  173. info,
  174. process: proc,
  175. buffer: "",
  176. bufferCursor: 0,
  177. cursor: 0,
  178. subscribers: new Map(),
  179. listeners: [],
  180. }
  181. sessions.set(id, session)
  182. session.listeners.push(
  183. proc.onData((chunk) => {
  184. session.cursor += chunk.length
  185. for (const [key, ws] of session.subscribers.entries()) {
  186. if (ws.readyState !== 1 || sock(ws) !== key) {
  187. session.subscribers.delete(key)
  188. continue
  189. }
  190. try {
  191. ws.send(chunk)
  192. } catch {
  193. session.subscribers.delete(key)
  194. }
  195. }
  196. session.buffer += chunk
  197. if (session.buffer.length <= BUFFER_LIMIT) return
  198. const excess = session.buffer.length - BUFFER_LIMIT
  199. session.buffer = session.buffer.slice(excess)
  200. session.bufferCursor += excess
  201. }),
  202. proc.onExit(({ exitCode }) => {
  203. if (session.info.status === "exited") return
  204. runFork(
  205. Effect.gen(function* () {
  206. yield* Effect.logInfo("session exited", { id, exitCode })
  207. session.info.status = "exited"
  208. yield* events.publish(Event.Exited, { id, exitCode })
  209. yield* removeSession(id)
  210. }),
  211. )
  212. }),
  213. )
  214. yield* events.publish(Event.Created, { info })
  215. return info
  216. })
  217. const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
  218. const session = yield* requireSession(id)
  219. if (input.title) session.info.title = input.title
  220. if (input.size) session.process.resize(input.size.cols, input.size.rows)
  221. yield* events.publish(Event.Updated, { info: session.info })
  222. return session.info
  223. })
  224. const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) {
  225. const session = yield* requireSession(id)
  226. if (session.info.status === "running") session.process.resize(cols, rows)
  227. })
  228. const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
  229. const session = yield* requireSession(id)
  230. if (session.info.status === "running") session.process.write(data)
  231. })
  232. const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
  233. const session = yield* requireSession(id).pipe(Effect.tapError(() => Effect.sync(() => ws.close())))
  234. yield* Effect.logInfo("client connected to session", { id, directory: location.directory })
  235. const sub = sock(ws)
  236. session.subscribers.delete(sub)
  237. session.subscribers.set(sub, ws)
  238. const cleanup = () => session.subscribers.delete(sub)
  239. const start = session.bufferCursor
  240. const end = session.cursor
  241. const from =
  242. cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0
  243. const data = (() => {
  244. if (!session.buffer || from >= end) return ""
  245. const offset = Math.max(0, from - start)
  246. if (offset >= session.buffer.length) return ""
  247. return session.buffer.slice(offset)
  248. })()
  249. if (data) {
  250. try {
  251. for (let i = 0; i < data.length; i += BUFFER_CHUNK) ws.send(data.slice(i, i + BUFFER_CHUNK))
  252. } catch {
  253. cleanup()
  254. ws.close()
  255. return
  256. }
  257. }
  258. try {
  259. ws.send(meta(end))
  260. } catch {
  261. cleanup()
  262. ws.close()
  263. return
  264. }
  265. return {
  266. onMessage: (message: string | ArrayBuffer) => {
  267. session.process.write(typeof message === "string" ? message : new TextDecoder().decode(message))
  268. },
  269. onClose: () => {
  270. cleanup()
  271. },
  272. }
  273. })
  274. return Service.of({ list, get, create, update, remove, resize, write, connect })
  275. }),
  276. )
  277. export const locationLayer = layer