@meistrari/agent-core 0.2.0-experimental.0 → 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 +82 -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 +876 -124
- 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 +922 -91
- 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 +428 -48
- 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 +54 -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 +4 -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/steps/contract.ts +4 -1
- package/src/steps/projector.ts +17 -1
- package/src/supervisor/agent-provider-factory.ts +60 -5
- package/src/supervisor/bootstrap-binder.ts +3 -2
- package/src/supervisor/index.ts +1 -1
- package/src/supervisor/persistence/supervisor-store.ts +22 -8
- package/src/supervisor/runtime-handler.ts +30 -2
- package/src/supervisor-protocol/agent-event-wrapper.ts +30 -0
- package/src/supervisor-protocol/bootstrap.ts +4 -1
- package/src/supervisor-protocol/command-body.ts +4 -1
- package/src/supervisor-protocol/envelopes/supervisor-to-control-plane.ts +1 -0
- package/src/testing/in-memory-runtime-control-plane.ts +118 -12
- package/src/testing/index.ts +1 -1
- package/src/worker-runtime-client/command-pump.ts +85 -10
- package/src/worker-runtime-client/connection-attempt.ts +95 -20
- package/src/worker-runtime-client/frame-processor.ts +242 -58
- package/src/worker-runtime-client/index.ts +8 -2
- package/src/worker-runtime-client/ports.ts +34 -2
- package/src/worker-runtime-client/rpc-request-manager.ts +152 -107
- package/src/worker-runtime-client/sandbox-connection-runtime.ts +72 -35
|
@@ -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,28 +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 modelChangeReady = true
|
|
100
|
-
private modelChangeInFlight = false
|
|
101
212
|
private finished = false
|
|
213
|
+
private queryClosed = false
|
|
102
214
|
private interruptRequest?: { turnId: string, promise: Promise<void> }
|
|
103
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.
|
|
104
226
|
private readonly commandByClientMessageId = new Map<string, string>()
|
|
227
|
+
// Prompts whose accepted user message has not been emitted into a turn yet.
|
|
228
|
+
private readonly pendingMessages = new Map<string, { messageId: string, mode: 'next' | 'steer', turnId?: string, started: boolean }>()
|
|
229
|
+
|
|
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
|
|
105
236
|
|
|
106
|
-
constructor({ metadata, query, userInputRequests, input, eventMapperState, init, recordStartup }: ClaudeRunInput) {
|
|
237
|
+
constructor({ metadata, query, userInputRequests, input, eventMapperState, init, steeredNotifications, sessionContext, workspace, process, onFinished, compactionTimeoutMs = CLAUDE_COMPACTION_TIMEOUT_MS, recordStartup, hydratePersistedToolOutput = true }: ClaudeRunInput) {
|
|
107
238
|
this.metadata = metadata
|
|
108
239
|
this.query = query
|
|
109
240
|
this.userInputRequests = userInputRequests
|
|
110
241
|
this.input = input
|
|
111
242
|
this.eventMapperState = eventMapperState
|
|
112
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
|
|
113
253
|
this.recordStartup = recordStartup
|
|
114
|
-
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) => {
|
|
115
258
|
this.initializationResolve = resolve
|
|
116
259
|
this.initializationReject = reject
|
|
117
260
|
})
|
|
@@ -127,9 +270,10 @@ export class ClaudeRun implements AgentRun {
|
|
|
127
270
|
// Starts the single background query consumer. command_lifecycle frames are the acceptance
|
|
128
271
|
// receipt: they are strictly validated and ROUTED to settle the matching sendPrompt waiter
|
|
129
272
|
// (never skipped, never mapped to an AgentEvent), including the first input's queued frame that
|
|
130
|
-
// legitimately precedes system/init.
|
|
131
|
-
//
|
|
132
|
-
//
|
|
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.
|
|
133
277
|
begin(): void {
|
|
134
278
|
void this.consume()
|
|
135
279
|
}
|
|
@@ -137,6 +281,8 @@ export class ClaudeRun implements AgentRun {
|
|
|
137
281
|
pushEvent(draft: AgentEventDraft): void {
|
|
138
282
|
if (this.finished)
|
|
139
283
|
return
|
|
284
|
+
if (draft.type === 'turn.started' && draft.actor.type === 'main')
|
|
285
|
+
this.settleNotificationTurn(draft.turnId)
|
|
140
286
|
if (draft.type === 'turn.started')
|
|
141
287
|
this.modelChangeReady = false
|
|
142
288
|
if (draft.type === 'session.state.changed' && draft.payload.state === 'idle')
|
|
@@ -147,6 +293,22 @@ export class ClaudeRun implements AgentRun {
|
|
|
147
293
|
return
|
|
148
294
|
}
|
|
149
295
|
this.events.push(decorated)
|
|
296
|
+
if (draft.type === 'turn.started' && draft.payload.commandId)
|
|
297
|
+
this.emitAcceptedMessage(draft.payload.commandId, draft.turnId)
|
|
298
|
+
}
|
|
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.
|
|
302
|
+
private emitAcceptedMessage(commandId: string, turnId: string): void {
|
|
303
|
+
const pending = this.pendingMessages.get(commandId)
|
|
304
|
+
if (!pending?.started || !this.initialized || this.finished)
|
|
305
|
+
return
|
|
306
|
+
if (pending.mode === 'steer' && pending.turnId && pending.turnId !== turnId)
|
|
307
|
+
return
|
|
308
|
+
this.pendingMessages.delete(commandId)
|
|
309
|
+
const actor = { type: 'main', actorId: 'main' } as const
|
|
310
|
+
this.events.push({ type: 'message.started', turnId, actor, payload: { messageId: pending.messageId, commandId, role: 'user' } })
|
|
311
|
+
this.events.push({ type: 'message.ended', turnId, actor, payload: { messageId: pending.messageId, commandId, role: 'user' } })
|
|
150
312
|
}
|
|
151
313
|
|
|
152
314
|
async requestUserInput(input: UserInputRequestInput): Promise<{ requestId: string }> {
|
|
@@ -156,60 +318,416 @@ export class ClaudeRun implements AgentRun {
|
|
|
156
318
|
return { requestId: request.requestId }
|
|
157
319
|
}
|
|
158
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.
|
|
159
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)
|
|
160
329
|
options.signal?.throwIfAborted()
|
|
330
|
+
await this.reloadSkills()
|
|
161
331
|
}
|
|
162
332
|
|
|
163
|
-
|
|
164
|
-
|
|
333
|
+
async addWorkspaceRepositoryRoot(root: string, options: AgentOperationOptions = {}): Promise<void> {
|
|
334
|
+
this.assertRunning()
|
|
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
|
|
165
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
|
|
166
355
|
}
|
|
167
356
|
|
|
168
|
-
async sendPrompt(command: SendPromptCommand, options:
|
|
357
|
+
async sendPrompt(command: SendPromptCommand, options: AgentPromptOperationOptions = {}): Promise<AgentPromptAcceptance> {
|
|
169
358
|
this.assertCommandTargetsRun(command)
|
|
170
359
|
this.assertRunning()
|
|
171
360
|
options.signal?.throwIfAborted()
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
361
|
+
if (options.submissionOrder === 'bypass' && command.mode === 'next') {
|
|
362
|
+
throw new AgentRunStateError({
|
|
363
|
+
message: 'Bypass submission requires a steer prompt.',
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
if (command.reasoningEffort !== undefined && command.reasoningEffort !== this.init.reasoningEffort)
|
|
367
|
+
throw new AgentNotAcceptedError({ message: 'Claude reasoning effort changes are not supported by this adapter.', recovery: { kind: 'terminal', reason: 'reasoning_effort_change_not_supported' } })
|
|
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> {
|
|
175
439
|
if (this.modelChangeInFlight)
|
|
176
440
|
throw new AgentPromptDeferredError({ message: 'Claude model configuration is still in progress.' })
|
|
177
441
|
const currentModel = this.init.requestedModel ?? this.metadata.model
|
|
178
|
-
if (command.model
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
+
})
|
|
186
538
|
}
|
|
187
|
-
|
|
188
|
-
|
|
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
|
+
}))
|
|
189
593
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
+
})
|
|
193
664
|
}
|
|
194
|
-
this.
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
197
674
|
const clientMessageId = deriveMessageId(command.commandId)
|
|
198
675
|
const receipt = this.registerAcceptanceWaiter(clientMessageId)
|
|
199
|
-
this.
|
|
676
|
+
const waiter = this.registerCompactionWaiter({ commandId: command.commandId, clientMessageId, deadline })
|
|
200
677
|
try {
|
|
201
|
-
await this.
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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' })
|
|
208
693
|
}
|
|
209
694
|
finally {
|
|
210
695
|
this.acceptanceWaiters.delete(clientMessageId)
|
|
211
696
|
}
|
|
212
|
-
|
|
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
|
+
})
|
|
213
731
|
}
|
|
214
732
|
|
|
215
733
|
async respondUserInput(command: RespondUserInputAgentCommand, options: AgentOperationOptions = {}): Promise<AgentUserInputSubmission> {
|
|
@@ -221,6 +739,7 @@ export class ClaudeRun implements AgentRun {
|
|
|
221
739
|
return 'already_delivered'
|
|
222
740
|
// Deliver first: on a prebound resume this is the run's first input, and the resolved
|
|
223
741
|
// event must not precede the lifecycle events the init barrier emits.
|
|
742
|
+
await this.applyProviderSettings(this.current)
|
|
224
743
|
await this.deliverInput(toClaudeUserMessage({ prompt: resolved.prompt, mode: 'next', commandId: command.commandId }))
|
|
225
744
|
if (resolved.event)
|
|
226
745
|
this.pushEvent(resolved.event)
|
|
@@ -233,6 +752,9 @@ export class ClaudeRun implements AgentRun {
|
|
|
233
752
|
this.assertRunning()
|
|
234
753
|
options.signal?.throwIfAborted()
|
|
235
754
|
const turnId = this.eventMapperState.currentTurnId
|
|
755
|
+
// A targeted interrupt never affects a replacement turn.
|
|
756
|
+
if (command.turnId && command.turnId !== turnId)
|
|
757
|
+
return
|
|
236
758
|
if (!turnId)
|
|
237
759
|
return
|
|
238
760
|
if (this.eventMapperState.interrupt.status === 'accepted' && this.eventMapperState.interrupt.turnId === turnId)
|
|
@@ -269,35 +791,141 @@ export class ClaudeRun implements AgentRun {
|
|
|
269
791
|
async stop(command: StopAgentCommand, options: AgentOperationOptions = {}): Promise<void> {
|
|
270
792
|
this.assertCommandTargetsRun(command)
|
|
271
793
|
options.signal?.throwIfAborted()
|
|
272
|
-
|
|
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
|
|
273
798
|
return
|
|
799
|
+
}
|
|
274
800
|
this.events.push({ type: 'session.state.changed', payload: { state: 'stopping' } })
|
|
275
801
|
if (!this.initialized)
|
|
276
802
|
this.initializationReject(new AgentRunStateError({ message: 'Claude run stopped before initialization.' }))
|
|
277
|
-
this.query.close()
|
|
278
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)
|
|
279
831
|
}
|
|
280
832
|
|
|
281
833
|
finish(reason: 'completed' | 'stopped' | 'interrupted' | 'failed', error?: AgentError): void {
|
|
282
834
|
if (this.finished)
|
|
283
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)
|
|
284
841
|
this.finished = true
|
|
285
842
|
// The run ends before pending inputs saw their acceptance receipt: each may already be in
|
|
286
843
|
// the CLI stream, so their outcome is delivery-unknown — never a hang, retry, or terminal.
|
|
287
844
|
for (const waiter of this.acceptanceWaiters.values()) {
|
|
288
845
|
waiter.reject(new AgentDeliveryUnknownError({
|
|
289
846
|
message: `Claude run ended (${reason}) before the input's acceptance receipt was observed.`,
|
|
847
|
+
cause: error,
|
|
290
848
|
}))
|
|
291
849
|
}
|
|
292
850
|
this.acceptanceWaiters.clear()
|
|
851
|
+
this.eventMapperState.promptCommandByInputId.clear()
|
|
293
852
|
this.commandByClientMessageId.clear()
|
|
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()
|
|
294
869
|
this.events.push({ type: 'session.ended', payload: error ? { reason, error } : { reason } })
|
|
295
870
|
this.events.push({ type: 'session.state.changed', payload: { state: reason === 'failed' ? 'failed' : 'stopped' } })
|
|
296
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
|
|
297
927
|
}
|
|
298
928
|
|
|
299
|
-
// One pending acceptance per derived command uuid; the promise is pre-observed so a rejection
|
|
300
|
-
// while the caller is still awaiting delivery/init can never become an unhandled rejection.
|
|
301
929
|
private registerAcceptanceWaiter(clientMessageId: string): AcceptanceWaiter {
|
|
302
930
|
if (this.finished)
|
|
303
931
|
throw new AgentRunStateError({ message: 'Claude run has stopped.' })
|
|
@@ -317,12 +945,16 @@ export class ClaudeRun implements AgentRun {
|
|
|
317
945
|
|
|
318
946
|
// Strict pinned-runtime boundary for the undocumented receipt frame: shape/state deviation and
|
|
319
947
|
// session identity mismatch are provider protocol drift and fail the run closed. `cancelled`
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
|
|
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[] {
|
|
326
958
|
const parsed = commandLifecycleFrameSchema.safeParse(frame)
|
|
327
959
|
if (!parsed.success) {
|
|
328
960
|
throw new AgentProviderProtocolError({
|
|
@@ -336,22 +968,130 @@ export class ClaudeRun implements AgentRun {
|
|
|
336
968
|
details: { expected: this.metadata.providerSessionId, actual: parsed.data.session_id },
|
|
337
969
|
})
|
|
338
970
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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)
|
|
1004
|
+
if (commandId)
|
|
1005
|
+
this.pendingMessages.delete(commandId)
|
|
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.`,
|
|
342
1027
|
}))
|
|
343
|
-
this.commandByClientMessageId.delete(parsed.data.command_uuid)
|
|
344
1028
|
return
|
|
345
1029
|
}
|
|
346
|
-
// A queued receipt acknowledges admission, not turn ownership
|
|
347
|
-
//
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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) {
|
|
1033
|
+
this.eventMapperState.currentCommandId = commandId
|
|
1034
|
+
const pending = this.pendingMessages.get(commandId)
|
|
1035
|
+
if (pending) {
|
|
1036
|
+
pending.started = true
|
|
1037
|
+
const turnId = this.eventMapperState.currentTurnId
|
|
1038
|
+
if (pending.mode === 'steer' && turnId && (!pending.turnId || pending.turnId === turnId))
|
|
1039
|
+
this.emitAcceptedMessage(commandId, turnId)
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
if (frame.state === 'completed') {
|
|
1043
|
+
this.commandByClientMessageId.delete(frame.command_uuid)
|
|
1044
|
+
if (commandId)
|
|
1045
|
+
this.pendingMessages.delete(commandId)
|
|
351
1046
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
+
})
|
|
355
1095
|
}
|
|
356
1096
|
|
|
357
1097
|
// Exactly one message may enter the stream before init: the first input wakes the CLI and then
|
|
@@ -426,20 +1166,31 @@ export class ClaudeRun implements AgentRun {
|
|
|
426
1166
|
// Acceptance receipts are routed before PRE_INIT and normal mapping: the first
|
|
427
1167
|
// input's queued frame legitimately precedes system/init.
|
|
428
1168
|
if (message.type === 'command_lifecycle') {
|
|
429
|
-
this.
|
|
1169
|
+
const interrupt = this.eventMapperState.interrupt
|
|
1170
|
+
await this.emitDrafts(this.settleAcceptance(message), interrupt)
|
|
1171
|
+
continue
|
|
1172
|
+
}
|
|
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)
|
|
430
1177
|
continue
|
|
431
1178
|
}
|
|
432
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
|
+
}
|
|
433
1188
|
this.completeInitialization(message)
|
|
1189
|
+
for (const held of this.preInitSessionStates.splice(0))
|
|
1190
|
+
await this.mapMessage(held)
|
|
434
1191
|
continue
|
|
435
1192
|
}
|
|
436
|
-
|
|
437
|
-
? await hydrateClaudeToolOutput(message, {
|
|
438
|
-
transcriptPath: this.metadata.transcriptPath,
|
|
439
|
-
toolNameById: this.eventMapperState.toolNameById,
|
|
440
|
-
})
|
|
441
|
-
: message
|
|
442
|
-
for (const draft of mapClaudeMessage(completeMessage, this.eventMapperState)) this.pushEvent(draft)
|
|
1193
|
+
await this.mapMessage(message)
|
|
443
1194
|
}
|
|
444
1195
|
// An intentional stop already finalized the run; the ended stream is expected then.
|
|
445
1196
|
if (this.finished)
|
|
@@ -447,6 +1198,13 @@ export class ClaudeRun implements AgentRun {
|
|
|
447
1198
|
if (!this.initialized) {
|
|
448
1199
|
throw new AgentProviderProtocolError({ message: 'Claude SDK ended before emitting system init.' })
|
|
449
1200
|
}
|
|
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
|
+
}
|
|
450
1208
|
this.finish('completed')
|
|
451
1209
|
}
|
|
452
1210
|
catch (error) {
|
|
@@ -457,9 +1215,58 @@ export class ClaudeRun implements AgentRun {
|
|
|
457
1215
|
}
|
|
458
1216
|
}
|
|
459
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
|
+
|
|
460
1264
|
private completeInitialization(message: SDKMessage): void {
|
|
461
1265
|
if (message.type !== 'system' || message.subtype !== 'init') {
|
|
462
|
-
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
|
+
})
|
|
463
1270
|
}
|
|
464
1271
|
const init: ClaudeInitMessage = message
|
|
465
1272
|
if (!init.session_id) {
|
|
@@ -471,7 +1278,7 @@ export class ClaudeRun implements AgentRun {
|
|
|
471
1278
|
details: { expected: this.init.expectedSessionId, actual: init.session_id },
|
|
472
1279
|
})
|
|
473
1280
|
}
|
|
474
|
-
const effectiveModel = init.model ?? this.
|
|
1281
|
+
const effectiveModel = init.model ?? this.current.model
|
|
475
1282
|
if (!effectiveModel) {
|
|
476
1283
|
throw new AgentProviderMetadataError({ message: 'Claude SDK init did not include model.', details: { provider: 'claude' } })
|
|
477
1284
|
}
|
|
@@ -481,25 +1288,26 @@ export class ClaudeRun implements AgentRun {
|
|
|
481
1288
|
this.pushEvent(sessionConfiguredEvent({
|
|
482
1289
|
model: effectiveModel,
|
|
483
1290
|
cwd: init.cwd ?? this.metadata.cwd,
|
|
484
|
-
reasoningEffort: this.
|
|
1291
|
+
reasoningEffort: this.current.effort,
|
|
485
1292
|
toolNames: init.tools,
|
|
486
1293
|
environment: this.init.environment,
|
|
487
1294
|
}))
|
|
488
1295
|
this.pushEvent({ type: 'session.state.changed', payload: { state: 'ready' } })
|
|
489
1296
|
for (const draft of mapClaudeMessage(init, this.eventMapperState)) this.pushEvent(draft)
|
|
490
1297
|
this.initialized = true
|
|
1298
|
+
this.skillCatalog.markReady()
|
|
491
1299
|
this.recordStartup?.('session_ready')
|
|
492
|
-
this.initializationResolve()
|
|
1300
|
+
this.initializationResolve(effectiveModel)
|
|
493
1301
|
}
|
|
494
1302
|
|
|
495
1303
|
private failRun(error: unknown): void {
|
|
496
1304
|
if (this.finished)
|
|
497
1305
|
return
|
|
498
|
-
const agentError = serializeAgentError(error)
|
|
1306
|
+
const agentError = withProcessStderr(serializeAgentError(error), this.process.stderrTail())
|
|
499
1307
|
if (!this.initialized) {
|
|
500
1308
|
// Pre-init failure: the first-input promise must reject typed instead of hanging.
|
|
501
1309
|
this.initializationReject(error)
|
|
502
|
-
this.
|
|
1310
|
+
this.closeQuery()
|
|
503
1311
|
this.finish('failed', agentError)
|
|
504
1312
|
return
|
|
505
1313
|
}
|
|
@@ -507,6 +1315,14 @@ export class ClaudeRun implements AgentRun {
|
|
|
507
1315
|
this.finish('failed', agentError)
|
|
508
1316
|
}
|
|
509
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
|
+
|
|
510
1326
|
private assertRunning(): void {
|
|
511
1327
|
if (this.finished)
|
|
512
1328
|
throw new AgentRunStateError({ message: 'Claude run has stopped.' })
|
|
@@ -518,3 +1334,18 @@ export class ClaudeRun implements AgentRun {
|
|
|
518
1334
|
}
|
|
519
1335
|
}
|
|
520
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
|
+
}
|