pty.ts 11 KB

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