run.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599
  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 SubprocessOutcome,
  31. type SubprocessSpawnSpec,
  32. } from '@deepseek-ai/dsh-subprocess'
  33. import {
  34. claudeSpawnSpec,
  35. ManagedClaudeCodeProcess,
  36. } from './process.ts'
  37. /** Default POSIX grace between subprocess termination tiers. */
  38. export const DEFAULT_DISPOSE_GRACE_MS = 3_000
  39. /** Claude Code permission modes that cannot wait for a human response. */
  40. export const CLAUDE_CODE_PERMISSION_MODES = [
  41. 'dontAsk',
  42. 'acceptEdits',
  43. 'auto',
  44. 'plan',
  45. 'bypassPermissions',
  46. ] as const satisfies readonly NonNullable<Options['permissionMode']>[]
  47. /** Profile-selectable non-interactive Claude Code permission mode. */
  48. export type ClaudeCodePermissionMode = typeof CLAUDE_CODE_PERMISSION_MODES[number]
  49. /** Safe default for unattended Claude Code runs. */
  50. export const DEFAULT_CLAUDE_CODE_PERMISSION_MODE: ClaudeCodePermissionMode = 'dontAsk'
  51. const SUPPORTED_UNATTENDED_DIALOG_KINDS = [
  52. 'refusal_fallback_prompt',
  53. ] satisfies NonNullable<Options['supportedDialogKinds']>
  54. type ClaudeCodeErrorSubtype = Exclude<SDKResultMessage['subtype'], 'success'>
  55. type ClaudeCodeFailureStage =
  56. | 'query-start'
  57. | 'query-run'
  58. | 'process'
  59. | 'teardown'
  60. type ClaudeCodeFailureCategory =
  61. | ClaudeCodeErrorSubtype
  62. | 'invalid-success'
  63. | 'missing-result'
  64. | 'process-exit'
  65. | 'unknown'
  66. interface ClaudeCodeFailureFacts {
  67. readonly stage: ClaudeCodeFailureStage
  68. readonly category: ClaudeCodeFailureCategory
  69. readonly outcome?: SubprocessOutcome | undefined
  70. }
  71. function failureDiagnostic(facts: ClaudeCodeFailureFacts): string {
  72. const fields = [
  73. 'product: Claude Code',
  74. `stage: ${facts.stage}`,
  75. `category: ${facts.category}`,
  76. ]
  77. const exitCode = facts.outcome?.exitCode
  78. if (exitCode !== null && exitCode !== undefined) {
  79. fields.push(`exit code: ${exitCode}`)
  80. }
  81. const signal = facts.outcome?.signal
  82. if (signal !== null && signal !== undefined) {
  83. fields.push(`signal: ${signal}`)
  84. }
  85. return `Product subagent failure (${fields.join('; ')})`
  86. }
  87. class ClaudeCodeFailure extends Error {
  88. constructor(
  89. readonly facts: ClaudeCodeFailureFacts,
  90. cause?: unknown,
  91. ) {
  92. super(
  93. `subagent-claude-code: ${failureDiagnostic(facts)}`,
  94. cause === undefined ? undefined : { cause },
  95. )
  96. this.name = 'ClaudeCodeFailure'
  97. }
  98. }
  99. function sdkFailureCategory(
  100. subtype: string,
  101. ): ClaudeCodeErrorSubtype | 'unknown' {
  102. switch (subtype) {
  103. case 'error_during_execution':
  104. case 'error_max_turns':
  105. case 'error_max_budget_usd':
  106. case 'error_max_structured_output_retries':
  107. return subtype
  108. default:
  109. return 'unknown'
  110. }
  111. }
  112. /**
  113. * Hide an unpublished product startup failure behind fixed safe facts.
  114. * @param cause - original host-side failure retained only on the Error cause chain.
  115. * @returns a rejection safe to expose through the subagent start boundary.
  116. */
  117. export function claudeCodeStartupFailure(cause: unknown): Error {
  118. return new ClaudeCodeFailure({
  119. stage: 'query-start',
  120. category: 'unknown',
  121. }, cause)
  122. }
  123. function unattendedDiagnostic(
  124. mode: ClaudeCodePermissionMode,
  125. request: 'tool permission' | 'MCP elicitation' | 'user dialog',
  126. decision: 'denied' | 'declined' | 'cancelled',
  127. reason: string,
  128. ): string {
  129. return `Claude Code unattended decision (mode: ${mode}; request: ${request}; decision: ${decision}): ${reason}`
  130. }
  131. /* jscpd:ignore-start -- sibling providers intentionally keep product-private
  132. * run inputs and error normalization instead of adding a shared lifecycle owner. */
  133. /** Fully resolved inputs for one official Claude Agent SDK query. */
  134. export interface ClaudeCodeRunSpec {
  135. /** Parent Session workspace supplied to the SDK and real CLI. */
  136. readonly cwd: string
  137. /** Profile-selected native non-interactive permission mode. */
  138. readonly permissionMode: ClaudeCodePermissionMode
  139. /** Explicit deployment/test environment layered after shared scrubbing. */
  140. readonly env: Record<string, string>
  141. /** Subprocess termination grace passed to the shared process-tree owner. */
  142. readonly disposeGraceMs: number
  143. /** Shared subprocess service spawn operation. */
  144. readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
  145. /** Host diagnostic sink for a product failure kept outside model-visible text. */
  146. readonly onError?: (error: Error, stopReason: SubagentStopReason) => void
  147. }
  148. function thrown(value: unknown): Error {
  149. /* v8 ignore next -- typed SDK and subprocess failures reject with Error. */
  150. return value instanceof Error ? value : new Error(String(value))
  151. }
  152. /** Read live request cancellation across awaited startup cleanup. */
  153. function isAborted(signal: AbortSignal): boolean {
  154. return signal.aborted
  155. }
  156. /* jscpd:ignore-end */
  157. /**
  158. * Validate and preserve the one-shot task before crossing the SDK boundary.
  159. * @param prompt - task content accepted from the shared subagent service.
  160. * @returns the exact text sequence as one SDK prompt.
  161. */
  162. export function textTask(prompt: readonly ContentBlock[]): string {
  163. if (prompt.length === 0) {
  164. throw new Error('subagent-claude-code: the one-shot task must contain only text blocks')
  165. }
  166. const texts: string[] = []
  167. for (const block of prompt) {
  168. if (block.type !== 'text') {
  169. throw new Error('subagent-claude-code: the one-shot task must contain only text blocks')
  170. }
  171. texts.push(block.text)
  172. }
  173. if (texts.every(text => text.trim().length === 0)) {
  174. throw new Error('subagent-claude-code: the one-shot task must not be empty')
  175. }
  176. return texts.join('')
  177. }
  178. /**
  179. * Strictly derive the only SDK result that can complete a shared run.
  180. * @param message - an official discriminated result union.
  181. * @returns exact final text for a successful, non-error result.
  182. */
  183. export function successfulResult(message: SDKResultMessage): string {
  184. if (message.subtype !== 'success') {
  185. const category = sdkFailureCategory(message.subtype)
  186. const detail = category === 'unknown'
  187. ? undefined
  188. : message.errors.join('; ')
  189. throw new ClaudeCodeFailure(
  190. { stage: 'query-run', category },
  191. detail === undefined || detail.length === 0
  192. ? undefined
  193. : new Error(detail),
  194. )
  195. }
  196. if (message.is_error || message.result.trim().length === 0) {
  197. throw new ClaudeCodeFailure({
  198. stage: 'query-run',
  199. category: 'invalid-success',
  200. })
  201. }
  202. return message.result
  203. }
  204. /**
  205. * Consume the complete SDK stream and require one strict success plus normal
  206. * iterator completion.
  207. * @param query - published official SDK query.
  208. * @param onPermissionDenied - records a safe fact when the SDK reports native denial.
  209. * @param onResult - records that the SDK supplied a terminal result message.
  210. * @returns the completed shared result.
  211. */
  212. export async function consumeClaudeQuery(
  213. query: AsyncIterable<SDKMessage>,
  214. onPermissionDenied?: () => void,
  215. onResult?: () => void,
  216. ): Promise<SubagentResult> {
  217. let answer: string | undefined
  218. for await (const message of query) {
  219. if (message.type === 'system' && message.subtype === 'permission_denied') {
  220. onPermissionDenied?.()
  221. continue
  222. }
  223. if (message.type !== 'result') continue
  224. onResult?.()
  225. answer = successfulResult(message)
  226. }
  227. if (answer === undefined) {
  228. throw new ClaudeCodeFailure({
  229. stage: 'query-run',
  230. category: 'missing-result',
  231. })
  232. }
  233. return {
  234. output: [{ type: 'text', text: answer }],
  235. stopReason: 'completed',
  236. }
  237. }
  238. /**
  239. * Close the official query, terminate the managed process tree, and wait for
  240. * the subprocess owner to prove it is gone.
  241. * @param query - official SDK query, when creation reached that point.
  242. * @param child - live shared-service handle that owns the CLI process tree;
  243. * spawn-failed handles settle at the startup boundary instead.
  244. */
  245. export async function disposeClaudeCodeChild(
  246. query: Pick<Query, 'close'> | undefined,
  247. child: SubprocessHandle,
  248. ): Promise<void> {
  249. const failures: Error[] = []
  250. let outcome: SubprocessOutcome | undefined
  251. try {
  252. query?.close()
  253. } catch (error: unknown) {
  254. failures.push(thrown(error))
  255. }
  256. child.terminate()
  257. try {
  258. await child.waitForExit()
  259. } catch (error: unknown) {
  260. failures.push(thrown(error))
  261. }
  262. try {
  263. outcome = await child.done
  264. } catch (error: unknown) {
  265. failures.push(thrown(error))
  266. }
  267. const firstFailure = failures[0]
  268. if (firstFailure !== undefined) {
  269. const facts = {
  270. stage: 'teardown',
  271. category: 'unknown',
  272. outcome,
  273. } as const
  274. const cause = failures.length === 1
  275. ? firstFailure
  276. : new AggregateError(failures, 'Claude Code teardown failures')
  277. throw new ClaudeCodeFailure(facts, cause)
  278. }
  279. }
  280. /**
  281. * Build the fixed official SDK options for one one-shot provider run.
  282. * @param spec - Workspace, environment, process service, and disposal policy.
  283. * @param controller - per-run cancellation owner.
  284. * @param capture - receives the shared child and SDK-facing process synchronously.
  285. * @param captureDiagnostic - receives safe facts from unattended interaction callbacks.
  286. * @returns options that inherit native settings while disabling persistence and user questions.
  287. */
  288. export function claudeQueryOptions(
  289. spec: ClaudeCodeRunSpec,
  290. controller: AbortController,
  291. capture: (
  292. child: SubprocessHandle,
  293. process: ManagedClaudeCodeProcess,
  294. ) => void,
  295. captureDiagnostic: (diagnostic: string) => void,
  296. ): Options {
  297. return {
  298. abortController: controller,
  299. cwd: spec.cwd,
  300. env: { ...scrubbedParentEnv(), ...spec.env },
  301. persistSession: false,
  302. disallowedTools: spec.permissionMode === 'plan'
  303. ? ['AskUserQuestion', 'ExitPlanMode']
  304. : ['AskUserQuestion'],
  305. permissionMode: spec.permissionMode,
  306. ...spec.permissionMode === 'bypassPermissions'
  307. ? { allowDangerouslySkipPermissions: true }
  308. : {
  309. canUseTool: () => {
  310. captureDiagnostic(unattendedDiagnostic(
  311. spec.permissionMode,
  312. 'tool permission',
  313. 'denied',
  314. 'the provider does not request human approval',
  315. ))
  316. return Promise.resolve({
  317. behavior: 'deny' as const,
  318. message: 'This unattended Claude Code subagent cannot request human approval.',
  319. })
  320. },
  321. },
  322. onElicitation: () => {
  323. captureDiagnostic(unattendedDiagnostic(
  324. spec.permissionMode,
  325. 'MCP elicitation',
  326. 'declined',
  327. 'the provider does not collect interactive MCP input',
  328. ))
  329. return Promise.resolve({ action: 'decline' })
  330. },
  331. onUserDialog: () => {
  332. captureDiagnostic(unattendedDiagnostic(
  333. spec.permissionMode,
  334. 'user dialog',
  335. 'cancelled',
  336. 'the provider does not render blocking dialogs',
  337. ))
  338. return Promise.resolve({ behavior: 'cancelled' as const })
  339. },
  340. supportedDialogKinds: SUPPORTED_UNATTENDED_DIALOG_KINDS,
  341. spawnClaudeCodeProcess: (options: SpawnOptions) => {
  342. const child = spec.spawn(claudeSpawnSpec(options, spec.disposeGraceMs))
  343. const process = new ManagedClaudeCodeProcess(child)
  344. capture(child, process)
  345. return process
  346. },
  347. }
  348. }
  349. /**
  350. * Start one official Claude Agent SDK query and publish its one-shot run.
  351. * @param request - resolved shared subagent request.
  352. * @param spec - Workspace, environment, process service, and diagnostic policy.
  353. * @returns the published run after both Query and real CLI handle exist.
  354. */
  355. export async function startClaudeCodeRun(
  356. request: SubagentStartRequest,
  357. spec: ClaudeCodeRunSpec,
  358. ): Promise<SubagentRun> {
  359. const prompt = textTask(request.prompt)
  360. if (request.signal.aborted) {
  361. throw new Error('subagent-claude-code: request was aborted before SDK startup')
  362. }
  363. const controller = new AbortController()
  364. const requestCancel = (): void => {
  365. if (!controller.signal.aborted) {
  366. controller.abort(new Error('subagent-claude-code: run cancelled locally'))
  367. }
  368. }
  369. const onAbort = (): void => { requestCancel() }
  370. request.signal.addEventListener('abort', onAbort, { once: true })
  371. const reportFailure = (error: Error): void => {
  372. try {
  373. spec.onError?.(error, 'error')
  374. } catch {
  375. // Host diagnostic logging cannot replace the product failure.
  376. }
  377. }
  378. let child: SubprocessHandle | undefined
  379. let query: Query | undefined
  380. let managedProcess: ManagedClaudeCodeProcess | undefined
  381. let diagnostic: string | undefined
  382. const capturePermissionDiagnostic = (value: string): void => {
  383. diagnostic = value
  384. }
  385. const prependFailureDiagnostic = (facts: ClaudeCodeFailureFacts): void => {
  386. const failure = failureDiagnostic(facts)
  387. diagnostic = diagnostic === undefined
  388. ? failure
  389. : `${failure}\n${diagnostic}`
  390. }
  391. const captureChild = (
  392. captured: SubprocessHandle,
  393. process: ManagedClaudeCodeProcess,
  394. ): void => {
  395. child = captured
  396. managedProcess = process
  397. }
  398. try {
  399. query = officialQuery({
  400. prompt,
  401. options: claudeQueryOptions(
  402. spec,
  403. controller,
  404. captureChild,
  405. capturePermissionDiagnostic,
  406. ),
  407. })
  408. if (child === undefined || child.pid <= 0) {
  409. throw new Error(
  410. 'subagent-claude-code: official SDK did not publish a controllable Claude Code process',
  411. )
  412. }
  413. if (controller.signal.aborted) {
  414. throw new Error('subagent-claude-code: request was aborted before SDK startup')
  415. }
  416. } catch (error: unknown) {
  417. request.signal.removeEventListener('abort', onAbort)
  418. const cancelledBeforeCleanup = controller.signal.aborted
  419. // Let child.done publish a concurrently observed exit before classification.
  420. await Promise.resolve()
  421. const startupOutcome = managedProcess?.outcome
  422. const startupFacts = {
  423. stage: 'query-start',
  424. category: 'unknown',
  425. outcome: startupOutcome,
  426. } as const
  427. const startupFailure = (cause: unknown = error): ClaudeCodeFailure => new ClaudeCodeFailure(
  428. startupFacts,
  429. thrown(cause),
  430. )
  431. requestCancel()
  432. if (child !== undefined && child.pid <= 0) {
  433. let closeError: Error | undefined
  434. try {
  435. query?.close()
  436. } catch (disposeError: unknown) {
  437. closeError = thrown(disposeError)
  438. }
  439. let spawnError = thrown(error)
  440. try {
  441. await child.done
  442. } catch (childError: unknown) {
  443. spawnError = thrown(childError)
  444. }
  445. if (closeError !== undefined) {
  446. const failure = startupFailure(spawnError)
  447. const cleanupFailure = new ClaudeCodeFailure({
  448. stage: 'teardown',
  449. category: 'unknown',
  450. }, closeError)
  451. const aggregate = new AggregateError(
  452. [failure, cleanupFailure],
  453. `${failure.message}; ${cleanupFailure.message}`,
  454. )
  455. reportFailure(aggregate)
  456. throw aggregate
  457. }
  458. if (cancelledBeforeCleanup || isAborted(request.signal)) {
  459. throw new Error('subagent-claude-code: request was aborted before SDK startup')
  460. }
  461. const failure = startupFailure(spawnError)
  462. reportFailure(failure)
  463. throw failure
  464. }
  465. if (child !== undefined) {
  466. try {
  467. await disposeClaudeCodeChild(query, child)
  468. } catch (disposeError: unknown) {
  469. const failure = startupFailure()
  470. const cleanupFailure = thrown(disposeError)
  471. const aggregate = new AggregateError(
  472. [failure, cleanupFailure],
  473. `${failure.message}; ${cleanupFailure.message}`,
  474. )
  475. reportFailure(aggregate)
  476. throw aggregate
  477. }
  478. } else if (query !== undefined) {
  479. try {
  480. query.close()
  481. } catch (disposeError: unknown) {
  482. const failure = startupFailure()
  483. const cleanupFailure = new ClaudeCodeFailure({
  484. stage: 'teardown',
  485. category: 'unknown',
  486. }, thrown(disposeError))
  487. const aggregate = new AggregateError(
  488. [failure, cleanupFailure],
  489. `${failure.message}; ${cleanupFailure.message}`,
  490. )
  491. reportFailure(aggregate)
  492. throw aggregate
  493. }
  494. }
  495. if (cancelledBeforeCleanup || isAborted(request.signal)) {
  496. throw new Error('subagent-claude-code: request was aborted before SDK startup')
  497. }
  498. const failure = startupFailure()
  499. reportFailure(failure)
  500. throw failure
  501. }
  502. const publishedQuery = query
  503. const publishedChild = child
  504. let receivedResult = false
  505. const result = settleRunResult({
  506. attempt: async () => {
  507. try {
  508. return await consumeClaudeQuery(publishedQuery, () => {
  509. capturePermissionDiagnostic(unattendedDiagnostic(
  510. spec.permissionMode,
  511. 'tool permission',
  512. 'denied',
  513. 'Claude Code denied the request before an interactive prompt',
  514. ))
  515. }, () => {
  516. receivedResult = true
  517. })
  518. } catch (error: unknown) {
  519. const processOutcome = managedProcess?.outcome
  520. let facts: ClaudeCodeFailureFacts
  521. if (error instanceof ClaudeCodeFailure) {
  522. facts = { ...error.facts, outcome: processOutcome }
  523. } else if (processOutcome !== undefined && !receivedResult) {
  524. facts = {
  525. stage: 'process',
  526. category: 'process-exit',
  527. outcome: processOutcome,
  528. }
  529. } else {
  530. facts = {
  531. stage: 'query-run',
  532. category: 'unknown',
  533. outcome: processOutcome,
  534. }
  535. }
  536. prependFailureDiagnostic(facts)
  537. // Keep the SDK category and cause; the diagnostic adds later process facts.
  538. throw error instanceof ClaudeCodeFailure
  539. ? error
  540. : new ClaudeCodeFailure(facts, thrown(error))
  541. }
  542. },
  543. collectOutput: () => [],
  544. collectDiagnostic: () => diagnostic,
  545. cancelled: () => controller.signal.aborted,
  546. onError: spec.onError,
  547. signal: request.signal,
  548. onAbort,
  549. })
  550. return subprocessRunHandle({
  551. id: SessionId(randomUUID()),
  552. result,
  553. signal: request.signal,
  554. onAbort,
  555. requestCancel,
  556. teardown: async () => {
  557. try {
  558. await disposeClaudeCodeChild(publishedQuery, publishedChild)
  559. } catch (error: unknown) {
  560. const failure = thrown(error)
  561. reportFailure(failure)
  562. throw failure
  563. }
  564. },
  565. })
  566. }