run.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587
  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-range 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 managed-range 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 range, and wait for the
  243. * subprocess owner to prove it is quiescent.
  244. * @param query - official SDK query, when creation reached that point.
  245. * @param child - shared-service handle that owns the CLI managed range, including
  246. * a published handle whose direct result later rejects.
  247. */
  248. export async function disposeClaudeCodeChild(
  249. query: Pick<Query, 'close'> | undefined,
  250. child: SubprocessHandle,
  251. ): Promise<void> {
  252. const failures: Error[] = []
  253. let outcome: SubprocessOutcome | undefined
  254. void child.done.then(
  255. (value) => { outcome = value },
  256. () => {},
  257. )
  258. try {
  259. query?.close()
  260. } catch (error: unknown) {
  261. failures.push(thrown(error))
  262. }
  263. child.terminate()
  264. try {
  265. await child.waitForExit()
  266. } catch (error: unknown) {
  267. failures.push(thrown(error))
  268. }
  269. const firstFailure = failures[0]
  270. if (firstFailure !== undefined) {
  271. const facts = {
  272. stage: 'teardown',
  273. category: 'unknown',
  274. outcome,
  275. } as const
  276. const cause = failures.length === 1
  277. ? firstFailure
  278. : new AggregateError(failures, 'Claude Code teardown failures')
  279. throw new ClaudeCodeFailure(facts, cause)
  280. }
  281. await child.done.catch(() => {})
  282. }
  283. /**
  284. * Build the fixed official SDK options for one one-shot provider run.
  285. * @param spec - Workspace, environment, process service, and disposal policy.
  286. * @param controller - per-run cancellation owner.
  287. * @param capture - receives the shared child and SDK-facing process synchronously.
  288. * @param captureDiagnostic - receives safe facts from unattended interaction callbacks.
  289. * @returns options that inherit native settings while disabling persistence and user questions.
  290. */
  291. export function claudeQueryOptions(
  292. spec: ClaudeCodeRunSpec,
  293. controller: AbortController,
  294. capture: (
  295. child: SubprocessHandle,
  296. process: ManagedClaudeCodeProcess,
  297. ) => void,
  298. captureDiagnostic: (diagnostic: string) => void,
  299. ): Options {
  300. return {
  301. abortController: controller,
  302. cwd: spec.cwd,
  303. ...spec.model === undefined ? {} : { model: spec.model },
  304. env: { ...scrubbedParentEnv(), ...spec.env },
  305. persistSession: false,
  306. disallowedTools: spec.permissionMode === 'plan'
  307. ? ['AskUserQuestion', 'ExitPlanMode']
  308. : ['AskUserQuestion'],
  309. permissionMode: spec.permissionMode,
  310. ...spec.permissionMode === 'bypassPermissions'
  311. ? { allowDangerouslySkipPermissions: true }
  312. : {
  313. canUseTool: () => {
  314. captureDiagnostic(unattendedDiagnostic(
  315. spec.permissionMode,
  316. 'tool permission',
  317. 'denied',
  318. 'the provider does not request human approval',
  319. ))
  320. return Promise.resolve({
  321. behavior: 'deny' as const,
  322. message: 'This unattended Claude Code subagent cannot request human approval.',
  323. })
  324. },
  325. },
  326. onElicitation: () => {
  327. captureDiagnostic(unattendedDiagnostic(
  328. spec.permissionMode,
  329. 'MCP elicitation',
  330. 'declined',
  331. 'the provider does not collect interactive MCP input',
  332. ))
  333. return Promise.resolve({ action: 'decline' })
  334. },
  335. onUserDialog: () => {
  336. captureDiagnostic(unattendedDiagnostic(
  337. spec.permissionMode,
  338. 'user dialog',
  339. 'cancelled',
  340. 'the provider does not render blocking dialogs',
  341. ))
  342. return Promise.resolve({ behavior: 'cancelled' as const })
  343. },
  344. supportedDialogKinds: SUPPORTED_UNATTENDED_DIALOG_KINDS,
  345. spawnClaudeCodeProcess: (options: SpawnOptions) => {
  346. const child = spec.spawn(claudeSpawnSpec(options, spec.disposeGraceMs))
  347. const process = new ManagedClaudeCodeProcess(child)
  348. capture(child, process)
  349. return process
  350. },
  351. }
  352. }
  353. /**
  354. * Start one official Claude Agent SDK query and publish its one-shot run.
  355. * @param request - resolved shared subagent request.
  356. * @param spec - Workspace, environment, process service, and diagnostic policy.
  357. * @returns the published run after both Query and the real CLI handle exist.
  358. */
  359. export async function startClaudeCodeRun(
  360. request: SubagentStartRequest,
  361. spec: ClaudeCodeRunSpec,
  362. ): Promise<SubagentRun> {
  363. const prompt = textTask(request.prompt)
  364. if (request.signal.aborted) {
  365. throw new Error('subagent-claude-code: request was aborted before SDK startup')
  366. }
  367. const controller = new AbortController()
  368. const requestCancel = (): void => {
  369. if (!controller.signal.aborted) {
  370. controller.abort(new Error('subagent-claude-code: run cancelled locally'))
  371. }
  372. }
  373. const onAbort = (): void => { requestCancel() }
  374. request.signal.addEventListener('abort', onAbort, { once: true })
  375. const reportFailure = (error: Error): void => {
  376. try {
  377. spec.onError?.(error, 'error')
  378. } catch {
  379. // Host diagnostic logging cannot replace the product failure.
  380. }
  381. }
  382. let child: SubprocessHandle | undefined
  383. let childFailure: Error | undefined
  384. let childProcessFailure: Promise<never> | undefined
  385. let query: Query | undefined
  386. let managedProcess: ManagedClaudeCodeProcess | undefined
  387. let diagnostic: string | undefined
  388. const capturePermissionDiagnostic = (value: string): void => {
  389. diagnostic = value
  390. }
  391. const prependFailureDiagnostic = (facts: ClaudeCodeFailureFacts): void => {
  392. const failure = failureDiagnostic(facts)
  393. diagnostic = diagnostic === undefined
  394. ? failure
  395. : `${failure}\n${diagnostic}`
  396. }
  397. const captureChild = (
  398. captured: SubprocessHandle,
  399. process: ManagedClaudeCodeProcess,
  400. ): void => {
  401. child = captured
  402. managedProcess = process
  403. childProcessFailure = captured.done.then(
  404. () => new Promise<never>(() => {}),
  405. (error: unknown) => {
  406. childFailure = thrown(error)
  407. throw childFailure
  408. },
  409. )
  410. void childProcessFailure.catch(() => {})
  411. }
  412. try {
  413. query = officialQuery({
  414. prompt,
  415. options: claudeQueryOptions(
  416. spec,
  417. controller,
  418. captureChild,
  419. capturePermissionDiagnostic,
  420. ),
  421. })
  422. if (child === undefined || childProcessFailure === undefined) {
  423. throw new Error(
  424. 'subagent-claude-code: official SDK did not publish a controllable Claude Code process',
  425. )
  426. }
  427. if (isAborted(controller.signal)) {
  428. throw new Error('subagent-claude-code: request was aborted before SDK startup')
  429. }
  430. } catch (error: unknown) {
  431. request.signal.removeEventListener('abort', onAbort)
  432. const cancelledBeforeCleanup = controller.signal.aborted
  433. // Let child.done publish a concurrently observed exit before classification.
  434. await Promise.resolve()
  435. const startupOutcome = managedProcess?.outcome
  436. const startupFacts = {
  437. stage: 'query-start',
  438. category: 'unknown',
  439. outcome: startupOutcome,
  440. } as const
  441. const startupFailure = (cause: unknown = childFailure ?? error): ClaudeCodeFailure => new ClaudeCodeFailure(
  442. startupFacts,
  443. thrown(cause),
  444. )
  445. requestCancel()
  446. if (child !== undefined) {
  447. try {
  448. await disposeClaudeCodeChild(query, child)
  449. } catch (disposeError: unknown) {
  450. const failure = startupFailure()
  451. const cleanupFailure = thrown(disposeError)
  452. const aggregate = new AggregateError(
  453. [failure, cleanupFailure],
  454. `${failure.message}; ${cleanupFailure.message}`,
  455. )
  456. reportFailure(aggregate)
  457. throw aggregate
  458. }
  459. if (cancelledBeforeCleanup || isAborted(request.signal)) {
  460. throw new Error('subagent-claude-code: request was aborted before SDK startup')
  461. }
  462. const failure = startupFailure()
  463. reportFailure(failure)
  464. throw failure
  465. } else if (query !== undefined) {
  466. try {
  467. query.close()
  468. } catch (disposeError: unknown) {
  469. const failure = startupFailure()
  470. const cleanupFailure = new ClaudeCodeFailure({
  471. stage: 'teardown',
  472. category: 'unknown',
  473. }, thrown(disposeError))
  474. const aggregate = new AggregateError(
  475. [failure, cleanupFailure],
  476. `${failure.message}; ${cleanupFailure.message}`,
  477. )
  478. reportFailure(aggregate)
  479. throw aggregate
  480. }
  481. }
  482. if (cancelledBeforeCleanup || isAborted(request.signal)) {
  483. throw new Error('subagent-claude-code: request was aborted before SDK startup')
  484. }
  485. const failure = startupFailure()
  486. reportFailure(failure)
  487. throw failure
  488. }
  489. const publishedQuery = query
  490. const publishedChild = child
  491. const publishedProcessFailure = childProcessFailure
  492. let receivedResult = false
  493. const result = settleRunResult({
  494. attempt: async () => {
  495. try {
  496. return await Promise.race([
  497. consumeClaudeQuery(publishedQuery, () => {
  498. capturePermissionDiagnostic(unattendedDiagnostic(
  499. spec.permissionMode,
  500. 'tool permission',
  501. 'denied',
  502. 'Claude Code denied the request before an interactive prompt',
  503. ))
  504. }, () => {
  505. receivedResult = true
  506. }),
  507. publishedProcessFailure,
  508. ])
  509. } catch (error: unknown) {
  510. const processOutcome = managedProcess?.outcome
  511. let facts: ClaudeCodeFailureFacts
  512. if (error instanceof ClaudeCodeFailure) {
  513. facts = { ...error.facts, outcome: processOutcome }
  514. } else if (processOutcome !== undefined && !receivedResult) {
  515. facts = {
  516. stage: 'process',
  517. category: 'process',
  518. outcome: processOutcome,
  519. }
  520. } else {
  521. facts = {
  522. stage: 'query-run',
  523. category: 'unknown',
  524. outcome: processOutcome,
  525. }
  526. }
  527. prependFailureDiagnostic(facts)
  528. // Keep the SDK category and cause; the diagnostic adds later process facts.
  529. throw error instanceof ClaudeCodeFailure
  530. ? error
  531. : new ClaudeCodeFailure(facts, thrown(error))
  532. }
  533. },
  534. collectOutput: () => [],
  535. collectDiagnostic: () => diagnostic,
  536. cancelled: () => controller.signal.aborted,
  537. onError: spec.onError,
  538. signal: request.signal,
  539. onAbort,
  540. })
  541. return subprocessRunHandle({
  542. id: brandString<SessionId>(randomUUID()),
  543. result,
  544. signal: request.signal,
  545. onAbort,
  546. requestCancel,
  547. teardown: async () => {
  548. try {
  549. await disposeClaudeCodeChild(publishedQuery, publishedChild)
  550. } catch (error: unknown) {
  551. const failure = thrown(error)
  552. reportFailure(failure)
  553. throw failure
  554. }
  555. },
  556. })
  557. }