process.ts 5.4 KB

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