terminal.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. /** One PTY, a bounded terminal emulator and its detachable browser followers. */
  2. import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
  3. import type { Terminal as HeadlessTerminal } from '@xterm/headless'
  4. import type { SerializeAddon as Serializer } from '@xterm/addon-serialize'
  5. import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
  6. import { createLazyRequire } from '@deepseek-ai/dsh-lazy-require'
  7. import { TerminalFollower } from './stream.ts'
  8. import { TerminalRetention, type TerminalRetentionPolicy } from './retention.ts'
  9. import type { TerminalAttachmentId, TerminalFrame, TerminalRetentionFrame, WebTerminalInfo } from './types.ts'
  10. const requireHeadless = createLazyRequire<typeof import('@xterm/headless')>('@xterm/headless', import.meta.url)
  11. const requireSerialize = createLazyRequire<typeof import('@xterm/addon-serialize')>('@xterm/addon-serialize', import.meta.url)
  12. /** Process lifetime is independent of follower and component lifetimes. */
  13. export class BrowserTerminal {
  14. private readonly screen: HeadlessTerminal
  15. private readonly serializer: Serializer
  16. private readonly followers = new Set<TerminalFollower>()
  17. private sequence = 0
  18. private operations: Promise<unknown> = Promise.resolve()
  19. private readonly drained: Promise<void>
  20. private closing: Promise<void> | undefined
  21. private retention: TerminalRetention | undefined
  22. private controller: { id: TerminalAttachmentId; follower: TerminalFollower } | undefined
  23. /**
  24. * @param handle - allocated terminal process range.
  25. * @param info - initial metadata.
  26. * @param scrollback - maximum retained scrollback rows.
  27. * @param maxBufferedBytes - per-follower queue cap.
  28. */
  29. constructor(
  30. private readonly handle: SubprocessTerminalHandle,
  31. public info: WebTerminalInfo,
  32. scrollback: number,
  33. private readonly maxBufferedBytes: number,
  34. ) {
  35. const { Terminal } = requireHeadless()
  36. const { SerializeAddon } = requireSerialize()
  37. this.screen = new Terminal({ cols: info.cols, rows: info.rows, scrollback, allowProposedApi: true })
  38. this.serializer = new SerializeAddon()
  39. this.screen.loadAddon(this.serializer)
  40. this.drained = this.consume()
  41. }
  42. /**
  43. * Start monitoring after this allocation is committed to its Session owner.
  44. * @param policy - validated Host timing policy.
  45. * @param closing - closes the id before any asynchronous termination.
  46. * @param closed - removes the exact successfully terminated owner record.
  47. * @param failed - diagnostic sink for background cleanup failure.
  48. */
  49. monitor(policy: TerminalRetentionPolicy, closing: () => void, closed: () => void, failed: (error: unknown) => void): void {
  50. this.retention = new TerminalRetention(policy, () => this.handle.inspectActivity(), async () => {
  51. closing()
  52. await this.closeProcess()
  53. closed()
  54. }, failed)
  55. }
  56. /**
  57. * Retain this committed process independently of output attachment.
  58. * @param signal - physical window stream lifetime.
  59. * @returns its hold acknowledgement and lifetime.
  60. */
  61. retain(signal: AbortSignal): AsyncIterable<TerminalRetentionFrame> {
  62. if (this.retention === undefined) throw new Error('Terminal has not been committed')
  63. return this.retention.retain(signal)
  64. }
  65. /**
  66. * Attach with exclusive input control; an older attachment becomes read-only.
  67. * @param id - browser attachment identity.
  68. * @param signal - attachment cancellation; never terminates the process.
  69. * @returns a consistent screen followed by ordered output and state changes.
  70. */
  71. async *follow(id: TerminalAttachmentId, signal: AbortSignal): AsyncIterable<TerminalFrame> {
  72. signal.throwIfAborted()
  73. const follower = new TerminalFollower(this.maxBufferedBytes)
  74. const baseline = await this.enqueue(() => {
  75. signal.throwIfAborted()
  76. this.controller = { id, follower }
  77. this.info = { ...this.info, controllerId: id }
  78. this.broadcast({ type: 'state', info: this.info })
  79. const snapshot: TerminalFrame = { type: 'snapshot', sequence: this.sequence, screen: this.serializer.serialize(), info: this.info }
  80. this.followers.add(follower)
  81. return snapshot
  82. })
  83. try {
  84. yield baseline
  85. yield* follower.read(signal)
  86. } finally {
  87. this.followers.delete(follower)
  88. follower.close()
  89. if (this.controller?.follower === follower) {
  90. this.controller = undefined
  91. const { controllerId: _controllerId, ...info } = this.info
  92. this.info = info
  93. this.broadcast({ type: 'state', info })
  94. }
  95. }
  96. }
  97. /**
  98. * Send raw terminal input without command interpretation.
  99. * @param id - current writable attachment.
  100. * @param data - UTF-8 input, including shell completion/control keys.
  101. * @returns when the provider accepts the input.
  102. */
  103. write(id: TerminalAttachmentId, data: string): Promise<void> {
  104. this.retention?.invalidate()
  105. return this.enqueue(async () => { this.requireController(id); await this.handle.write(data) })
  106. }
  107. /**
  108. * Resize the PTY and recovery screen in the same operation order as output.
  109. * @param id - current writable attachment.
  110. * @param cols - validated column count.
  111. * @param rows - validated row count.
  112. * @returns when the provider and emulator use the new dimensions.
  113. */
  114. resize(id: TerminalAttachmentId, cols: number, rows: number): Promise<void> {
  115. return this.enqueue(async () => {
  116. this.requireController(id)
  117. await this.handle.resize(cols, rows)
  118. this.screen.resize(cols, rows)
  119. this.info = { ...this.info, cols, rows }
  120. this.broadcast({ type: 'state', info: this.info })
  121. })
  122. }
  123. /**
  124. * Publish a display name to every attached view.
  125. * @param title - validated user title.
  126. */
  127. rename(title: string): void {
  128. this.info = { ...this.info, title }
  129. this.broadcast({ type: 'state', info: this.info })
  130. }
  131. /**
  132. * Terminate the complete provider-owned process range before releasing its screen.
  133. * @returns after process cleanup and final output drainage; failures remain retryable.
  134. */
  135. close(): Promise<void> {
  136. return this.retention?.close() ?? this.closeProcess()
  137. }
  138. /**
  139. * Stop unattended cleanup scheduling and await final process cleanup.
  140. * @returns after terminal and monitor quiescence.
  141. */
  142. dispose(): Promise<void> { return this.retention?.dispose() ?? this.closeProcess() }
  143. private closeProcess(): Promise<void> {
  144. if (this.closing !== undefined) return this.closing
  145. this.closing = (async () => {
  146. await this.handle.terminate()
  147. await this.drained
  148. for (const follower of this.followers) follower.finish()
  149. this.followers.clear()
  150. this.screen.dispose()
  151. })().catch((error: unknown) => { this.closing = undefined; throw error })
  152. return this.closing
  153. }
  154. private requireController(id: TerminalAttachmentId): void {
  155. if (this.closing !== undefined || this.info.state !== 'running') throw new RemoteError('terminal/control-unavailable', 'Terminal is not running', { reason: 'not-running' })
  156. if (this.controller?.id !== id) throw new RemoteError('terminal/control-unavailable', 'Terminal input is controlled by another attachment', { reason: 'read-only' })
  157. }
  158. private broadcast(frame: TerminalFrame): void {
  159. for (const follower of this.followers) follower.push(frame)
  160. }
  161. private enqueue<T>(operation: () => T | Promise<T>): Promise<T> {
  162. const pending = this.operations.then(operation)
  163. this.operations = pending.catch(() => { /* The caller owns this operation's failure; later cleanup must still run. */ })
  164. return pending
  165. }
  166. private async consume(): Promise<void> {
  167. const decoder = new TextDecoder('utf-8', { ignoreBOM: true })
  168. const outcome = this.handle.done.then(value => ({ value }), (error: unknown) => ({ error }))
  169. try {
  170. for await (const chunk of this.handle.output) {
  171. // Node Readable's iterator is untyped; this provider explicitly emits Buffer chunks.
  172. const data = decoder.decode(chunk as Buffer, { stream: true })
  173. await this.output(data)
  174. }
  175. await this.output(decoder.decode())
  176. const result = await outcome
  177. if ('error' in result) throw result.error
  178. this.info = { ...this.info, state: 'exited', exitCode: result.value.exitCode }
  179. } catch (error) {
  180. this.info = { ...this.info, state: 'failed', error: error instanceof Error ? error.message : String(error) }
  181. }
  182. this.broadcast({ type: 'state', info: this.info })
  183. }
  184. private async output(data: string): Promise<void> {
  185. if (data.length === 0) return
  186. await this.enqueue(async () => {
  187. await new Promise<void>((resolve) => { this.screen.write(data, resolve) })
  188. this.broadcast({ type: 'output', sequence: ++this.sequence, data })
  189. })
  190. }
  191. }