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