process.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  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 SubprocessOutcome,
  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 - managed-range termination grace.
  40. * @returns the fully explicit shared subprocess request.
  41. */
  42. export function claudeSpawnSpec(
  43. options: SpawnOptions,
  44. graceMs: number,
  45. ): SubprocessSpawnSpec {
  46. if (options.cwd === undefined || options.cwd.length === 0) {
  47. throw new Error('subagent-claude-code: SDK spawn request omitted its workspace')
  48. }
  49. return {
  50. argv: [options.command, ...options.args],
  51. cwd: options.cwd,
  52. stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
  53. graceMs,
  54. signal: options.signal,
  55. env: sdkEnvironmentOverlay(options.env),
  56. }
  57. }
  58. /**
  59. * SDK-facing view of one shared managed process. Protocol transport remains
  60. * in the official SDK; this adapter only projects streams and exit events.
  61. */
  62. export class ManagedClaudeCodeProcess implements SpawnedProcess {
  63. readonly stdin
  64. readonly stdout
  65. private readonly events = new EventEmitter()
  66. private outcomeValue: SubprocessOutcome | undefined
  67. private killRequested = false
  68. /**
  69. * Project a managed process with piped stdin and stdout.
  70. * @param child - shared handle that remains the managed-range 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.outcomeValue = outcome
  82. this.events.emit('exit', outcome.exitCode, outcome.signal)
  83. },
  84. (error: unknown) => {
  85. this.events.emit('error', thrown(error))
  86. },
  87. )
  88. }
  89. /** Whether the SDK has requested managed-range termination. */
  90. get killed(): boolean {
  91. return this.killRequested
  92. }
  93. /** Direct-child exit code, or null while running or after signal exit. */
  94. get exitCode(): number | null {
  95. return this.outcomeValue?.exitCode ?? null
  96. }
  97. /** Direct-child terminating signal, if any. */
  98. get signalCode(): NodeJS.Signals | null {
  99. return this.outcomeValue?.signal ?? null
  100. }
  101. /** Exact managed-process outcome after exit, or undefined while running. */
  102. get outcome(): SubprocessOutcome | undefined {
  103. return this.outcomeValue
  104. }
  105. /**
  106. * Route the SDK's termination request to the managed-range process owner.
  107. * @param _signal - SDK-selected signal; the shared seam owns its escalation ladder.
  108. * @returns false only after exit or a previous termination request.
  109. */
  110. kill(_signal: NodeJS.Signals): boolean {
  111. if (
  112. this.killRequested
  113. || this.outcomeValue !== undefined
  114. ) {
  115. return false
  116. }
  117. this.killRequested = true
  118. this.child.terminate()
  119. return true
  120. }
  121. /** Register a persistent process lifecycle listener. */
  122. on(
  123. event: 'exit' | 'error',
  124. listener: ((code: number | null, signal: NodeJS.Signals | null) => void)
  125. | ((error: Error) => void),
  126. ): void {
  127. this.events.on(event, listener)
  128. }
  129. /** Register a one-shot process lifecycle listener. */
  130. once(
  131. event: 'exit' | 'error',
  132. listener: ((code: number | null, signal: NodeJS.Signals | null) => void)
  133. | ((error: Error) => void),
  134. ): void {
  135. this.events.once(event, listener)
  136. }
  137. /** Remove a process lifecycle listener. */
  138. off(
  139. event: 'exit' | 'error',
  140. listener: ((code: number | null, signal: NodeJS.Signals | null) => void)
  141. | ((error: Error) => void),
  142. ): void {
  143. this.events.off(event, listener)
  144. }
  145. }