pty.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. export * as Pty from "./pty"
  2. import type { Disp, Proc } from "#pty"
  3. import { Context, Effect, Layer, Schema, Types } from "effect"
  4. import { PtyEvent, PtyInfo, Pty } from "@opencode-ai/schema/pty"
  5. import { Config } from "./config"
  6. import { EventV2 } from "./event"
  7. import { Location } from "./location"
  8. import { PtyID } from "./pty/schema"
  9. import { Shell } from "./shell"
  10. import { lazy } from "./util/lazy"
  11. const BUFFER_LIMIT = 1024 * 1024 * 2
  12. // Exited sessions stay observable (status, exit code, retained output) until removed explicitly.
  13. // Cap retention so abandoned terminals do not accumulate unbounded buffers.
  14. const EXITED_LIMIT = 25
  15. const pty = lazy(() => import("#pty"))
  16. type Subscriber = {
  17. readonly onData: (chunk: string) => void
  18. readonly onEnd: (event: { exitCode?: number }) => void
  19. active: boolean
  20. detached: boolean
  21. pending: string[]
  22. end?: { exitCode?: number }
  23. }
  24. type Active = {
  25. info: Info
  26. process: Proc
  27. buffer: string
  28. bufferCursor: number
  29. cursor: number
  30. subscribers: Map<object, Subscriber>
  31. listeners: Disp[]
  32. }
  33. export const Info = PtyInfo
  34. export type Info = Types.DeepMutable<typeof Info.Type>
  35. export const CreateInput = Pty.CreateInput
  36. export type CreateInput = Types.DeepMutable<typeof CreateInput.Type>
  37. export const UpdateInput = Pty.UpdateInput
  38. export type UpdateInput = Types.DeepMutable<typeof UpdateInput.Type>
  39. export type AttachInput = {
  40. // Absolute output cursor to replay from. -1 tails from the current end; omitted replays the full retained buffer.
  41. readonly cursor?: number
  42. // Callbacks fire synchronously from the native PTY data path; keep them non-blocking.
  43. readonly onData: (chunk: string) => void
  44. // Fired once when the session stops producing output: process exit (exitCode set), removal, or service teardown.
  45. readonly onEnd: (event: { exitCode?: number }) => void
  46. }
  47. export type Attachment = {
  48. // Retained output from the requested cursor to the current end.
  49. readonly replay: string
  50. // Absolute output cursor after replay.
  51. readonly cursor: number
  52. readonly write: (data: string) => void
  53. // Starts live delivery after the caller has applied replay and cursor metadata.
  54. readonly activate: () => void
  55. readonly detach: () => void
  56. }
  57. export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Pty.NotFoundError", {
  58. ptyID: PtyID,
  59. }) {}
  60. export class ExitedError extends Schema.TaggedErrorClass<ExitedError>()("Pty.ExitedError", {
  61. ptyID: PtyID,
  62. }) {}
  63. export const Event = PtyEvent
  64. export interface Interface {
  65. readonly list: () => Effect.Effect<Info[]>
  66. readonly get: (id: PtyID) => Effect.Effect<Info, NotFoundError>
  67. readonly create: (input: CreateInput) => Effect.Effect<Info>
  68. readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info, NotFoundError>
  69. readonly remove: (id: PtyID) => Effect.Effect<void, NotFoundError>
  70. readonly write: (id: PtyID, data: string) => Effect.Effect<void, NotFoundError>
  71. readonly attach: (id: PtyID, input: AttachInput) => Effect.Effect<Attachment, NotFoundError | ExitedError>
  72. }
  73. export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Pty") {}
  74. export const layer = Layer.effect(
  75. Service,
  76. Effect.gen(function* () {
  77. const events = yield* EventV2.Service
  78. const location = yield* Location.Service
  79. const config = yield* Config.Service
  80. const context = yield* Effect.context()
  81. const runFork = Effect.runForkWith(context)
  82. const sessions = new Map<PtyID, Active>()
  83. const exitOrder: PtyID[] = []
  84. function notifyEnd(session: Active, event: { exitCode?: number }) {
  85. for (const subscriber of session.subscribers.values()) {
  86. if (!subscriber.active) {
  87. subscriber.end = event
  88. continue
  89. }
  90. try {
  91. subscriber.onEnd(event)
  92. } catch {}
  93. }
  94. session.subscribers.clear()
  95. }
  96. function teardown(session: Active) {
  97. for (const listener of session.listeners) listener.dispose()
  98. session.listeners.length = 0
  99. if (session.info.status === "running") {
  100. try {
  101. session.process.kill()
  102. } catch {}
  103. }
  104. notifyEnd(session, {})
  105. }
  106. yield* Effect.addFinalizer(() =>
  107. Effect.sync(() => {
  108. for (const session of sessions.values()) teardown(session)
  109. sessions.clear()
  110. exitOrder.length = 0
  111. }),
  112. )
  113. const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) {
  114. const session = sessions.get(id)
  115. if (!session) return yield* new NotFoundError({ ptyID: id })
  116. return session
  117. })
  118. const removeSession = Effect.fnUntraced(function* (id: PtyID) {
  119. const session = sessions.get(id)
  120. if (!session) return
  121. sessions.delete(id)
  122. const index = exitOrder.indexOf(id)
  123. if (index !== -1) exitOrder.splice(index, 1)
  124. yield* Effect.logInfo("removing session", { id })
  125. teardown(session)
  126. yield* events.publish(Event.Deleted, { id: session.info.id })
  127. })
  128. const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
  129. yield* requireSession(id)
  130. yield* removeSession(id)
  131. })
  132. const list = Effect.fn("Pty.list")(function* () {
  133. return Array.from(sessions.values()).map((session) => session.info)
  134. })
  135. const get = Effect.fn("Pty.get")(function* (id: PtyID) {
  136. return (yield* requireSession(id)).info
  137. })
  138. const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
  139. const id = PtyID.ascending()
  140. const command = input.command || Shell.preferred(Config.latest(yield* config.entries(), "shell"))
  141. const args = Shell.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
  142. const cwd = input.cwd || location.directory
  143. const env = {
  144. ...process.env,
  145. ...input.env,
  146. TERM: "xterm-256color",
  147. OPENCODE_TERMINAL: "1",
  148. } as Record<string, string>
  149. if (process.platform === "win32") {
  150. env.LC_ALL = "C.UTF-8"
  151. env.LC_CTYPE = "C.UTF-8"
  152. env.LANG = "C.UTF-8"
  153. }
  154. yield* Effect.logInfo("creating session", { id, cmd: command, args, cwd })
  155. const { spawn } = yield* Effect.promise(() => pty())
  156. const proc = yield* Effect.sync(() => spawn(command, args, { name: "xterm-256color", cwd, env }))
  157. const info: Info = {
  158. id,
  159. title: input.title || `Terminal ${id.slice(-4)}`,
  160. command,
  161. args,
  162. cwd,
  163. status: "running",
  164. pid: proc.pid,
  165. }
  166. const session: Active = {
  167. info,
  168. process: proc,
  169. buffer: "",
  170. bufferCursor: 0,
  171. cursor: 0,
  172. subscribers: new Map(),
  173. listeners: [],
  174. }
  175. sessions.set(id, session)
  176. session.listeners.push(
  177. proc.onData((chunk) => {
  178. session.cursor += chunk.length
  179. for (const [token, subscriber] of session.subscribers.entries()) {
  180. if (!subscriber.active) {
  181. subscriber.pending.push(chunk)
  182. continue
  183. }
  184. try {
  185. subscriber.onData(chunk)
  186. } catch {
  187. session.subscribers.delete(token)
  188. }
  189. }
  190. session.buffer += chunk
  191. if (session.buffer.length <= BUFFER_LIMIT) return
  192. const excess = session.buffer.length - BUFFER_LIMIT
  193. session.buffer = session.buffer.slice(excess)
  194. session.bufferCursor += excess
  195. }),
  196. proc.onExit(({ exitCode }) => {
  197. if (session.info.status === "exited") return
  198. session.info.status = "exited"
  199. session.info.exitCode = exitCode
  200. notifyEnd(session, { exitCode })
  201. exitOrder.push(id)
  202. runFork(
  203. Effect.gen(function* () {
  204. yield* Effect.logInfo("session exited", { id, exitCode })
  205. yield* events.publish(Event.Exited, { id, exitCode })
  206. while (exitOrder.length > EXITED_LIMIT) {
  207. const oldest = exitOrder[0]
  208. if (!oldest) break
  209. yield* removeSession(oldest)
  210. }
  211. }),
  212. )
  213. }),
  214. )
  215. yield* events.publish(Event.Created, { info })
  216. return info
  217. })
  218. const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
  219. const session = yield* requireSession(id)
  220. if (input.title) session.info.title = input.title
  221. if (input.size && session.info.status === "running") session.process.resize(input.size.cols, input.size.rows)
  222. yield* events.publish(Event.Updated, { info: session.info })
  223. return session.info
  224. })
  225. const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
  226. const session = yield* requireSession(id)
  227. if (session.info.status === "running") session.process.write(data)
  228. })
  229. const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) {
  230. const session = yield* requireSession(id)
  231. if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id })
  232. yield* Effect.logInfo("client attached to session", { id, directory: location.directory })
  233. const token = {}
  234. const subscriber: Subscriber = {
  235. onData: input.onData,
  236. onEnd: input.onEnd,
  237. active: false,
  238. detached: false,
  239. pending: [],
  240. }
  241. session.subscribers.set(token, subscriber)
  242. const start = session.bufferCursor
  243. const end = session.cursor
  244. const from =
  245. input.cursor === -1
  246. ? end
  247. : typeof input.cursor === "number" && Number.isSafeInteger(input.cursor)
  248. ? Math.max(0, input.cursor)
  249. : 0
  250. const replay = (() => {
  251. if (!session.buffer || from >= end) return ""
  252. const offset = Math.max(0, from - start)
  253. if (offset >= session.buffer.length) return ""
  254. return session.buffer.slice(offset)
  255. })()
  256. return {
  257. replay,
  258. cursor: end,
  259. write: (data: string) => {
  260. if (session.info.status === "running") session.process.write(data)
  261. },
  262. activate: () => {
  263. if (subscriber.active || subscriber.detached) return
  264. subscriber.active = true
  265. try {
  266. for (const chunk of subscriber.pending) subscriber.onData(chunk)
  267. subscriber.pending.length = 0
  268. if (subscriber.end) subscriber.onEnd(subscriber.end)
  269. } catch {
  270. session.subscribers.delete(token)
  271. }
  272. },
  273. detach: () => {
  274. subscriber.detached = true
  275. subscriber.pending.length = 0
  276. subscriber.end = undefined
  277. session.subscribers.delete(token)
  278. },
  279. }
  280. })
  281. return Service.of({ list, get, create, update, remove, write, attach })
  282. }),
  283. )
  284. export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))