process.ts 4.9 KB

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