run.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. /**
  2. * One-shot Claude Code lifecycle: invoke the official Agent SDK, place its
  3. * real CLI process under the shared subprocess owner, map only strict SDK
  4. * success to completion, and dispose to whole-tree quiescence.
  5. *
  6. * @module @deepseek-ai/dsh-subagent-claude-code/run
  7. */
  8. import { randomUUID } from 'node:crypto'
  9. import {
  10. query as officialQuery,
  11. type Options,
  12. type Query,
  13. type SDKMessage,
  14. type SDKResultMessage,
  15. type SpawnOptions,
  16. } from '@anthropic-ai/claude-agent-sdk'
  17. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  18. import { SessionId } from '@deepseek-ai/dsh-session'
  19. import {
  20. settleRunResult,
  21. subprocessRunHandle,
  22. type SubagentResult,
  23. type SubagentRun,
  24. type SubagentStartRequest,
  25. type SubagentStopReason,
  26. } from '@deepseek-ai/dsh-subagent'
  27. import {
  28. scrubbedParentEnv,
  29. type SubprocessHandle,
  30. type SubprocessSpawnSpec,
  31. } from '@deepseek-ai/dsh-subprocess'
  32. import {
  33. claudeSpawnSpec,
  34. ManagedClaudeCodeProcess,
  35. } from './process.ts'
  36. /** Default POSIX grace between subprocess termination tiers. */
  37. export const DEFAULT_DISPOSE_GRACE_MS = 3_000
  38. /** Claude Code permission modes that cannot wait for a human response. */
  39. export const CLAUDE_CODE_PERMISSION_MODES = [
  40. 'dontAsk',
  41. 'acceptEdits',
  42. 'auto',
  43. 'plan',
  44. 'bypassPermissions',
  45. ] as const satisfies readonly NonNullable<Options['permissionMode']>[]
  46. /** Profile-selectable non-interactive Claude Code permission mode. */
  47. export type ClaudeCodePermissionMode = typeof CLAUDE_CODE_PERMISSION_MODES[number]
  48. /** Safe default for unattended Claude Code runs. */
  49. export const DEFAULT_CLAUDE_CODE_PERMISSION_MODE: ClaudeCodePermissionMode = 'dontAsk'
  50. const SUPPORTED_UNATTENDED_DIALOG_KINDS = [
  51. 'refusal_fallback_prompt',
  52. ] satisfies NonNullable<Options['supportedDialogKinds']>
  53. function unattendedDiagnostic(
  54. mode: ClaudeCodePermissionMode,
  55. request: 'tool permission' | 'MCP elicitation' | 'user dialog',
  56. decision: 'denied' | 'declined' | 'cancelled',
  57. reason: string,
  58. ): string {
  59. return `Claude Code unattended decision (mode: ${mode}; request: ${request}; decision: ${decision}): ${reason}`
  60. }
  61. /* jscpd:ignore-start -- sibling providers intentionally keep product-private
  62. * run inputs and error normalization instead of adding a shared lifecycle owner. */
  63. /** Fully resolved inputs for one official Claude Agent SDK query. */
  64. export interface ClaudeCodeRunSpec {
  65. /** Parent Session workspace supplied to the SDK and real CLI. */
  66. readonly cwd: string
  67. /** Profile-selected native non-interactive permission mode. */
  68. readonly permissionMode: ClaudeCodePermissionMode
  69. /** Explicit deployment/test environment layered after shared scrubbing. */
  70. readonly env: Record<string, string>
  71. /** Subprocess termination grace passed to the shared process-tree owner. */
  72. readonly disposeGraceMs: number
  73. /** Shared subprocess service spawn operation. */
  74. readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
  75. /** Diagnostic sink for a post-publication error flattened into a result. */
  76. readonly onError?: (error: Error, stopReason: SubagentStopReason) => void
  77. }
  78. function thrown(value: unknown): Error {
  79. /* v8 ignore next -- typed SDK and subprocess failures reject with Error. */
  80. return value instanceof Error ? value : new Error(String(value))
  81. }
  82. /** Read live request cancellation across awaited startup cleanup. */
  83. function isAborted(signal: AbortSignal): boolean {
  84. return signal.aborted
  85. }
  86. /* jscpd:ignore-end */
  87. /**
  88. * Validate and preserve the one-shot task before crossing the SDK boundary.
  89. * @param prompt - task content accepted from the shared subagent service.
  90. * @returns the exact text sequence as one SDK prompt.
  91. */
  92. export function textTask(prompt: readonly ContentBlock[]): string {
  93. if (prompt.length === 0) {
  94. throw new Error('subagent-claude-code: the one-shot task must contain only text blocks')
  95. }
  96. const texts: string[] = []
  97. for (const block of prompt) {
  98. if (block.type !== 'text') {
  99. throw new Error('subagent-claude-code: the one-shot task must contain only text blocks')
  100. }
  101. texts.push(block.text)
  102. }
  103. if (texts.every(text => text.trim().length === 0)) {
  104. throw new Error('subagent-claude-code: the one-shot task must not be empty')
  105. }
  106. return texts.join('')
  107. }
  108. /**
  109. * Strictly derive the only SDK result that can complete a shared run.
  110. * @param message - an official discriminated result union.
  111. * @returns exact final text for a successful, non-error result.
  112. */
  113. export function successfulResult(message: SDKResultMessage): string {
  114. if (
  115. message.subtype !== 'success'
  116. || message.is_error
  117. || message.result.trim().length === 0
  118. ) {
  119. const detail = message.subtype === 'success'
  120. ? 'success result was marked as an error or contained no answer'
  121. : message.errors.join('; ') || message.subtype
  122. throw new Error(`subagent-claude-code: Claude Code failed: ${detail}`)
  123. }
  124. return message.result
  125. }
  126. /**
  127. * Consume the complete SDK stream and require one strict success plus normal
  128. * iterator completion.
  129. * @param query - published official SDK query.
  130. * @param onPermissionDenied - records a safe fact when the SDK reports native denial.
  131. * @returns the completed shared result.
  132. */
  133. export async function consumeClaudeQuery(
  134. query: AsyncIterable<SDKMessage>,
  135. onPermissionDenied?: () => void,
  136. ): Promise<SubagentResult> {
  137. let answer: string | undefined
  138. for await (const message of query) {
  139. if (message.type === 'system' && message.subtype === 'permission_denied') {
  140. onPermissionDenied?.()
  141. continue
  142. }
  143. if (message.type !== 'result') continue
  144. answer = successfulResult(message)
  145. }
  146. if (answer === undefined) {
  147. throw new Error('subagent-claude-code: Claude Code ended without a result')
  148. }
  149. return {
  150. output: [{ type: 'text', text: answer }],
  151. stopReason: 'completed',
  152. }
  153. }
  154. /**
  155. * Close the official query, terminate the managed process tree, and wait for
  156. * the subprocess owner to prove it is gone.
  157. * @param query - official SDK query, when creation reached that point.
  158. * @param child - shared-service handle that owns the CLI process tree.
  159. */
  160. export async function disposeClaudeCodeChild(
  161. query: Pick<Query, 'close'> | undefined,
  162. child: SubprocessHandle,
  163. ): Promise<void> {
  164. const failures: Error[] = []
  165. try {
  166. query?.close()
  167. } catch (error: unknown) {
  168. failures.push(thrown(error))
  169. }
  170. if (child.pid > 0) {
  171. child.terminate()
  172. try {
  173. await child.waitForExit()
  174. } catch (error: unknown) {
  175. failures.push(thrown(error))
  176. }
  177. }
  178. try {
  179. await child.done
  180. } catch (error: unknown) {
  181. failures.push(thrown(error))
  182. }
  183. const firstFailure = failures[0]
  184. if (failures.length === 1 && firstFailure !== undefined) throw firstFailure
  185. if (failures.length > 1) {
  186. throw new AggregateError(
  187. failures,
  188. 'subagent-claude-code: query and process cleanup failed',
  189. )
  190. }
  191. }
  192. /**
  193. * Build the fixed official SDK options for one one-shot provider run.
  194. * @param spec - Workspace, environment, process service, and disposal policy.
  195. * @param controller - per-run cancellation owner.
  196. * @param capture - receives the real managed child synchronously from the SDK hook.
  197. * @param captureDiagnostic - receives safe facts from unattended interaction callbacks.
  198. * @returns options that inherit native settings while disabling persistence and user questions.
  199. */
  200. export function claudeQueryOptions(
  201. spec: ClaudeCodeRunSpec,
  202. controller: AbortController,
  203. capture: (child: SubprocessHandle) => void,
  204. captureDiagnostic: (diagnostic: string) => void,
  205. ): Options {
  206. return {
  207. abortController: controller,
  208. cwd: spec.cwd,
  209. env: { ...scrubbedParentEnv(), ...spec.env },
  210. persistSession: false,
  211. disallowedTools: spec.permissionMode === 'plan'
  212. ? ['AskUserQuestion', 'ExitPlanMode']
  213. : ['AskUserQuestion'],
  214. permissionMode: spec.permissionMode,
  215. ...spec.permissionMode === 'bypassPermissions'
  216. ? { allowDangerouslySkipPermissions: true }
  217. : {
  218. canUseTool: () => {
  219. captureDiagnostic(unattendedDiagnostic(
  220. spec.permissionMode,
  221. 'tool permission',
  222. 'denied',
  223. 'the provider does not request human approval',
  224. ))
  225. return Promise.resolve({
  226. behavior: 'deny' as const,
  227. message: 'This unattended Claude Code subagent cannot request human approval.',
  228. })
  229. },
  230. },
  231. onElicitation: () => {
  232. captureDiagnostic(unattendedDiagnostic(
  233. spec.permissionMode,
  234. 'MCP elicitation',
  235. 'declined',
  236. 'the provider does not collect interactive MCP input',
  237. ))
  238. return Promise.resolve({ action: 'decline' })
  239. },
  240. onUserDialog: () => {
  241. captureDiagnostic(unattendedDiagnostic(
  242. spec.permissionMode,
  243. 'user dialog',
  244. 'cancelled',
  245. 'the provider does not render blocking dialogs',
  246. ))
  247. return Promise.resolve({ behavior: 'cancelled' as const })
  248. },
  249. supportedDialogKinds: SUPPORTED_UNATTENDED_DIALOG_KINDS,
  250. spawnClaudeCodeProcess: (options: SpawnOptions) => {
  251. const child = spec.spawn(claudeSpawnSpec(options, spec.disposeGraceMs))
  252. capture(child)
  253. return new ManagedClaudeCodeProcess(child)
  254. },
  255. }
  256. }
  257. /**
  258. * Start one official Claude Agent SDK query and publish its one-shot run.
  259. * @param request - resolved shared subagent request.
  260. * @param spec - Workspace, environment, process service, and diagnostic policy.
  261. * @returns the published run after both Query and real CLI handle exist.
  262. */
  263. export async function startClaudeCodeRun(
  264. request: SubagentStartRequest,
  265. spec: ClaudeCodeRunSpec,
  266. ): Promise<SubagentRun> {
  267. const prompt = textTask(request.prompt)
  268. if (request.signal.aborted) {
  269. throw new Error('subagent-claude-code: request was aborted before SDK startup')
  270. }
  271. const controller = new AbortController()
  272. const requestCancel = (): void => {
  273. if (!controller.signal.aborted) {
  274. controller.abort(new Error('subagent-claude-code: run cancelled locally'))
  275. }
  276. }
  277. const onAbort = (): void => { requestCancel() }
  278. request.signal.addEventListener('abort', onAbort, { once: true })
  279. let child: SubprocessHandle | undefined
  280. let query: Query | undefined
  281. let diagnostic: string | undefined
  282. const captureDiagnostic = (value: string): void => {
  283. diagnostic = value
  284. }
  285. try {
  286. query = officialQuery({
  287. prompt,
  288. options: claudeQueryOptions(
  289. spec,
  290. controller,
  291. (captured) => {
  292. child = captured
  293. },
  294. captureDiagnostic,
  295. ),
  296. })
  297. if (child === undefined || child.pid <= 0) {
  298. throw new Error(
  299. 'subagent-claude-code: official SDK did not publish a controllable Claude Code process',
  300. )
  301. }
  302. if (controller.signal.aborted) {
  303. throw new Error('subagent-claude-code: request was aborted before SDK startup')
  304. }
  305. } catch (error: unknown) {
  306. request.signal.removeEventListener('abort', onAbort)
  307. const cancelledBeforeCleanup = controller.signal.aborted
  308. requestCancel()
  309. const startupError = thrown(error)
  310. if (child !== undefined && child.pid <= 0) {
  311. let closeError: Error | undefined
  312. try {
  313. query?.close()
  314. } catch (disposeError: unknown) {
  315. closeError = thrown(disposeError)
  316. }
  317. let spawnError = startupError
  318. try {
  319. await child.done
  320. } catch (childError: unknown) {
  321. spawnError = thrown(childError)
  322. }
  323. const cancelled = cancelledBeforeCleanup || isAborted(request.signal)
  324. if (closeError !== undefined) {
  325. const failures = cancelled
  326. ? [
  327. new Error('subagent-claude-code: request was aborted before SDK startup'),
  328. spawnError,
  329. closeError,
  330. ]
  331. : [spawnError, closeError]
  332. throw new AggregateError(
  333. failures,
  334. cancelled
  335. ? `subagent-claude-code: request was aborted before SDK startup; Claude Code process startup also failed: ${spawnError.message}; query cleanup also failed`
  336. : `subagent-claude-code: Claude Code process startup failed: ${spawnError.message}; query cleanup also failed`,
  337. )
  338. }
  339. if (cancelled) {
  340. throw new Error('subagent-claude-code: request was aborted before SDK startup')
  341. }
  342. throw spawnError
  343. }
  344. if (child !== undefined) {
  345. try {
  346. await disposeClaudeCodeChild(query, child)
  347. } catch (disposeError: unknown) {
  348. throw new AggregateError(
  349. [startupError, thrown(disposeError)],
  350. 'subagent-claude-code: startup failed and CLI cleanup also failed',
  351. )
  352. }
  353. } else if (query !== undefined) {
  354. try {
  355. query.close()
  356. } catch (disposeError: unknown) {
  357. throw new AggregateError(
  358. [startupError, thrown(disposeError)],
  359. 'subagent-claude-code: startup failed and query cleanup also failed',
  360. )
  361. }
  362. }
  363. if (cancelledBeforeCleanup || isAborted(request.signal)) {
  364. throw new Error('subagent-claude-code: request was aborted before SDK startup')
  365. }
  366. throw startupError
  367. }
  368. const publishedQuery = query
  369. const publishedChild = child
  370. const result = settleRunResult({
  371. attempt: () => consumeClaudeQuery(publishedQuery, () => {
  372. captureDiagnostic(unattendedDiagnostic(
  373. spec.permissionMode,
  374. 'tool permission',
  375. 'denied',
  376. 'Claude Code denied the request before an interactive prompt',
  377. ))
  378. }),
  379. collectOutput: () => [],
  380. collectDiagnostic: () => diagnostic,
  381. cancelled: () => controller.signal.aborted,
  382. onError: spec.onError,
  383. signal: request.signal,
  384. onAbort,
  385. })
  386. return subprocessRunHandle({
  387. id: SessionId(randomUUID()),
  388. result,
  389. signal: request.signal,
  390. onAbort,
  391. requestCancel,
  392. teardown: () => disposeClaudeCodeChild(
  393. publishedQuery,
  394. publishedChild,
  395. ),
  396. })
  397. }