@meistrari/agent-core 0.2.0-experimental.1 → 0.2.0-experimental.2
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.md +50 -0
- package/docs/consumer-capability-matrix.md +374 -0
- package/package.json +17 -10
- package/scripts/generate-codex-protocol.ts +711 -0
- package/src/agents/agent-operation.ts +8 -0
- package/src/agents/agent-provider.ts +3 -0
- package/src/agents/agent-run.ts +14 -2
- package/src/agents/agent-runtime-error.ts +28 -0
- package/src/agents/agent-session-events.ts +33 -0
- package/src/agents/agent-tool-runner.ts +12 -11
- package/src/agents/agent-tool.ts +12 -4
- package/src/agents/author-context.ts +1 -1
- package/src/agents/claude/claude-auth.ts +40 -0
- package/src/agents/claude/claude-browser-turn-metadata.ts +70 -0
- package/src/agents/claude/claude-code-process.ts +98 -0
- package/src/agents/claude/claude-command-mapper.ts +63 -3
- package/src/agents/claude/claude-desktop.ts +55 -0
- package/src/agents/claude/claude-event-mapper.ts +864 -135
- package/src/agents/claude/claude-file-change.ts +128 -0
- package/src/agents/claude/claude-provider.ts +344 -29
- package/src/agents/claude/claude-run.ts +893 -130
- package/src/agents/claude/claude-selected-repository-instructions.ts +91 -0
- package/src/agents/claude/claude-skill-catalog.ts +75 -0
- package/src/agents/claude/claude-steered-notifications.ts +180 -0
- package/src/agents/claude/claude-tool-mapper.ts +81 -2
- package/src/agents/claude/claude-workspace-skills.ts +87 -0
- package/src/agents/claude/index.ts +2 -0
- package/src/agents/codex/codex-auth-mapper.ts +27 -0
- package/src/agents/codex/codex-auth.ts +19 -0
- package/src/agents/codex/codex-browser-auth-broker.ts +320 -0
- package/src/agents/codex/codex-browser-auth-status.ts +55 -0
- package/src/agents/codex/codex-command-mapper.ts +38 -3
- package/src/agents/codex/codex-event-mapper.ts +375 -64
- package/src/agents/codex/codex-json-rpc-client.ts +66 -21
- package/src/agents/codex/codex-provider.ts +118 -236
- package/src/agents/codex/codex-run-auth.ts +113 -0
- package/src/agents/codex/codex-run.ts +423 -53
- package/src/agents/codex/codex-tool-mapper.ts +2 -2
- package/src/agents/codex/codex.errors.ts +8 -0
- package/src/agents/codex/generated/meta.gen.ts +144 -6
- package/src/agents/codex/generated/namespaces.gen.ts +92 -3
- package/src/agents/codex/generated/schema.gen.ts +24698 -15638
- package/src/agents/codex/lazy-codex-run-auth.ts +47 -0
- package/src/agents/index.ts +5 -0
- package/src/agents/input-attachment-preparation.ts +13 -0
- package/src/agents/mcp-tool-content.ts +82 -0
- package/src/agents/model-rebinding-run.ts +14 -3
- package/src/agents/selected-repository-instructions.ts +202 -0
- package/src/agents/session-context.ts +41 -0
- package/src/agents/subscription-token-refresh.ts +73 -0
- package/src/agents/user-input-request.ts +19 -20
- package/src/errors/index.ts +1 -1
- package/src/errors/validation-error.ts +6 -0
- package/src/protocol/agent-catalog.ts +159 -0
- package/src/protocol/agent-command.ts +52 -7
- package/src/protocol/agent-content.ts +36 -16
- package/src/protocol/agent-event.ts +210 -73
- package/src/protocol/agent-identity.ts +29 -0
- package/src/protocol/agent-json.ts +122 -8
- package/src/protocol/agent-metadata.ts +2 -2
- package/src/protocol/agent-model.ts +13 -1
- package/src/protocol/agent-notification.ts +110 -0
- package/src/protocol/agent-presentation.ts +71 -20
- package/src/protocol/agent-tool.ts +66 -10
- package/src/protocol/agent-usage.ts +3 -1
- package/src/protocol/agent-user-input.ts +4 -4
- package/src/protocol/agent-work.ts +12 -10
- package/src/protocol/index.ts +2 -0
- package/src/protocol/rejected-keys.ts +11 -0
- package/src/provenance.gen.ts +3 -3
- package/src/supervisor-protocol/agent-event-wrapper.ts +30 -0
|
@@ -1,27 +1,39 @@
|
|
|
1
1
|
import type { Query, SDKMessage, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'
|
|
2
|
+
import type { UUID } from 'node:crypto'
|
|
2
3
|
import type {
|
|
4
|
+
AgentContextCompactionRequestResult,
|
|
3
5
|
AgentError,
|
|
6
|
+
AgentNotifyCommand,
|
|
4
7
|
AgentReasoningEffort,
|
|
5
8
|
AgentRunMetadata,
|
|
9
|
+
AgentSettingsChange,
|
|
10
|
+
CompactContextAgentCommand,
|
|
6
11
|
InterruptAgentCommand,
|
|
7
12
|
RespondUserInputAgentCommand,
|
|
8
13
|
SendPromptCommand,
|
|
9
14
|
StopAgentCommand,
|
|
10
15
|
} from '../../protocol'
|
|
11
16
|
import type { AgentEventDraft } from '../agent-event-stream'
|
|
12
|
-
import type { AgentOperationOptions } from '../agent-operation'
|
|
13
|
-
import type { AgentPromptAcceptance, AgentRun, AgentUserInputSubmission } from '../agent-run'
|
|
17
|
+
import type { AgentOperationOptions, AgentPromptOperationOptions } from '../agent-operation'
|
|
18
|
+
import type { AgentNotificationAcceptance, AgentPromptAcceptance, AgentRun, AgentUserInputSubmission } from '../agent-run'
|
|
14
19
|
import type { UserInputRequestInput, UserInputRequestStore } from '../user-input-request'
|
|
20
|
+
import type { ClaudeAuthFailure } from './claude-auth'
|
|
21
|
+
import type { ClaudeCodeProcess } from './claude-code-process'
|
|
15
22
|
import type { ClaudeEventMapperState } from './claude-event-mapper'
|
|
23
|
+
import type { ClaudeSelectedRepositoryInstructions } from './claude-selected-repository-instructions'
|
|
24
|
+
import type { ClaudeSkillCatalog } from './claude-skill-catalog'
|
|
16
25
|
import type { ClaudeStartupPhase } from './claude-startup-timing'
|
|
26
|
+
import type { ClaudeSteeredNotificationStore, SteeredNotificationDelivery } from './claude-steered-notifications'
|
|
27
|
+
import type { ClaudeWorkspaceSkills } from './claude-workspace-skills'
|
|
28
|
+
import { randomUUID } from 'node:crypto'
|
|
17
29
|
import z from 'zod'
|
|
18
30
|
import { serializeAgentError } from '../agent-error-serializer'
|
|
19
31
|
import { AgentEventStream } from '../agent-event-stream'
|
|
20
32
|
import { AgentDeliveryUnknownError, AgentNotAcceptedError, AgentPromptDeferredError, AgentProviderMetadataError, AgentProviderProtocolError, AgentRunStateError } from '../agent-runtime-error'
|
|
21
|
-
import { sessionConfiguredEvent } from '../agent-session-events'
|
|
33
|
+
import { sessionConfiguredEvent, sessionSettingsChangedEvent } from '../agent-session-events'
|
|
22
34
|
import { deriveMessageId } from '../message-id'
|
|
23
|
-
import { mapClaudeUserMessage, toClaudeUserMessage } from './claude-command-mapper'
|
|
24
|
-
import { mapClaudeMessage } from './claude-event-mapper'
|
|
35
|
+
import { claudeNotificationText, mapClaudeUserMessage, toClaudeCompactContextMessage, toClaudeNotificationMessage, toClaudeTaskNotificationMessage, toClaudeUserMessage } from './claude-command-mapper'
|
|
36
|
+
import { compactionError, flushHeldTurnEnd, mapClaudeMessage, mapSteerLifecycle, resultError } from './claude-event-mapper'
|
|
25
37
|
import { hydrateClaudeToolOutput } from './claude-persisted-output'
|
|
26
38
|
|
|
27
39
|
// The ONE long-lived SDK input stream created with the query. SDK 0.3.207 Query.streamInput ends
|
|
@@ -41,24 +53,39 @@ interface ClaudeInitExpectation {
|
|
|
41
53
|
environment: Record<string, string | undefined>
|
|
42
54
|
}
|
|
43
55
|
|
|
56
|
+
// The model and reasoning effort a run's inputs run with; an undefined field is the CLI's default.
|
|
57
|
+
interface ClaudeRunSettings {
|
|
58
|
+
model: string | undefined
|
|
59
|
+
effort: AgentReasoningEffort | undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
44
62
|
type ClaudeInitMessage = Extract<SDKMessage, { type: 'system', subtype: 'init' }>
|
|
63
|
+
type ClaudePreInitHookMessage = Extract<SDKMessage, { type: 'system', subtype: 'hook_started' | 'hook_progress' | 'hook_response' }>
|
|
45
64
|
interface ClaudeCommandLifecycleMessage {
|
|
46
65
|
type: 'command_lifecycle'
|
|
47
66
|
}
|
|
48
67
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
68
|
+
function isClaudePreInitHookMessage(message: SDKMessage | ClaudeCommandLifecycleMessage): message is ClaudePreInitHookMessage {
|
|
69
|
+
return message.type === 'system'
|
|
70
|
+
&& (message.subtype === 'hook_started' || message.subtype === 'hook_progress' || message.subtype === 'hook_response')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The Claude prompt-acceptance receipt is an undocumented CLI frame absent from the SDK's
|
|
74
|
+
// exported SDKMessage union. Its exact shape and the original states were live-verified on SDK
|
|
75
|
+
// 0.3.207 / bundled CLI 2.1.207 by scripts/smoke/claude-command-acceptance-probe.ts. CLI 2.1.263
|
|
76
|
+
// added one more state, `refused`, and the pinned 0.3.280 / CLI 2.1.280 embeds the same schema,
|
|
77
|
+
// which its own description defines as "declined by the session's receive-side policy before
|
|
78
|
+
// entering the queue (outright, or after being held): not preceded by 'queued', and it will not
|
|
79
|
+
// run in this session". Any other shape or state deviation is provider protocol drift and fails
|
|
80
|
+
// the run closed. Only `queued`/`started`/`completed` are positive. `cancelled` (steered-over,
|
|
81
|
+
// aborted, or user-cancelled) and `discarded` (session ended with the command still queued) are
|
|
82
|
+
// observed non-positive states whose real outcome the run's close settles; `refused` is a definite
|
|
83
|
+
// terminal non-acceptance while the session keeps running, so it settles the waiter itself.
|
|
57
84
|
const commandLifecycleFrameSchema = z.strictObject({
|
|
58
85
|
type: z.literal('command_lifecycle'),
|
|
59
86
|
command_uuid: z.uuid(),
|
|
60
87
|
session_id: z.uuid(),
|
|
61
|
-
state: z.enum(['queued', 'started', 'completed', 'cancelled', 'discarded']),
|
|
88
|
+
state: z.enum(['queued', 'started', 'completed', 'cancelled', 'discarded', 'refused']),
|
|
62
89
|
uuid: z.uuid(),
|
|
63
90
|
})
|
|
64
91
|
|
|
@@ -68,6 +95,51 @@ interface AcceptanceWaiter {
|
|
|
68
95
|
reject: (error: unknown) => void
|
|
69
96
|
}
|
|
70
97
|
|
|
98
|
+
// The one in-flight notification (the supervisor submits commands one at a time). It settles on the
|
|
99
|
+
// turn that actually consumes it: `started` on its command_lifecycle frame, then the turn identity.
|
|
100
|
+
interface NotificationWaiter {
|
|
101
|
+
clientMessageId: string
|
|
102
|
+
// The main turn active when the notification was written; a fold into it is a join.
|
|
103
|
+
activeTurnIdAtSubmission: string | undefined
|
|
104
|
+
consumed: boolean
|
|
105
|
+
promise: Promise<AgentNotificationAcceptance>
|
|
106
|
+
resolve: (acceptance: AgentNotificationAcceptance) => void
|
|
107
|
+
reject: (error: unknown) => void
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Steered envelopes taken for a follow-up turn whose input Claude has not started yet; they return
|
|
111
|
+
// to the store if Claude drops that input first.
|
|
112
|
+
interface SteeredNotificationFollowUp {
|
|
113
|
+
clientMessageId: UUID
|
|
114
|
+
delivery: SteeredNotificationDelivery
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The one-time session context is owed until a prompt carrying it starts in Claude. A carrying
|
|
118
|
+
// prompt Claude drops before starting it (cancelled, discarded, refused, or never written) owes it
|
|
119
|
+
// again, so the next prompt carries it. Synthetic inputs (/compact, notifications) never carry it.
|
|
120
|
+
type ClaudeSessionContextState
|
|
121
|
+
= | { status: 'owed', text: string }
|
|
122
|
+
| { status: 'carried', text: string, clientMessageId: string }
|
|
123
|
+
| { status: 'delivered' }
|
|
124
|
+
|
|
125
|
+
type CommandLifecycleFrame = z.infer<typeof commandLifecycleFrameSchema>
|
|
126
|
+
|
|
127
|
+
// A manual compaction reports one request result: the correlated outcome, the compaction deadline,
|
|
128
|
+
// or the run's end. `compactContext` returns once Claude accepts the input, so an outcome observed
|
|
129
|
+
// later is reported as it arrives, while one observed before acceptance is kept for the submitting
|
|
130
|
+
// call to report and return. Reporting does not end the compaction: an accepted one stays registered,
|
|
131
|
+
// deferring steers and rejecting a second compaction, until Claude ends it (its correlated outcome
|
|
132
|
+
// or the end of its turn) or the run ends, even after the deadline reported `unknown`.
|
|
133
|
+
interface CompactionWaiter {
|
|
134
|
+
commandId: string
|
|
135
|
+
clientMessageId: string
|
|
136
|
+
timeout: ReturnType<typeof setTimeout>
|
|
137
|
+
accepted: boolean
|
|
138
|
+
outcome?: AgentContextCompactionRequestResult
|
|
139
|
+
reported?: AgentContextCompactionRequestResult
|
|
140
|
+
boundaryObserved: boolean
|
|
141
|
+
}
|
|
142
|
+
|
|
71
143
|
function interruptTargetsTurn(interrupt: ClaudeEventMapperState['interrupt'], turnId: string): boolean {
|
|
72
144
|
return interrupt.status !== 'none' && interrupt.turnId === turnId
|
|
73
145
|
}
|
|
@@ -79,9 +151,33 @@ interface ClaudeRunInput {
|
|
|
79
151
|
input: ClaudeInputStream
|
|
80
152
|
eventMapperState: ClaudeEventMapperState
|
|
81
153
|
init: ClaudeInitExpectation
|
|
154
|
+
steeredNotifications: ClaudeSteeredNotificationStore
|
|
155
|
+
// Delivered once, with the session's first prompt, then cleared (see session-context.ts).
|
|
156
|
+
sessionContext: string | undefined
|
|
157
|
+
workspace: ClaudeRunWorkspace
|
|
158
|
+
// The run's Claude Code process; the run is not reported ended until it has exited.
|
|
159
|
+
process: ClaudeRunProcess
|
|
160
|
+
onFinished: () => Promise<void>
|
|
161
|
+
compactionTimeoutMs?: number
|
|
82
162
|
recordStartup?: (phase: ClaudeStartupPhase) => void
|
|
163
|
+
// Reads SDK-persisted tool results back from the transcript so tool events carry full output.
|
|
164
|
+
hydratePersistedToolOutput?: boolean
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
type ClaudeRunProcess = Pick<ClaudeCodeProcess, 'terminate' | 'stderrTail'>
|
|
168
|
+
|
|
169
|
+
// The run's workspace registration: the generated skills plugin, the repository roots it links,
|
|
170
|
+
// the catalog fed from the CLI's command list, and the managed selected-repository instructions.
|
|
171
|
+
interface ClaudeRunWorkspace {
|
|
172
|
+
// Absent when the provider generates no workspace skills plugin.
|
|
173
|
+
skills?: ClaudeWorkspaceSkills
|
|
174
|
+
repositoryRoots: readonly string[]
|
|
175
|
+
catalog: ClaudeSkillCatalog
|
|
176
|
+
instructions: ClaudeSelectedRepositoryInstructions
|
|
83
177
|
}
|
|
84
178
|
|
|
179
|
+
export const CLAUDE_COMPACTION_TIMEOUT_MS = 180_000
|
|
180
|
+
|
|
85
181
|
export class ClaudeRun implements AgentRun {
|
|
86
182
|
readonly events: AgentEventStream
|
|
87
183
|
readonly metadata: AgentRunMetadata
|
|
@@ -90,30 +186,75 @@ export class ClaudeRun implements AgentRun {
|
|
|
90
186
|
private readonly input: ClaudeInputStream
|
|
91
187
|
private readonly eventMapperState: ClaudeEventMapperState
|
|
92
188
|
private readonly init: ClaudeInitExpectation
|
|
93
|
-
private readonly
|
|
94
|
-
private readonly
|
|
95
|
-
private
|
|
189
|
+
private readonly workspaceSkills: ClaudeWorkspaceSkills | undefined
|
|
190
|
+
private readonly selectedRepositoryInstructions: ClaudeSelectedRepositoryInstructions
|
|
191
|
+
private readonly steeredNotifications: ClaudeSteeredNotificationStore
|
|
192
|
+
private sessionContext: ClaudeSessionContextState
|
|
193
|
+
private readonly skillCatalog: ClaudeSkillCatalog
|
|
194
|
+
private workspaceRepositoryRoots: readonly string[]
|
|
195
|
+
// Set when the skills plugin changed on disk and cleared only by a successful reload, so a
|
|
196
|
+
// repository root whose reload was aborted or failed is reloaded again on retry.
|
|
197
|
+
private skillReloadOwed = false
|
|
198
|
+
private readonly process: ClaudeRunProcess
|
|
199
|
+
private readonly onFinished: () => Promise<void>
|
|
200
|
+
private readonly compactionTimeoutMs: number
|
|
201
|
+
// Settles after the CLI has exited and the terminal events are pushed (set by finish).
|
|
202
|
+
private ended: Promise<void> | undefined
|
|
203
|
+
// Resolves with the model system/init reported: the CLI default when no model was requested.
|
|
204
|
+
private readonly initialization: Promise<string>
|
|
205
|
+
private initializationResolve: (model: string) => void = () => undefined
|
|
96
206
|
private initializationReject: (error: unknown) => void = () => undefined
|
|
97
207
|
private initialized = false
|
|
208
|
+
// With CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS the CLI reports `running` for the first input before
|
|
209
|
+
// system/init; those frames are held and mapped right after init so the turn starts on time.
|
|
210
|
+
private readonly preInitSessionStates: SDKMessage[] = []
|
|
98
211
|
private firstInputPushed = false
|
|
99
|
-
private pendingSteerResult?: Extract<SDKMessage, { type: 'result' }>
|
|
100
|
-
private modelChangeReady = true
|
|
101
|
-
private modelChangeInFlight = false
|
|
102
212
|
private finished = false
|
|
213
|
+
private queryClosed = false
|
|
103
214
|
private interruptRequest?: { turnId: string, promise: Promise<void> }
|
|
104
215
|
private readonly acceptanceWaiters = new Map<string, AcceptanceWaiter>()
|
|
216
|
+
// Client message ids of steering (`now`) prompts whose lifecycle can supersede the running ask.
|
|
217
|
+
private readonly steerInputIds = new Set<string>()
|
|
218
|
+
private compactionWaiter?: CompactionWaiter
|
|
219
|
+
private notificationWaiter?: NotificationWaiter
|
|
220
|
+
private steeredFollowUp?: SteeredNotificationFollowUp
|
|
221
|
+
private readonly recordStartup: ClaudeRunInput['recordStartup']
|
|
222
|
+
private readonly hydratePersistedToolOutput: boolean
|
|
223
|
+
private modelChangeReady = true
|
|
224
|
+
private modelChangeInFlight = false
|
|
225
|
+
// Provider command identity by derived client message id, until its completed/negative receipt.
|
|
105
226
|
private readonly commandByClientMessageId = new Map<string, string>()
|
|
227
|
+
// Prompts whose accepted user message has not been emitted into a turn yet.
|
|
106
228
|
private readonly pendingMessages = new Map<string, { messageId: string, mode: 'next' | 'steer', turnId?: string, started: boolean }>()
|
|
107
229
|
|
|
108
|
-
|
|
230
|
+
// The accepted settings: they change only once the input carrying a change is accepted.
|
|
231
|
+
private current: ClaudeRunSettings
|
|
232
|
+
// What the CLI was last told, or undefined when a failed control request left it unknown. It
|
|
233
|
+
// differs from `current` only after a change reached the CLI for an input that was then not
|
|
234
|
+
// written or not accepted; every input re-asserts `current` before it is written.
|
|
235
|
+
private providerApplied: ClaudeRunSettings | undefined
|
|
236
|
+
|
|
237
|
+
constructor({ metadata, query, userInputRequests, input, eventMapperState, init, steeredNotifications, sessionContext, workspace, process, onFinished, compactionTimeoutMs = CLAUDE_COMPACTION_TIMEOUT_MS, recordStartup, hydratePersistedToolOutput = true }: ClaudeRunInput) {
|
|
109
238
|
this.metadata = metadata
|
|
110
239
|
this.query = query
|
|
111
240
|
this.userInputRequests = userInputRequests
|
|
112
241
|
this.input = input
|
|
113
242
|
this.eventMapperState = eventMapperState
|
|
114
243
|
this.init = init
|
|
244
|
+
this.workspaceSkills = workspace.skills
|
|
245
|
+
this.selectedRepositoryInstructions = workspace.instructions
|
|
246
|
+
this.steeredNotifications = steeredNotifications
|
|
247
|
+
this.sessionContext = sessionContext === undefined ? { status: 'delivered' } : { status: 'owed', text: sessionContext }
|
|
248
|
+
this.skillCatalog = workspace.catalog
|
|
249
|
+
this.workspaceRepositoryRoots = [...workspace.repositoryRoots]
|
|
250
|
+
this.process = process
|
|
251
|
+
this.onFinished = onFinished
|
|
252
|
+
this.compactionTimeoutMs = compactionTimeoutMs
|
|
115
253
|
this.recordStartup = recordStartup
|
|
116
|
-
this.
|
|
254
|
+
this.hydratePersistedToolOutput = hydratePersistedToolOutput
|
|
255
|
+
this.current = { model: init.requestedModel, effort: init.reasoningEffort }
|
|
256
|
+
this.providerApplied = this.current
|
|
257
|
+
this.initialization = new Promise<string>((resolve, reject) => {
|
|
117
258
|
this.initializationResolve = resolve
|
|
118
259
|
this.initializationReject = reject
|
|
119
260
|
})
|
|
@@ -129,9 +270,10 @@ export class ClaudeRun implements AgentRun {
|
|
|
129
270
|
// Starts the single background query consumer. command_lifecycle frames are the acceptance
|
|
130
271
|
// receipt: they are strictly validated and ROUTED to settle the matching sendPrompt waiter
|
|
131
272
|
// (never skipped, never mapped to an AgentEvent), including the first input's queued frame that
|
|
132
|
-
// legitimately precedes system/init.
|
|
133
|
-
//
|
|
134
|
-
//
|
|
273
|
+
// legitimately precedes system/init. SDK-declared SessionStart/Setup hook lifecycle frames may
|
|
274
|
+
// also precede init; they are session-validated and discarded so hook output stays private.
|
|
275
|
+
// PRE_INIT is otherwise strict: the next message must be a matching system/init, and the run's
|
|
276
|
+
// own session lifecycle AgentEvents are emitted only after that init validation.
|
|
135
277
|
begin(): void {
|
|
136
278
|
void this.consume()
|
|
137
279
|
}
|
|
@@ -139,6 +281,8 @@ export class ClaudeRun implements AgentRun {
|
|
|
139
281
|
pushEvent(draft: AgentEventDraft): void {
|
|
140
282
|
if (this.finished)
|
|
141
283
|
return
|
|
284
|
+
if (draft.type === 'turn.started' && draft.actor.type === 'main')
|
|
285
|
+
this.settleNotificationTurn(draft.turnId)
|
|
142
286
|
if (draft.type === 'turn.started')
|
|
143
287
|
this.modelChangeReady = false
|
|
144
288
|
if (draft.type === 'session.state.changed' && draft.payload.state === 'idle')
|
|
@@ -153,6 +297,8 @@ export class ClaudeRun implements AgentRun {
|
|
|
153
297
|
this.emitAcceptedMessage(draft.payload.commandId, draft.turnId)
|
|
154
298
|
}
|
|
155
299
|
|
|
300
|
+
// The accepted input becomes a user message of the turn that consumed it, correlated with the
|
|
301
|
+
// command that carried it, once the native started receipt confirmed that turn.
|
|
156
302
|
private emitAcceptedMessage(commandId: string, turnId: string): void {
|
|
157
303
|
const pending = this.pendingMessages.get(commandId)
|
|
158
304
|
if (!pending?.started || !this.initialized || this.finished)
|
|
@@ -172,65 +318,416 @@ export class ClaudeRun implements AgentRun {
|
|
|
172
318
|
return { requestId: request.requestId }
|
|
173
319
|
}
|
|
174
320
|
|
|
321
|
+
// Codex registers repository skill roots at this barrier; Claude re-links the run's workspace
|
|
322
|
+
// skills plugin now that the Git baseline exists and always refreshes the catalog once.
|
|
175
323
|
async prepareWorkspace(options: AgentOperationOptions = {}): Promise<void> {
|
|
324
|
+
this.assertRunning()
|
|
325
|
+
options.signal?.throwIfAborted()
|
|
326
|
+
if (!this.workspaceSkills)
|
|
327
|
+
return
|
|
328
|
+
await this.workspaceSkills.sync(this.workspaceRepositoryRoots)
|
|
176
329
|
options.signal?.throwIfAborted()
|
|
330
|
+
await this.reloadSkills()
|
|
177
331
|
}
|
|
178
332
|
|
|
179
|
-
|
|
180
|
-
|
|
333
|
+
async addWorkspaceRepositoryRoot(root: string, options: AgentOperationOptions = {}): Promise<void> {
|
|
334
|
+
this.assertRunning()
|
|
181
335
|
options.signal?.throwIfAborted()
|
|
336
|
+
if (this.workspaceRepositoryRoots.includes(root))
|
|
337
|
+
return
|
|
338
|
+
const repositoryRoots = [...this.workspaceRepositoryRoots, root]
|
|
339
|
+
await this.selectedRepositoryInstructions.update(repositoryRoots)
|
|
340
|
+
if (await this.workspaceSkills?.sync(repositoryRoots))
|
|
341
|
+
this.skillReloadOwed = true
|
|
342
|
+
options.signal?.throwIfAborted()
|
|
343
|
+
if (this.skillReloadOwed)
|
|
344
|
+
await this.reloadSkills()
|
|
345
|
+
// Recorded last: a retry after an abort or failure above redoes the registration.
|
|
346
|
+
this.workspaceRepositoryRoots = repositoryRoots
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Claude Code re-reads plugin skill directories only on reload; the response carries the
|
|
350
|
+
// refreshed command list, which is authoritative for the catalog.
|
|
351
|
+
private async reloadSkills(): Promise<void> {
|
|
352
|
+
const reloaded = await this.query.reloadPlugins()
|
|
353
|
+
this.skillCatalog.publish(reloaded.commands)
|
|
354
|
+
this.skillReloadOwed = false
|
|
182
355
|
}
|
|
183
356
|
|
|
184
|
-
async sendPrompt(command: SendPromptCommand, options:
|
|
357
|
+
async sendPrompt(command: SendPromptCommand, options: AgentPromptOperationOptions = {}): Promise<AgentPromptAcceptance> {
|
|
185
358
|
this.assertCommandTargetsRun(command)
|
|
186
359
|
this.assertRunning()
|
|
187
360
|
options.signal?.throwIfAborted()
|
|
361
|
+
if (options.submissionOrder === 'bypass' && command.mode === 'next') {
|
|
362
|
+
throw new AgentRunStateError({
|
|
363
|
+
message: 'Bypass submission requires a steer prompt.',
|
|
364
|
+
})
|
|
365
|
+
}
|
|
188
366
|
if (command.reasoningEffort !== undefined && command.reasoningEffort !== this.init.reasoningEffort)
|
|
189
367
|
throw new AgentNotAcceptedError({ message: 'Claude reasoning effort changes are not supported by this adapter.', recovery: { kind: 'terminal', reason: 'reasoning_effort_change_not_supported' } })
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
368
|
+
// Claude Code steers by cancelling the running ask, which would abort a manual compaction
|
|
369
|
+
// (CLI 2.1.280). Like Codex's non-steerable compaction turn, the steer waits for it to end.
|
|
370
|
+
if (command.mode === 'steer' && this.compactionWaiter) {
|
|
371
|
+
throw new AgentPromptDeferredError({
|
|
372
|
+
message: 'Claude is compacting context; a steer would cancel the compaction.',
|
|
373
|
+
})
|
|
374
|
+
}
|
|
375
|
+
const settings = this.settingsWith(command.settings)
|
|
376
|
+
// Captured before the change: Claude applies it from the next model request, so a turn
|
|
377
|
+
// running now is the one it enters. With no running turn, the input starts a turn whose id
|
|
378
|
+
// does not exist yet. Either way the turn that handles this input runs on the new settings.
|
|
379
|
+
const turnIdAtSubmission = this.eventMapperState.currentTurnId
|
|
380
|
+
const clientMessageId = deriveMessageId(command.commandId)
|
|
381
|
+
// Claimed before the asynchronous mapping so a concurrent prompt cannot carry it too.
|
|
382
|
+
const sessionContext = this.carrySessionContext(clientMessageId)
|
|
383
|
+
try {
|
|
384
|
+
const input = { prompt: command.prompt, mode: command.mode, commandId: command.commandId, origin: command.origin, author: command.author, senderContext: options.senderContext, authorContext: options.authorContext, sessionContext }
|
|
385
|
+
const message = await mapClaudeUserMessage(input, options.inputAttachmentPreparation)
|
|
386
|
+
options.signal?.throwIfAborted()
|
|
387
|
+
if (command.model || this.modelChangeInFlight)
|
|
388
|
+
await this.applyCommandModel(command, options)
|
|
389
|
+
if (command.turnId && (command.mode !== 'steer' || command.turnId !== this.eventMapperState.currentTurnId))
|
|
390
|
+
throw new AgentNotAcceptedError({ message: 'The targeted claude turn is no longer active.', recovery: { kind: 'terminal', reason: 'target_turn_not_active' } })
|
|
391
|
+
this.modelChangeReady = false
|
|
392
|
+
await this.applyProviderSettings(command.settings ? settings : this.current)
|
|
393
|
+
// Register before the push: the CLI's queued receipt can outrun the local push resolution,
|
|
394
|
+
// and a receipt with no registered waiter would be lost.
|
|
395
|
+
const receipt = this.registerAcceptanceWaiter(clientMessageId)
|
|
396
|
+
if (command.origin === 'user')
|
|
397
|
+
this.eventMapperState.promptCommandByInputId.set(clientMessageId, command.commandId)
|
|
398
|
+
this.commandByClientMessageId.set(clientMessageId, command.commandId)
|
|
399
|
+
this.pendingMessages.set(command.commandId, { messageId: clientMessageId, mode: command.mode, turnId: command.turnId, started: false })
|
|
400
|
+
if (command.mode === 'steer')
|
|
401
|
+
this.steerInputIds.add(clientMessageId)
|
|
402
|
+
try {
|
|
403
|
+
await this.deliverInput(message)
|
|
404
|
+
// `submitted` requires the provider's positive acceptance receipt — the pinned
|
|
405
|
+
// command_lifecycle frame matching this command's derived uuid and this session —
|
|
406
|
+
// in addition to delivery (and, for the first input, validated init identity).
|
|
407
|
+
// Local queue push, transport write, and init alone are never acceptance. No timeout:
|
|
408
|
+
// provider close/error settles the wait as delivery-unknown.
|
|
409
|
+
await receipt.promise
|
|
410
|
+
}
|
|
411
|
+
finally {
|
|
412
|
+
this.acceptanceWaiters.delete(clientMessageId)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
if (error instanceof AgentNotAcceptedError)
|
|
417
|
+
this.eventMapperState.promptCommandByInputId.delete(clientMessageId)
|
|
418
|
+
if (sessionContext !== undefined)
|
|
419
|
+
this.releaseSessionContext(clientMessageId)
|
|
420
|
+
throw error
|
|
421
|
+
}
|
|
422
|
+
if (command.settings) {
|
|
423
|
+
this.current = settings
|
|
424
|
+
this.pushEvent(sessionSettingsChangedEvent({
|
|
425
|
+
model: settings.model ?? await this.initialization,
|
|
426
|
+
reasoningEffort: settings.effort,
|
|
427
|
+
commandId: command.commandId,
|
|
428
|
+
turnId: turnIdAtSubmission,
|
|
429
|
+
appliesFrom: 'current_turn',
|
|
430
|
+
}))
|
|
431
|
+
}
|
|
432
|
+
return { kind: 'submitted' }
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Applies an application-resolved model through the control channel before the input. Changes
|
|
436
|
+
// wait for an idle boundary with no accepted input still pending, so they never reconfigure a
|
|
437
|
+
// queued prompt. Reapplying the same resolved model after a crash is safe.
|
|
438
|
+
private async applyCommandModel(command: SendPromptCommand, options: AgentOperationOptions): Promise<void> {
|
|
193
439
|
if (this.modelChangeInFlight)
|
|
194
440
|
throw new AgentPromptDeferredError({ message: 'Claude model configuration is still in progress.' })
|
|
195
441
|
const currentModel = this.init.requestedModel ?? this.metadata.model
|
|
196
|
-
if (command.model
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
442
|
+
if (!command.model || command.model === currentModel)
|
|
443
|
+
return
|
|
444
|
+
if (!this.modelChangeReady || this.eventMapperState.currentTurnId || this.commandByClientMessageId.size > 0)
|
|
445
|
+
throw new AgentPromptDeferredError({ message: 'Claude model changes require an idle turn boundary.' })
|
|
446
|
+
this.modelChangeInFlight = true
|
|
447
|
+
try {
|
|
448
|
+
await this.query.setModel(command.model)
|
|
449
|
+
}
|
|
450
|
+
finally {
|
|
451
|
+
this.modelChangeInFlight = false
|
|
452
|
+
}
|
|
453
|
+
this.metadata.model = command.model
|
|
454
|
+
this.init.requestedModel = command.model
|
|
455
|
+
this.current = { ...this.current, model: command.model }
|
|
456
|
+
this.providerApplied = this.current
|
|
457
|
+
options.signal?.throwIfAborted()
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Model admission belongs to the product; core accepts its resolved provider model ID.
|
|
461
|
+
private settingsWith(change: AgentSettingsChange | undefined): ClaudeRunSettings {
|
|
462
|
+
if (change === undefined)
|
|
463
|
+
return this.current
|
|
464
|
+
return {
|
|
465
|
+
model: change.model ?? this.current.model,
|
|
466
|
+
effort: change.reasoningEffort ?? this.current.effort,
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// The SDK user message cannot carry a model or effort, so they go as control requests, which
|
|
471
|
+
// take effect from the CLI's next model request, even inside a running turn. Both are awaited
|
|
472
|
+
// before the input is written: a steer pushed first was observed making its first request with
|
|
473
|
+
// the new model but the old effort (CLI 2.1.280). A failure proves the input was not written,
|
|
474
|
+
// but not what the CLI kept, so the next input re-applies both.
|
|
475
|
+
private async applyProviderSettings(settings: ClaudeRunSettings): Promise<void> {
|
|
476
|
+
const known = this.providerApplied
|
|
477
|
+
this.providerApplied = undefined
|
|
478
|
+
try {
|
|
479
|
+
if (known === undefined || known.model !== settings.model)
|
|
480
|
+
await this.query.setModel(settings.model)
|
|
481
|
+
if (known === undefined || known.effort !== settings.effort)
|
|
482
|
+
await this.query.applyFlagSettings({ effortLevel: settings.effort ?? null })
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
throw new AgentNotAcceptedError({
|
|
486
|
+
message: 'Claude did not apply the model or reasoning effort; the input was not written.',
|
|
487
|
+
recovery: { kind: 'retry', reason: 'provider_settings_not_applied' },
|
|
488
|
+
cause: error,
|
|
489
|
+
})
|
|
490
|
+
}
|
|
491
|
+
this.providerApplied = settings
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private carrySessionContext(clientMessageId: string): string | undefined {
|
|
495
|
+
if (this.sessionContext.status !== 'owed')
|
|
496
|
+
return undefined
|
|
497
|
+
this.sessionContext = { status: 'carried', text: this.sessionContext.text, clientMessageId }
|
|
498
|
+
return this.sessionContext.text
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
private releaseSessionContext(clientMessageId: string): void {
|
|
502
|
+
if (this.sessionContext.status === 'carried' && this.sessionContext.clientMessageId === clientMessageId)
|
|
503
|
+
this.sessionContext = { status: 'owed', text: this.sessionContext.text }
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// `started`/`completed` mean the model received the carrying prompt. A later `cancelled` (an
|
|
507
|
+
// aborted turn cancels the inputs it already started) no longer finds it carried.
|
|
508
|
+
private observeSessionContextLifecycle(frame: CommandLifecycleFrame): void {
|
|
509
|
+
if (this.sessionContext.status !== 'carried' || this.sessionContext.clientMessageId !== frame.command_uuid)
|
|
510
|
+
return
|
|
511
|
+
if (frame.state === 'started' || frame.state === 'completed')
|
|
512
|
+
this.sessionContext = { status: 'delivered' }
|
|
513
|
+
else if (frame.state !== 'queued')
|
|
514
|
+
this.releaseSessionContext(frame.command_uuid)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Mirrors CodexRun.notify's observable contract: an idle session starts a turn with the
|
|
518
|
+
// notification, and an active main turn is joined. Claude Code has no mid-turn input channel, so
|
|
519
|
+
// a joined notification is written to the session-local steered-notification store before the
|
|
520
|
+
// `steered` receipt and delivered inside that turn by the run's hooks (next main tool boundary,
|
|
521
|
+
// or a Stop-hook continuation when the turn would otherwise end), or, when it lands after that
|
|
522
|
+
// Stop hook passed, by a follow-up turn. See claude-steered-notifications.ts.
|
|
523
|
+
async notify(command: AgentNotifyCommand, options: AgentOperationOptions = {}): Promise<AgentNotificationAcceptance> {
|
|
524
|
+
this.assertCommandTargetsRun(command)
|
|
525
|
+
this.assertRunning()
|
|
526
|
+
options.signal?.throwIfAborted()
|
|
527
|
+
if (this.userInputRequests.hasUnresolvedUserInput()) {
|
|
528
|
+
throw new AgentPromptDeferredError({
|
|
529
|
+
message: 'Claude is waiting for unresolved user input; a notification cannot be submitted.',
|
|
530
|
+
})
|
|
531
|
+
}
|
|
532
|
+
const activeTurnId = this.eventMapperState.currentTurnId
|
|
533
|
+
if (activeTurnId) {
|
|
534
|
+
if (interruptTargetsTurn(this.eventMapperState.interrupt, activeTurnId)) {
|
|
535
|
+
throw new AgentPromptDeferredError({
|
|
536
|
+
message: 'Claude is interrupting the active turn; the notification waits for the next idle boundary.',
|
|
537
|
+
})
|
|
204
538
|
}
|
|
205
|
-
|
|
206
|
-
|
|
539
|
+
const queued = this.steeredNotifications.enqueue({ commandId: command.commandId, turnId: activeTurnId, text: claudeNotificationText(command) })
|
|
540
|
+
return { kind: 'turn', turnId: queued.kind === 'duplicate' ? queued.turnId : activeTurnId, placement: 'steered' }
|
|
541
|
+
}
|
|
542
|
+
if (this.notificationWaiter)
|
|
543
|
+
throw new AgentRunStateError({ message: 'A Claude notification is already awaiting its turn.' })
|
|
544
|
+
const message = toClaudeNotificationMessage(command)
|
|
545
|
+
const waiter = this.registerNotificationWaiter(deriveMessageId(command.commandId))
|
|
546
|
+
try {
|
|
547
|
+
await this.applyProviderSettings(this.current)
|
|
548
|
+
await this.deliverInput(message)
|
|
549
|
+
return await waiter.promise
|
|
550
|
+
}
|
|
551
|
+
finally {
|
|
552
|
+
if (this.notificationWaiter === waiter)
|
|
553
|
+
this.notificationWaiter = undefined
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private registerNotificationWaiter(clientMessageId: string): NotificationWaiter {
|
|
558
|
+
if (this.finished)
|
|
559
|
+
throw new AgentRunStateError({ message: 'Claude run has stopped.' })
|
|
560
|
+
const { promise, resolve, reject } = Promise.withResolvers<AgentNotificationAcceptance>()
|
|
561
|
+
promise.catch(() => undefined)
|
|
562
|
+
const waiter: NotificationWaiter = {
|
|
563
|
+
clientMessageId,
|
|
564
|
+
activeTurnIdAtSubmission: this.eventMapperState.currentTurnId,
|
|
565
|
+
consumed: false,
|
|
566
|
+
promise,
|
|
567
|
+
resolve,
|
|
568
|
+
reject,
|
|
569
|
+
}
|
|
570
|
+
this.notificationWaiter = waiter
|
|
571
|
+
return waiter
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Runs on the notification's command_lifecycle frame. `started`/`completed` mean a turn consumed
|
|
575
|
+
// it: the active turn when it was folded in, else the turn about to be reported by the mapper.
|
|
576
|
+
private settleNotificationLifecycle(waiter: NotificationWaiter, state: CommandLifecycleFrame['state']): void {
|
|
577
|
+
if (state === 'queued')
|
|
578
|
+
return
|
|
579
|
+
if (state === 'refused') {
|
|
580
|
+
this.notificationWaiter = undefined
|
|
581
|
+
waiter.reject(new AgentNotAcceptedError({
|
|
582
|
+
message: 'Claude declined the notification before queueing it; it will not run in this session.',
|
|
583
|
+
recovery: { kind: 'terminal', reason: 'provider_refused' },
|
|
584
|
+
}))
|
|
585
|
+
return
|
|
586
|
+
}
|
|
587
|
+
if (state === 'cancelled' || state === 'discarded') {
|
|
588
|
+
if (!waiter.consumed) {
|
|
589
|
+
this.notificationWaiter = undefined
|
|
590
|
+
waiter.reject(new AgentDeliveryUnknownError({
|
|
591
|
+
message: `Claude ${state} the notification before a turn was observed consuming it.`,
|
|
592
|
+
}))
|
|
207
593
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
594
|
+
return
|
|
595
|
+
}
|
|
596
|
+
if (waiter.consumed)
|
|
597
|
+
return
|
|
598
|
+
waiter.consumed = true
|
|
599
|
+
const activeTurnId = this.eventMapperState.currentTurnId
|
|
600
|
+
if (activeTurnId)
|
|
601
|
+
this.settleNotificationTurn(activeTurnId)
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// A notify joined to a turn whose Stop hook already passed has no hook left in that turn. As in
|
|
605
|
+
// Codex, where such a steer starts a new turn, a completed main turn that leaves envelopes
|
|
606
|
+
// pending starts one follow-up turn delivering them. Interrupted and failed turns, and a session
|
|
607
|
+
// waiting for user input, keep them for the next input. At most one follow-up is in flight: its
|
|
608
|
+
// own UserPromptSubmit hook takes envelopes that arrive before it starts, so this cannot loop.
|
|
609
|
+
// Envelopes recovered from disk at open do not start a turn by themselves: the supervisor resumes
|
|
610
|
+
// a run only to dispatch an input, whose UserPromptSubmit hook carries them (a `/compact` input
|
|
611
|
+
// skips that hook, but its completed turn reaches this follow-up), and a prewarmed or resumed run
|
|
612
|
+
// must not reach the model on its own before its first input.
|
|
613
|
+
private async startSteeredNotificationFollowUp(): Promise<void> {
|
|
614
|
+
if (this.finished || this.steeredFollowUp || this.userInputRequests.hasUnresolvedUserInput())
|
|
615
|
+
return
|
|
616
|
+
const delivery = this.steeredNotifications.takePending()
|
|
617
|
+
if (!delivery)
|
|
618
|
+
return
|
|
619
|
+
const followUp: SteeredNotificationFollowUp = { clientMessageId: randomUUID(), delivery }
|
|
620
|
+
this.steeredFollowUp = followUp
|
|
621
|
+
try {
|
|
622
|
+
await this.pushOwnedStream(toClaudeTaskNotificationMessage({ text: delivery.text, uuid: followUp.clientMessageId }))
|
|
623
|
+
}
|
|
624
|
+
catch (error) {
|
|
625
|
+
// A rejected push provably wrote nothing.
|
|
626
|
+
this.steeredFollowUp = undefined
|
|
627
|
+
this.steeredNotifications.restore(delivery)
|
|
628
|
+
throw error
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// `started`/`completed` mean the model received the envelopes. A non-positive state before that
|
|
633
|
+
// means Claude will never run the input, so the envelopes go back to the store for the next
|
|
634
|
+
// input instead of being lost. A run that ends before either keeps them delivered: like a hook
|
|
635
|
+
// hand-off, the input most likely already started.
|
|
636
|
+
private settleSteeredFollowUp(followUp: SteeredNotificationFollowUp, state: CommandLifecycleFrame['state']): void {
|
|
637
|
+
if (state === 'queued')
|
|
638
|
+
return
|
|
639
|
+
this.steeredFollowUp = undefined
|
|
640
|
+
if (state === 'cancelled' || state === 'discarded' || state === 'refused')
|
|
641
|
+
this.steeredNotifications.restore(followUp.delivery)
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
private settleNotificationTurn(turnId: string): void {
|
|
645
|
+
const waiter = this.notificationWaiter
|
|
646
|
+
if (!waiter?.consumed)
|
|
647
|
+
return
|
|
648
|
+
this.notificationWaiter = undefined
|
|
649
|
+
waiter.resolve({
|
|
650
|
+
kind: 'turn',
|
|
651
|
+
turnId,
|
|
652
|
+
placement: turnId === waiter.activeTurnIdAtSubmission ? 'steered' : 'started',
|
|
653
|
+
})
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async compactContext(command: CompactContextAgentCommand, options: AgentOperationOptions = {}): Promise<AgentContextCompactionRequestResult> {
|
|
657
|
+
this.assertCommandTargetsRun(command)
|
|
658
|
+
this.assertRunning()
|
|
659
|
+
options.signal?.throwIfAborted()
|
|
660
|
+
if (this.eventMapperState.currentTurnId) {
|
|
661
|
+
throw new AgentPromptDeferredError({
|
|
662
|
+
message: 'Claude context compaction must wait for an idle main thread.',
|
|
663
|
+
})
|
|
211
664
|
}
|
|
212
|
-
if (
|
|
213
|
-
throw new
|
|
214
|
-
|
|
215
|
-
//
|
|
216
|
-
// and
|
|
665
|
+
if (this.compactionWaiter)
|
|
666
|
+
throw new AgentRunStateError({ message: 'A Claude context compaction request is already pending.' })
|
|
667
|
+
|
|
668
|
+
// The deadline bounds the whole request: it cancels a submission still awaiting delivery or
|
|
669
|
+
// acceptance, and reports `unknown` for an accepted compaction whose outcome never arrived.
|
|
670
|
+
const deadline = new AbortController()
|
|
671
|
+
const operationSignal = options.signal
|
|
672
|
+
? AbortSignal.any([options.signal, deadline.signal])
|
|
673
|
+
: deadline.signal
|
|
217
674
|
const clientMessageId = deriveMessageId(command.commandId)
|
|
218
675
|
const receipt = this.registerAcceptanceWaiter(clientMessageId)
|
|
219
|
-
this.
|
|
220
|
-
this.pendingMessages.set(command.commandId, { messageId: clientMessageId, mode: command.mode, turnId: command.turnId, started: false })
|
|
676
|
+
const waiter = this.registerCompactionWaiter({ commandId: command.commandId, clientMessageId, deadline })
|
|
221
677
|
try {
|
|
222
|
-
await this.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
678
|
+
await this.applyProviderSettings(this.current)
|
|
679
|
+
await this.awaitPossiblyDeliveredCompaction(
|
|
680
|
+
this.deliverInput(toClaudeCompactContextMessage(command.commandId)),
|
|
681
|
+
operationSignal,
|
|
682
|
+
)
|
|
683
|
+
await this.awaitPossiblyDeliveredCompaction(receipt.promise, operationSignal)
|
|
684
|
+
}
|
|
685
|
+
catch (error) {
|
|
686
|
+
if (!(error instanceof AgentDeliveryUnknownError)) {
|
|
687
|
+
this.releaseCompactionWaiter(waiter)
|
|
688
|
+
throw error
|
|
689
|
+
}
|
|
690
|
+
// Not accepted: the input may never have reached Claude, so holding the guard could defer
|
|
691
|
+
// steers indefinitely.
|
|
692
|
+
return this.endCompaction(waiter, { status: 'unknown' })
|
|
229
693
|
}
|
|
230
694
|
finally {
|
|
231
695
|
this.acceptanceWaiters.delete(clientMessageId)
|
|
232
696
|
}
|
|
233
|
-
|
|
697
|
+
// Accepted: the compaction runs as a Claude turn whose events stream while it runs. Waiting
|
|
698
|
+
// for its outcome here would hold the caller, and every event queued behind it, for the
|
|
699
|
+
// whole compaction. An outcome Claude already reported is definitive; otherwise `unknown`
|
|
700
|
+
// tells the caller a turn is starting, and the outcome is reported when it arrives.
|
|
701
|
+
waiter.accepted = true
|
|
702
|
+
if (waiter.outcome)
|
|
703
|
+
return this.endCompaction(waiter, waiter.outcome)
|
|
704
|
+
return { status: 'unknown' }
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
private async awaitPossiblyDeliveredCompaction<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
708
|
+
if (signal.aborted) {
|
|
709
|
+
throw new AgentDeliveryUnknownError({
|
|
710
|
+
message: 'Claude context compaction was cancelled after delivery may have begun.',
|
|
711
|
+
cause: signal.reason,
|
|
712
|
+
})
|
|
713
|
+
}
|
|
714
|
+
return await new Promise<T>((resolve, reject) => {
|
|
715
|
+
const aborted = (): void => {
|
|
716
|
+
reject(new AgentDeliveryUnknownError({
|
|
717
|
+
message: 'Claude context compaction was cancelled after delivery may have begun.',
|
|
718
|
+
cause: signal.reason,
|
|
719
|
+
}))
|
|
720
|
+
}
|
|
721
|
+
const settle = (continuation: () => void): void => {
|
|
722
|
+
signal.removeEventListener('abort', aborted)
|
|
723
|
+
continuation()
|
|
724
|
+
}
|
|
725
|
+
signal.addEventListener('abort', aborted, { once: true })
|
|
726
|
+
promise.then(
|
|
727
|
+
value => settle(() => resolve(value)),
|
|
728
|
+
error => settle(() => reject(error)),
|
|
729
|
+
)
|
|
730
|
+
})
|
|
234
731
|
}
|
|
235
732
|
|
|
236
733
|
async respondUserInput(command: RespondUserInputAgentCommand, options: AgentOperationOptions = {}): Promise<AgentUserInputSubmission> {
|
|
@@ -242,6 +739,7 @@ export class ClaudeRun implements AgentRun {
|
|
|
242
739
|
return 'already_delivered'
|
|
243
740
|
// Deliver first: on a prebound resume this is the run's first input, and the resolved
|
|
244
741
|
// event must not precede the lifecycle events the init barrier emits.
|
|
742
|
+
await this.applyProviderSettings(this.current)
|
|
245
743
|
await this.deliverInput(toClaudeUserMessage({ prompt: resolved.prompt, mode: 'next', commandId: command.commandId }))
|
|
246
744
|
if (resolved.event)
|
|
247
745
|
this.pushEvent(resolved.event)
|
|
@@ -254,6 +752,7 @@ export class ClaudeRun implements AgentRun {
|
|
|
254
752
|
this.assertRunning()
|
|
255
753
|
options.signal?.throwIfAborted()
|
|
256
754
|
const turnId = this.eventMapperState.currentTurnId
|
|
755
|
+
// A targeted interrupt never affects a replacement turn.
|
|
257
756
|
if (command.turnId && command.turnId !== turnId)
|
|
258
757
|
return
|
|
259
758
|
if (!turnId)
|
|
@@ -292,36 +791,141 @@ export class ClaudeRun implements AgentRun {
|
|
|
292
791
|
async stop(command: StopAgentCommand, options: AgentOperationOptions = {}): Promise<void> {
|
|
293
792
|
this.assertCommandTargetsRun(command)
|
|
294
793
|
options.signal?.throwIfAborted()
|
|
295
|
-
|
|
794
|
+
// A run that already ended still resolves only after its CLI has exited, so a following
|
|
795
|
+
// resume never overlaps it on the same transcript.
|
|
796
|
+
if (this.finished) {
|
|
797
|
+
await this.ended
|
|
296
798
|
return
|
|
799
|
+
}
|
|
297
800
|
this.events.push({ type: 'session.state.changed', payload: { state: 'stopping' } })
|
|
298
801
|
if (!this.initialized)
|
|
299
802
|
this.initializationReject(new AgentRunStateError({ message: 'Claude run stopped before initialization.' }))
|
|
300
|
-
this.query.close()
|
|
301
803
|
this.finish('stopped')
|
|
804
|
+
await this.ended
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Mirrors CodexRun: a central credential failure is terminal for this run. The typed turn
|
|
808
|
+
// reason lets the supervisor recover owed work instead of observing a generic 401 result.
|
|
809
|
+
failActiveTurnAndEndRun(input: ClaudeAuthFailure): void {
|
|
810
|
+
// A run without input (an idle prewarm) made no model request, so the failure is not its
|
|
811
|
+
// own; ending it would leave a finished run for the supervisor to adopt. Its first request
|
|
812
|
+
// acquires a fresh credential and reports that outcome instead.
|
|
813
|
+
if (this.finished || !this.firstInputPushed)
|
|
814
|
+
return
|
|
815
|
+
this.pushEvent({ type: 'error', payload: { message: 'Claude authentication failed.', fatal: false, source: 'provider', error: input.error } })
|
|
816
|
+
const turnId = this.eventMapperState.currentTurnId
|
|
817
|
+
this.eventMapperState.heldTurnEnd = undefined
|
|
818
|
+
if (turnId) {
|
|
819
|
+
this.pushEvent({
|
|
820
|
+
type: 'turn.ended',
|
|
821
|
+
turnId,
|
|
822
|
+
actor: { type: 'main', actorId: 'main' },
|
|
823
|
+
payload: { status: 'failed', reason: input.reason, error: input.error },
|
|
824
|
+
})
|
|
825
|
+
this.eventMapperState.currentTurnId = undefined
|
|
826
|
+
}
|
|
827
|
+
if (!this.initialized)
|
|
828
|
+
this.initializationReject(new AgentRunStateError({ message: 'Claude authentication failed before initialization.' }))
|
|
829
|
+
this.closeQuery()
|
|
830
|
+
this.finish('failed', input.error)
|
|
302
831
|
}
|
|
303
832
|
|
|
304
833
|
finish(reason: 'completed' | 'stopped' | 'interrupted' | 'failed', error?: AgentError): void {
|
|
305
834
|
if (this.finished)
|
|
306
835
|
return
|
|
836
|
+
const compaction = this.compactionWaiter
|
|
837
|
+
if (compaction)
|
|
838
|
+
this.endCompaction(compaction, { status: 'unknown' })
|
|
839
|
+
// A superseded turn's steer can no longer continue it once the run ends.
|
|
840
|
+
for (const draft of flushHeldTurnEnd(this.eventMapperState)) this.pushEvent(draft)
|
|
307
841
|
this.finished = true
|
|
308
842
|
// The run ends before pending inputs saw their acceptance receipt: each may already be in
|
|
309
843
|
// the CLI stream, so their outcome is delivery-unknown — never a hang, retry, or terminal.
|
|
310
844
|
for (const waiter of this.acceptanceWaiters.values()) {
|
|
311
845
|
waiter.reject(new AgentDeliveryUnknownError({
|
|
312
846
|
message: `Claude run ended (${reason}) before the input's acceptance receipt was observed.`,
|
|
847
|
+
cause: error,
|
|
313
848
|
}))
|
|
314
849
|
}
|
|
315
850
|
this.acceptanceWaiters.clear()
|
|
851
|
+
this.eventMapperState.promptCommandByInputId.clear()
|
|
316
852
|
this.commandByClientMessageId.clear()
|
|
317
853
|
this.pendingMessages.clear()
|
|
854
|
+
const notification = this.notificationWaiter
|
|
855
|
+
this.notificationWaiter = undefined
|
|
856
|
+
notification?.reject(new AgentDeliveryUnknownError({
|
|
857
|
+
message: `Claude run ended (${reason}) before a turn was observed consuming the notification.`,
|
|
858
|
+
cause: error,
|
|
859
|
+
}))
|
|
860
|
+
this.ended = this.endAfterProcessExit(reason, error)
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// session.ended means the CLI is gone: the terminal events wait for its exit (bounded by
|
|
864
|
+
// CLAUDE_PROCESS_EXIT_TIMEOUT_MS, then a process-group kill). close() is idempotent and sends
|
|
865
|
+
// the stdin EOF the CLI exits on.
|
|
866
|
+
private async endAfterProcessExit(reason: 'completed' | 'stopped' | 'interrupted' | 'failed', error: AgentError | undefined): Promise<void> {
|
|
867
|
+
this.closeQuery()
|
|
868
|
+
await this.process.terminate()
|
|
318
869
|
this.events.push({ type: 'session.ended', payload: error ? { reason, error } : { reason } })
|
|
319
870
|
this.events.push({ type: 'session.state.changed', payload: { state: reason === 'failed' ? 'failed' : 'stopped' } })
|
|
320
871
|
this.events.end()
|
|
872
|
+
void this.onFinished().catch(() => undefined)
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Reports the request's one result; later calls return the reported one. The caller decides
|
|
876
|
+
// whether the compaction has ended and releases the waiter.
|
|
877
|
+
private reportCompactionResult(waiter: CompactionWaiter, result: AgentContextCompactionRequestResult): AgentContextCompactionRequestResult {
|
|
878
|
+
if (waiter.reported)
|
|
879
|
+
return waiter.reported
|
|
880
|
+
waiter.reported = result
|
|
881
|
+
clearTimeout(waiter.timeout)
|
|
882
|
+
this.pushEvent({
|
|
883
|
+
type: 'context.compaction.request.completed',
|
|
884
|
+
payload: { commandId: waiter.commandId, result },
|
|
885
|
+
})
|
|
886
|
+
return result
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
private endCompaction(waiter: CompactionWaiter, result: AgentContextCompactionRequestResult): AgentContextCompactionRequestResult {
|
|
890
|
+
const reported = this.reportCompactionResult(waiter, result)
|
|
891
|
+
this.releaseCompactionWaiter(waiter)
|
|
892
|
+
return reported
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
private releaseCompactionWaiter(waiter: CompactionWaiter): void {
|
|
896
|
+
clearTimeout(waiter.timeout)
|
|
897
|
+
if (this.compactionWaiter === waiter)
|
|
898
|
+
this.compactionWaiter = undefined
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// First outcome wins. Before acceptance the submitting call reports it (see compactContext).
|
|
902
|
+
private settleCompaction(waiter: CompactionWaiter, outcome: AgentContextCompactionRequestResult): void {
|
|
903
|
+
waiter.outcome ??= outcome
|
|
904
|
+
if (waiter.accepted)
|
|
905
|
+
this.endCompaction(waiter, waiter.outcome)
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
private registerCompactionWaiter({ commandId, clientMessageId, deadline }: { commandId: string, clientMessageId: string, deadline: AbortController }): CompactionWaiter {
|
|
909
|
+
if (this.compactionWaiter)
|
|
910
|
+
throw new AgentRunStateError({ message: 'A Claude context compaction request is already pending.' })
|
|
911
|
+
const waiter: CompactionWaiter = {
|
|
912
|
+
commandId,
|
|
913
|
+
clientMessageId,
|
|
914
|
+
timeout: setTimeout(() => {
|
|
915
|
+
// Before acceptance the abort makes the submitting call report and release. After it,
|
|
916
|
+
// Claude may still be compacting: the result becomes `unknown`, the guard stays.
|
|
917
|
+
deadline.abort(new Error(`Claude context compaction exceeded ${this.compactionTimeoutMs}ms.`))
|
|
918
|
+
if (waiter.accepted)
|
|
919
|
+
this.reportCompactionResult(waiter, { status: 'unknown' })
|
|
920
|
+
}, this.compactionTimeoutMs),
|
|
921
|
+
accepted: false,
|
|
922
|
+
boundaryObserved: false,
|
|
923
|
+
}
|
|
924
|
+
waiter.timeout.unref?.()
|
|
925
|
+
this.compactionWaiter = waiter
|
|
926
|
+
return waiter
|
|
321
927
|
}
|
|
322
928
|
|
|
323
|
-
// One pending acceptance per derived command uuid; the promise is pre-observed so a rejection
|
|
324
|
-
// while the caller is still awaiting delivery/init can never become an unhandled rejection.
|
|
325
929
|
private registerAcceptanceWaiter(clientMessageId: string): AcceptanceWaiter {
|
|
326
930
|
if (this.finished)
|
|
327
931
|
throw new AgentRunStateError({ message: 'Claude run has stopped.' })
|
|
@@ -341,26 +945,16 @@ export class ClaudeRun implements AgentRun {
|
|
|
341
945
|
|
|
342
946
|
// Strict pinned-runtime boundary for the undocumented receipt frame: shape/state deviation and
|
|
343
947
|
// session identity mismatch are provider protocol drift and fail the run closed. `cancelled`
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
private flushSteerResult(continueTurn = false): void {
|
|
355
|
-
const result = this.pendingSteerResult
|
|
356
|
-
this.pendingSteerResult = undefined
|
|
357
|
-
if (result) {
|
|
358
|
-
for (const draft of mapClaudeMessage(result, this.eventMapperState, { continueTurn }))
|
|
359
|
-
this.pushEvent(draft)
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
private settleAcceptance(frame: ClaudeCommandLifecycleMessage): void {
|
|
948
|
+
// and `discarded` are valid non-positive states that are validated and no-op even when a
|
|
949
|
+
// waiter is pending: the pending prompt's real outcome is settled by run close/error as
|
|
950
|
+
// delivery-unknown. `refused` is the pinned runtime's definite pre-queue rejection: the input
|
|
951
|
+
// will never run in this session, and the session stays open, so the pending waiter is
|
|
952
|
+
// rejected as a terminal not-accepted outcome instead of waiting for a close that may not
|
|
953
|
+
// come. Valid positive frames without a pending waiter are internal lifecycle for
|
|
954
|
+
// already-settled or non-prompt inputs and are ignored. The synthetic `/compact` input is the
|
|
955
|
+
// exception: every negative state for its uuid settles the pending compaction as `failed`, because
|
|
956
|
+
// the request has a definite result to report while the session keeps running.
|
|
957
|
+
private settleAcceptance(frame: ClaudeCommandLifecycleMessage): AgentEventDraft[] {
|
|
364
958
|
const parsed = commandLifecycleFrameSchema.safeParse(frame)
|
|
365
959
|
if (!parsed.success) {
|
|
366
960
|
throw new AgentProviderProtocolError({
|
|
@@ -374,40 +968,130 @@ export class ClaudeRun implements AgentRun {
|
|
|
374
968
|
details: { expected: this.metadata.providerSessionId, actual: parsed.data.session_id },
|
|
375
969
|
})
|
|
376
970
|
}
|
|
377
|
-
|
|
378
|
-
|
|
971
|
+
this.observeSessionContextLifecycle(parsed.data)
|
|
972
|
+
const steerDrafts = this.observeSteerLifecycle(parsed.data)
|
|
973
|
+
this.settleInputLifecycle(parsed.data)
|
|
974
|
+
return steerDrafts
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// A steer's receipts before system/init belong to the session's first input, which supersedes
|
|
978
|
+
// nothing; afterwards they drive the mapper's supersession of the running ask.
|
|
979
|
+
private observeSteerLifecycle(frame: CommandLifecycleFrame): AgentEventDraft[] {
|
|
980
|
+
if (!this.steerInputIds.has(frame.command_uuid))
|
|
981
|
+
return []
|
|
982
|
+
if (frame.state !== 'queued' && frame.state !== 'started')
|
|
983
|
+
this.steerInputIds.delete(frame.command_uuid)
|
|
984
|
+
if (!this.initialized)
|
|
985
|
+
return []
|
|
986
|
+
return mapSteerLifecycle({ steerId: frame.command_uuid, state: frame.state }, this.eventMapperState)
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
private settleInputLifecycle(frame: CommandLifecycleFrame): void {
|
|
990
|
+
const followUp = this.steeredFollowUp
|
|
991
|
+
if (followUp?.clientMessageId === frame.command_uuid) {
|
|
992
|
+
this.settleSteeredFollowUp(followUp, frame.state)
|
|
993
|
+
return
|
|
994
|
+
}
|
|
995
|
+
const notification = this.notificationWaiter
|
|
996
|
+
if (notification?.clientMessageId === frame.command_uuid) {
|
|
997
|
+
this.settleNotificationLifecycle(notification, frame.state)
|
|
998
|
+
return
|
|
999
|
+
}
|
|
1000
|
+
const waiter = this.acceptanceWaiters.get(frame.command_uuid)
|
|
1001
|
+
const commandId = this.commandByClientMessageId.get(frame.command_uuid)
|
|
1002
|
+
if (frame.state === 'cancelled' || frame.state === 'discarded' || frame.state === 'refused') {
|
|
1003
|
+
this.commandByClientMessageId.delete(frame.command_uuid)
|
|
379
1004
|
if (commandId)
|
|
380
1005
|
this.pendingMessages.delete(commandId)
|
|
381
|
-
this.
|
|
382
|
-
|
|
1006
|
+
const compaction = this.compactionWaiter
|
|
1007
|
+
if (compaction?.clientMessageId === frame.command_uuid) {
|
|
1008
|
+
this.settleCompaction(compaction, {
|
|
1009
|
+
status: 'failed',
|
|
1010
|
+
error: { message: 'Claude refused context compaction.' },
|
|
1011
|
+
})
|
|
1012
|
+
waiter?.resolve()
|
|
1013
|
+
return
|
|
1014
|
+
}
|
|
1015
|
+
if (frame.state === 'refused') {
|
|
1016
|
+
this.eventMapperState.promptCommandByInputId.delete(frame.command_uuid)
|
|
1017
|
+
waiter?.reject(new AgentNotAcceptedError({
|
|
1018
|
+
message: 'Claude declined the input before queueing it; it will not run in this session.',
|
|
1019
|
+
recovery: { kind: 'terminal', reason: 'provider_refused' },
|
|
1020
|
+
}))
|
|
1021
|
+
return
|
|
1022
|
+
}
|
|
1023
|
+
// A cancelled (steered-over or aborted) or discarded input will not be accepted; the
|
|
1024
|
+
// waiter settles now so command processing can advance while the session stays open.
|
|
1025
|
+
waiter?.reject(new AgentDeliveryUnknownError({
|
|
1026
|
+
message: `Claude command ended as ${frame.state} before acceptance was observed.`,
|
|
383
1027
|
}))
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
// A queued receipt acknowledges admission, not turn ownership. Keep the
|
|
390
|
-
// mapping after acceptance settles: the provider's started receipt is
|
|
391
|
-
// the authority for the next root turn, even with several queued inputs.
|
|
392
|
-
if (parsed.data.state === 'started') {
|
|
393
|
-
const pending = commandId ? this.pendingMessages.get(commandId) : undefined
|
|
394
|
-
// Only a native started receipt can join the interrupted segment to a steer.
|
|
395
|
-
// A queued receipt alone must never suppress the logical turn's completion.
|
|
396
|
-
this.flushSteerResult(pending?.mode === 'steer' && (!pending.turnId || pending.turnId === this.eventMapperState.currentTurnId))
|
|
1028
|
+
return
|
|
1029
|
+
}
|
|
1030
|
+
// A queued receipt acknowledges admission, not turn ownership: the started receipt binds the
|
|
1031
|
+
// command to the turn that consumes it, even with several queued inputs.
|
|
1032
|
+
if (frame.state === 'started' && commandId) {
|
|
397
1033
|
this.eventMapperState.currentCommandId = commandId
|
|
398
|
-
|
|
1034
|
+
const pending = this.pendingMessages.get(commandId)
|
|
1035
|
+
if (pending) {
|
|
399
1036
|
pending.started = true
|
|
400
1037
|
const turnId = this.eventMapperState.currentTurnId
|
|
401
1038
|
if (pending.mode === 'steer' && turnId && (!pending.turnId || pending.turnId === turnId))
|
|
402
1039
|
this.emitAcceptedMessage(commandId, turnId)
|
|
403
1040
|
}
|
|
404
1041
|
}
|
|
405
|
-
if (
|
|
406
|
-
this.commandByClientMessageId.delete(
|
|
1042
|
+
if (frame.state === 'completed') {
|
|
1043
|
+
this.commandByClientMessageId.delete(frame.command_uuid)
|
|
407
1044
|
if (commandId)
|
|
408
1045
|
this.pendingMessages.delete(commandId)
|
|
409
1046
|
}
|
|
410
|
-
|
|
1047
|
+
waiter?.resolve()
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// An accepted compaction runs as the next main turn, since it is submitted only while idle. If that
|
|
1051
|
+
// turn ends without a correlated outcome, the compaction is over: report `unknown` if nothing was
|
|
1052
|
+
// reported yet and stop deferring steers.
|
|
1053
|
+
private endCompactionWithItsTurn(drafts: AgentEventDraft[]): void {
|
|
1054
|
+
const waiter = this.compactionWaiter
|
|
1055
|
+
if (waiter?.accepted && drafts.some(draft => draft.type === 'turn.ended' && draft.actor.type === 'main'))
|
|
1056
|
+
this.endCompaction(waiter, { status: 'unknown' })
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
private observeCompactionResult(message: SDKMessage): void {
|
|
1060
|
+
const waiter = this.compactionWaiter
|
|
1061
|
+
if (!waiter)
|
|
1062
|
+
return
|
|
1063
|
+
if (message.type === 'system' && message.subtype === 'compact_boundary') {
|
|
1064
|
+
if (message.compact_metadata.trigger === 'manual')
|
|
1065
|
+
waiter.boundaryObserved = true
|
|
1066
|
+
return
|
|
1067
|
+
}
|
|
1068
|
+
if (message.type === 'system' && message.subtype === 'status' && message.compact_result === 'failed') {
|
|
1069
|
+
this.settleCompaction(waiter, {
|
|
1070
|
+
status: 'failed',
|
|
1071
|
+
error: compactionError(message),
|
|
1072
|
+
})
|
|
1073
|
+
return
|
|
1074
|
+
}
|
|
1075
|
+
if (message.type !== 'result')
|
|
1076
|
+
return
|
|
1077
|
+
const commandIds = resultUserMessageIds(message)
|
|
1078
|
+
if (!commandIds.includes(waiter.clientMessageId))
|
|
1079
|
+
return
|
|
1080
|
+
if (message.subtype !== 'success' || message.is_error) {
|
|
1081
|
+
this.settleCompaction(waiter, {
|
|
1082
|
+
status: 'failed',
|
|
1083
|
+
error: resultError(message),
|
|
1084
|
+
})
|
|
1085
|
+
return
|
|
1086
|
+
}
|
|
1087
|
+
if (waiter.boundaryObserved) {
|
|
1088
|
+
this.settleCompaction(waiter, { status: 'completed' })
|
|
1089
|
+
return
|
|
1090
|
+
}
|
|
1091
|
+
this.settleCompaction(waiter, {
|
|
1092
|
+
status: 'no_op',
|
|
1093
|
+
reason: message.result,
|
|
1094
|
+
})
|
|
411
1095
|
}
|
|
412
1096
|
|
|
413
1097
|
// Exactly one message may enter the stream before init: the first input wakes the CLI and then
|
|
@@ -482,31 +1166,31 @@ export class ClaudeRun implements AgentRun {
|
|
|
482
1166
|
// Acceptance receipts are routed before PRE_INIT and normal mapping: the first
|
|
483
1167
|
// input's queued frame legitimately precedes system/init.
|
|
484
1168
|
if (message.type === 'command_lifecycle') {
|
|
485
|
-
this.
|
|
1169
|
+
const interrupt = this.eventMapperState.interrupt
|
|
1170
|
+
await this.emitDrafts(this.settleAcceptance(message), interrupt)
|
|
486
1171
|
continue
|
|
487
1172
|
}
|
|
488
|
-
|
|
489
|
-
|
|
1173
|
+
// A skill reload can precede the first input, so the command list is routed to the
|
|
1174
|
+
// catalog before the PRE_INIT barrier; it is catalog input, not a session event.
|
|
1175
|
+
if (message.type === 'system' && message.subtype === 'commands_changed') {
|
|
1176
|
+
this.skillCatalog.publish(message.commands)
|
|
490
1177
|
continue
|
|
491
1178
|
}
|
|
492
|
-
this.
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
1179
|
+
if (!this.initialized) {
|
|
1180
|
+
if (isClaudePreInitHookMessage(message)) {
|
|
1181
|
+
this.admitPreInitializationHook(message)
|
|
1182
|
+
continue
|
|
1183
|
+
}
|
|
1184
|
+
if (message.type === 'system' && message.subtype === 'session_state_changed') {
|
|
1185
|
+
this.admitPreInitializationSessionState(message)
|
|
1186
|
+
continue
|
|
1187
|
+
}
|
|
1188
|
+
this.completeInitialization(message)
|
|
1189
|
+
for (const held of this.preInitSessionStates.splice(0))
|
|
1190
|
+
await this.mapMessage(held)
|
|
501
1191
|
continue
|
|
502
1192
|
}
|
|
503
|
-
|
|
504
|
-
? await hydrateClaudeToolOutput(message, {
|
|
505
|
-
transcriptPath: this.metadata.transcriptPath,
|
|
506
|
-
toolNameById: this.eventMapperState.toolNameById,
|
|
507
|
-
})
|
|
508
|
-
: message
|
|
509
|
-
for (const draft of mapClaudeMessage(completeMessage, this.eventMapperState)) this.pushEvent(draft)
|
|
1193
|
+
await this.mapMessage(message)
|
|
510
1194
|
}
|
|
511
1195
|
// An intentional stop already finalized the run; the ended stream is expected then.
|
|
512
1196
|
if (this.finished)
|
|
@@ -514,7 +1198,13 @@ export class ClaudeRun implements AgentRun {
|
|
|
514
1198
|
if (!this.initialized) {
|
|
515
1199
|
throw new AgentProviderProtocolError({ message: 'Claude SDK ended before emitting system init.' })
|
|
516
1200
|
}
|
|
517
|
-
|
|
1201
|
+
// A superseded ask's held end is final once the stream can deliver no steer to continue it.
|
|
1202
|
+
await this.emitDrafts(flushHeldTurnEnd(this.eventMapperState), this.eventMapperState.interrupt)
|
|
1203
|
+
// The SDK reports non-zero exits and signals as errors, but a clean exit mid-turn ends the stream
|
|
1204
|
+
// without a result: that turn did not complete.
|
|
1205
|
+
if (this.eventMapperState.currentTurnId) {
|
|
1206
|
+
throw new AgentProviderProtocolError({ message: 'Claude SDK ended before the active turn reported its result.' })
|
|
1207
|
+
}
|
|
518
1208
|
this.finish('completed')
|
|
519
1209
|
}
|
|
520
1210
|
catch (error) {
|
|
@@ -525,9 +1215,58 @@ export class ClaudeRun implements AgentRun {
|
|
|
525
1215
|
}
|
|
526
1216
|
}
|
|
527
1217
|
|
|
1218
|
+
private async mapMessage(message: SDKMessage): Promise<void> {
|
|
1219
|
+
const completeMessage = this.hydratePersistedToolOutput && message.type === 'user'
|
|
1220
|
+
? await hydrateClaudeToolOutput(message, {
|
|
1221
|
+
transcriptPath: this.metadata.transcriptPath,
|
|
1222
|
+
toolNameById: this.eventMapperState.toolNameById,
|
|
1223
|
+
})
|
|
1224
|
+
: message
|
|
1225
|
+
// The mapper resets the interrupt when it ends the turn; the follow-up needs it.
|
|
1226
|
+
const interrupt = this.eventMapperState.interrupt
|
|
1227
|
+
const drafts = mapClaudeMessage(completeMessage, this.eventMapperState)
|
|
1228
|
+
for (const draft of drafts) this.pushEvent(draft)
|
|
1229
|
+
// A compaction's request result follows the lifecycle events of the message that settles it.
|
|
1230
|
+
this.observeCompactionResult(message)
|
|
1231
|
+
this.endCompactionWithItsTurn(drafts)
|
|
1232
|
+
await this.followUpCompletedTurn(drafts, interrupt)
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
private async emitDrafts(drafts: AgentEventDraft[], interrupt: ClaudeEventMapperState['interrupt']): Promise<void> {
|
|
1236
|
+
for (const draft of drafts) this.pushEvent(draft)
|
|
1237
|
+
await this.followUpCompletedTurn(drafts, interrupt)
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
private async followUpCompletedTurn(drafts: AgentEventDraft[], interrupt: ClaudeEventMapperState['interrupt']): Promise<void> {
|
|
1241
|
+
if (drafts.some(draft => draft.type === 'turn.ended' && draft.actor.type === 'main' && draft.payload.status === 'completed' && !interruptTargetsTurn(interrupt, draft.turnId)))
|
|
1242
|
+
await this.startSteeredNotificationFollowUp()
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
private admitPreInitializationSessionState(message: Extract<SDKMessage, { type: 'system', subtype: 'session_state_changed' }>): void {
|
|
1246
|
+
if (message.session_id !== this.init.expectedSessionId) {
|
|
1247
|
+
throw new AgentProviderProtocolError({
|
|
1248
|
+
message: 'Claude SDK pre-init session state session_id does not match the requested session.',
|
|
1249
|
+
details: { expected: this.init.expectedSessionId, actual: message.session_id },
|
|
1250
|
+
})
|
|
1251
|
+
}
|
|
1252
|
+
this.preInitSessionStates.push(message)
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
private admitPreInitializationHook(message: ClaudePreInitHookMessage): void {
|
|
1256
|
+
if (message.session_id !== this.init.expectedSessionId) {
|
|
1257
|
+
throw new AgentProviderProtocolError({
|
|
1258
|
+
message: 'Claude SDK pre-init hook session_id does not match the requested session.',
|
|
1259
|
+
details: { expected: this.init.expectedSessionId, actual: message.session_id },
|
|
1260
|
+
})
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
528
1264
|
private completeInitialization(message: SDKMessage): void {
|
|
529
1265
|
if (message.type !== 'system' || message.subtype !== 'init') {
|
|
530
|
-
throw new AgentProviderProtocolError({
|
|
1266
|
+
throw new AgentProviderProtocolError({
|
|
1267
|
+
message: 'Claude SDK emitted an unexpected message before system init.',
|
|
1268
|
+
details: { type: message.type, subtype: 'subtype' in message ? message.subtype : undefined },
|
|
1269
|
+
})
|
|
531
1270
|
}
|
|
532
1271
|
const init: ClaudeInitMessage = message
|
|
533
1272
|
if (!init.session_id) {
|
|
@@ -539,7 +1278,7 @@ export class ClaudeRun implements AgentRun {
|
|
|
539
1278
|
details: { expected: this.init.expectedSessionId, actual: init.session_id },
|
|
540
1279
|
})
|
|
541
1280
|
}
|
|
542
|
-
const effectiveModel = init.model ?? this.
|
|
1281
|
+
const effectiveModel = init.model ?? this.current.model
|
|
543
1282
|
if (!effectiveModel) {
|
|
544
1283
|
throw new AgentProviderMetadataError({ message: 'Claude SDK init did not include model.', details: { provider: 'claude' } })
|
|
545
1284
|
}
|
|
@@ -549,25 +1288,26 @@ export class ClaudeRun implements AgentRun {
|
|
|
549
1288
|
this.pushEvent(sessionConfiguredEvent({
|
|
550
1289
|
model: effectiveModel,
|
|
551
1290
|
cwd: init.cwd ?? this.metadata.cwd,
|
|
552
|
-
reasoningEffort: this.
|
|
1291
|
+
reasoningEffort: this.current.effort,
|
|
553
1292
|
toolNames: init.tools,
|
|
554
1293
|
environment: this.init.environment,
|
|
555
1294
|
}))
|
|
556
1295
|
this.pushEvent({ type: 'session.state.changed', payload: { state: 'ready' } })
|
|
557
1296
|
for (const draft of mapClaudeMessage(init, this.eventMapperState)) this.pushEvent(draft)
|
|
558
1297
|
this.initialized = true
|
|
1298
|
+
this.skillCatalog.markReady()
|
|
559
1299
|
this.recordStartup?.('session_ready')
|
|
560
|
-
this.initializationResolve()
|
|
1300
|
+
this.initializationResolve(effectiveModel)
|
|
561
1301
|
}
|
|
562
1302
|
|
|
563
1303
|
private failRun(error: unknown): void {
|
|
564
1304
|
if (this.finished)
|
|
565
1305
|
return
|
|
566
|
-
const agentError = serializeAgentError(error)
|
|
1306
|
+
const agentError = withProcessStderr(serializeAgentError(error), this.process.stderrTail())
|
|
567
1307
|
if (!this.initialized) {
|
|
568
1308
|
// Pre-init failure: the first-input promise must reject typed instead of hanging.
|
|
569
1309
|
this.initializationReject(error)
|
|
570
|
-
this.
|
|
1310
|
+
this.closeQuery()
|
|
571
1311
|
this.finish('failed', agentError)
|
|
572
1312
|
return
|
|
573
1313
|
}
|
|
@@ -575,6 +1315,14 @@ export class ClaudeRun implements AgentRun {
|
|
|
575
1315
|
this.finish('failed', agentError)
|
|
576
1316
|
}
|
|
577
1317
|
|
|
1318
|
+
// Query.close() ends the CLI's stdin; every ending path and the final exit wait share one call.
|
|
1319
|
+
private closeQuery(): void {
|
|
1320
|
+
if (this.queryClosed)
|
|
1321
|
+
return
|
|
1322
|
+
this.queryClosed = true
|
|
1323
|
+
this.query.close()
|
|
1324
|
+
}
|
|
1325
|
+
|
|
578
1326
|
private assertRunning(): void {
|
|
579
1327
|
if (this.finished)
|
|
580
1328
|
throw new AgentRunStateError({ message: 'Claude run has stopped.' })
|
|
@@ -586,3 +1334,18 @@ export class ClaudeRun implements AgentRun {
|
|
|
586
1334
|
}
|
|
587
1335
|
}
|
|
588
1336
|
}
|
|
1337
|
+
|
|
1338
|
+
// The SDK appends the CLI's stderr tail to its process-exit errors only for its own spawn; the run
|
|
1339
|
+
// spawns the CLI itself (claude-code-process.ts), so it restores that diagnostic here.
|
|
1340
|
+
function withProcessStderr(error: AgentError, stderrTail: string): AgentError {
|
|
1341
|
+
if (!stderrTail || !/^Claude Code process (?:exited with code|terminated by signal) \S+$/u.test(error.message))
|
|
1342
|
+
return error
|
|
1343
|
+
return { ...error, message: `${error.message}. stderr: ${stderrTail}` }
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// SDK 0.3.280 reports every input a result consumed; older pinned runtimes report only one.
|
|
1347
|
+
function resultUserMessageIds(message: Extract<SDKMessage, { type: 'result' }>): readonly string[] {
|
|
1348
|
+
if ('user_message_uuids' in message && Array.isArray(message.user_message_uuids))
|
|
1349
|
+
return message.user_message_uuids.filter((id): id is string => typeof id === 'string')
|
|
1350
|
+
return message.user_message_uuid ? [message.user_message_uuid] : []
|
|
1351
|
+
}
|