process.ts 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. /**
  2. * Projection from the shared managed-process handle to the official Claude
  3. * Agent SDK's custom-spawn process interface.
  4. *
  5. * @module @deepseek-ai/dsh-subagent-claude-code/process
  6. */
  7. import { EventEmitter } from 'node:events'
  8. import { extname } from 'node:path'
  9. import type {
  10. SpawnedProcess,
  11. SpawnOptions,
  12. } from '@anthropic-ai/claude-agent-sdk'
  13. import {
  14. scrubbedParentEnv,
  15. type SubprocessHandle,
  16. type SubprocessSpawnSpec,
  17. } from '@deepseek-ai/dsh-subprocess'
  18. function thrown(value: unknown): Error {
  19. /* v8 ignore next -- the subprocess seam rejects with Error. */
  20. return value instanceof Error ? value : new Error(String(value))
  21. }
  22. /**
  23. * Encode the SDK's complete child environment as a subprocess overlay.
  24. * @param env - SDK-composed child environment after its removals and replacements.
  25. * @returns explicit values plus tombstones for surviving ambient names the SDK removed.
  26. */
  27. export function sdkEnvironmentOverlay(
  28. env: SpawnOptions['env'],
  29. ): NodeJS.ProcessEnv {
  30. const overlay: NodeJS.ProcessEnv = { ...env }
  31. for (const name of Object.keys(scrubbedParentEnv())) {
  32. if (!(name in env)) overlay[name] = undefined
  33. }
  34. return overlay
  35. }
  36. /**
  37. * Translate one official SDK spawn request to the shared process owner.
  38. * @param options - command, arguments, workspace, environment, and forwarded signal from the SDK.
  39. * @param graceMs - process-tree termination grace.
  40. * @param platform - host platform selecting the Windows batch-shim boundary.
  41. * @returns the fully explicit shared subprocess request.
  42. */
  43. export function claudeSpawnSpec(
  44. options: SpawnOptions,
  45. graceMs: number,
  46. platform: NodeJS.Platform = process.platform,
  47. ): SubprocessSpawnSpec {
  48. if (options.cwd === undefined || options.cwd.length === 0) {
  49. throw new Error('subagent-claude-code: SDK spawn request omitted its workspace')
  50. }
  51. const extension = extname(options.command).toLowerCase()
  52. const argv = platform === 'win32' && (extension === '.cmd' || extension === '.bat')
  53. ? ['cmd.exe', '/d', '/s', '/c', options.command, ...options.args]
  54. : [options.command, ...options.args]
  55. return {
  56. argv,
  57. cwd: options.cwd,
  58. stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
  59. graceMs,
  60. signal: options.signal,
  61. env: sdkEnvironmentOverlay(options.env),
  62. }
  63. }
  64. /**
  65. * SDK-facing view of one shared managed process. Protocol transport remains
  66. * in the official SDK; this adapter only projects streams and exit events.
  67. */
  68. export class ManagedClaudeCodeProcess implements SpawnedProcess {
  69. readonly stdin
  70. readonly stdout
  71. private readonly events = new EventEmitter()
  72. private exitCodeValue: number | null = null
  73. private signalCodeValue: NodeJS.Signals | null = null
  74. private killRequested = false
  75. /**
  76. * Project a managed process with piped stdin and stdout.
  77. * @param child - shared handle that remains the process-tree authority.
  78. */
  79. constructor(private readonly child: SubprocessHandle) {
  80. this.stdin = child.stdin as NonNullable<SubprocessHandle['stdin']>
  81. this.stdout = child.stdout as NonNullable<SubprocessHandle['stdout']>
  82. // EventEmitter gives `error` special throw semantics without a listener.
  83. // The SDK attaches its listener synchronously after custom spawn returns,
  84. // while this no-op also contains an already-rejected spawn handle.
  85. this.events.on('error', () => {})
  86. void child.done.then(
  87. (outcome) => {
  88. this.exitCodeValue = outcome.exitCode
  89. this.signalCodeValue = outcome.signal
  90. this.events.emit('exit', outcome.exitCode, outcome.signal)
  91. },
  92. (error: unknown) => {
  93. this.events.emit('error', thrown(error))
  94. },
  95. )
  96. }
  97. /** Whether the SDK has requested managed tree termination. */
  98. get killed(): boolean {
  99. return this.killRequested
  100. }
  101. /** Direct-child exit code, or null while running or after signal exit. */
  102. get exitCode(): number | null {
  103. return this.exitCodeValue
  104. }
  105. /** Direct-child terminating signal, if any. */
  106. get signalCode(): NodeJS.Signals | null {
  107. return this.signalCodeValue
  108. }
  109. /**
  110. * Route the SDK's termination request to the tree-scoped process owner.
  111. * @param _signal - SDK-selected signal; the shared seam owns its escalation ladder.
  112. * @returns false only after exit or a previous termination request.
  113. */
  114. kill(_signal: NodeJS.Signals): boolean {
  115. if (
  116. this.killRequested
  117. || this.exitCodeValue !== null
  118. || this.signalCodeValue !== null
  119. ) {
  120. return false
  121. }
  122. this.killRequested = true
  123. this.child.terminate()
  124. return true
  125. }
  126. /** Register a persistent process lifecycle listener. */
  127. on(
  128. event: 'exit' | 'error',
  129. listener: ((code: number | null, signal: NodeJS.Signals | null) => void)
  130. | ((error: Error) => void),
  131. ): void {
  132. this.events.on(event, listener)
  133. }
  134. /** Register a one-shot process lifecycle listener. */
  135. once(
  136. event: 'exit' | 'error',
  137. listener: ((code: number | null, signal: NodeJS.Signals | null) => void)
  138. | ((error: Error) => void),
  139. ): void {
  140. this.events.once(event, listener)
  141. }
  142. /** Remove a process lifecycle listener. */
  143. off(
  144. event: 'exit' | 'error',
  145. listener: ((code: number | null, signal: NodeJS.Signals | null) => void)
  146. | ((error: Error) => void),
  147. ): void {
  148. this.events.off(event, listener)
  149. }
  150. }