run.ts 19 KB

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