run.ts 19 KB

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