@meistrari/agent-core 0.0.0 → 0.1.1

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.
Files changed (85) hide show
  1. package/README.md +36 -3
  2. package/bin/supervisor.ts +116 -0
  3. package/package.json +42 -3
  4. package/scripts/build-supervisor-executable.ts +32 -0
  5. package/src/agents/agent-error-serializer.ts +23 -0
  6. package/src/agents/agent-event-stream.ts +81 -0
  7. package/src/agents/agent-id.ts +17 -0
  8. package/src/agents/agent-operation.ts +11 -0
  9. package/src/agents/agent-provider.ts +37 -0
  10. package/src/agents/agent-run.ts +32 -0
  11. package/src/agents/agent-runtime-error.ts +161 -0
  12. package/src/agents/agent-session-events.ts +32 -0
  13. package/src/agents/agent-tool-runner.ts +83 -0
  14. package/src/agents/agent-tool.ts +111 -0
  15. package/src/agents/author-context.ts +36 -0
  16. package/src/agents/claude/claude-command-mapper.ts +234 -0
  17. package/src/agents/claude/claude-event-mapper.ts +748 -0
  18. package/src/agents/claude/claude-provider.ts +191 -0
  19. package/src/agents/claude/claude-run.ts +468 -0
  20. package/src/agents/claude/claude-tool-mapper.ts +186 -0
  21. package/src/agents/claude/index.ts +1 -0
  22. package/src/agents/codex/codex-auth.ts +34 -0
  23. package/src/agents/codex/codex-command-mapper.ts +78 -0
  24. package/src/agents/codex/codex-event-mapper.ts +721 -0
  25. package/src/agents/codex/codex-json-rpc-client.ts +326 -0
  26. package/src/agents/codex/codex-protocol.ts +36 -0
  27. package/src/agents/codex/codex-provider.ts +1050 -0
  28. package/src/agents/codex/codex-run.ts +404 -0
  29. package/src/agents/codex/codex-skill-catalog.ts +158 -0
  30. package/src/agents/codex/codex-skill-roots.ts +19 -0
  31. package/src/agents/codex/codex-tool-mapper.ts +55 -0
  32. package/src/agents/codex/codex.errors.ts +68 -0
  33. package/src/agents/codex/generated/meta.gen.ts +606 -0
  34. package/src/agents/codex/generated/namespaces.gen.ts +311 -0
  35. package/src/agents/codex/generated/schema.gen.ts +34883 -0
  36. package/src/agents/codex/index.ts +2 -0
  37. package/src/agents/index.ts +14 -0
  38. package/src/agents/input-attachment-preparation.ts +86 -0
  39. package/src/agents/input-attachment.errors.ts +16 -0
  40. package/src/agents/instructions.ts +8 -0
  41. package/src/agents/materialized-input-attachment.ts +26 -0
  42. package/src/agents/message-id.ts +23 -0
  43. package/src/agents/normalize.ts +8 -0
  44. package/src/agents/sandbox-environment.ts +1 -0
  45. package/src/agents/tools/ping.tool.ts +13 -0
  46. package/src/agents/user-input-request.ts +470 -0
  47. package/src/protocol/agent-event.ts +2 -1
  48. package/src/protocol/agent-usage.ts +14 -0
  49. package/src/provenance.gen.ts +3 -3
  50. package/src/supervisor/agent-provider-factory.ts +189 -0
  51. package/src/supervisor/bootstrap-binder.ts +133 -0
  52. package/src/supervisor/config.ts +49 -0
  53. package/src/supervisor/control-authority-verifier.ts +135 -0
  54. package/src/supervisor/create-supervisor-runtime.ts +25 -0
  55. package/src/supervisor/errors.ts +24 -0
  56. package/src/supervisor/index.ts +34 -0
  57. package/src/supervisor/persistence/json.ts +21 -0
  58. package/src/supervisor/persistence/state-discovery.ts +56 -0
  59. package/src/supervisor/persistence/supervisor-store.ts +364 -0
  60. package/src/supervisor/ports/index.ts +109 -0
  61. package/src/supervisor/provider-factory.ts +37 -0
  62. package/src/supervisor/resident.ts +143 -0
  63. package/src/supervisor/rpc-client.ts +120 -0
  64. package/src/supervisor/runtime-handler.ts +309 -0
  65. package/src/supervisor/websocket-server.ts +435 -0
  66. package/src/supervisor-protocol/bootstrap.ts +1 -1
  67. package/src/template-onboarding.ts +47 -0
  68. package/src/testing/es256-test-keys.ts +73 -0
  69. package/src/testing/in-memory-runtime-control-plane.ts +206 -0
  70. package/src/testing/index.ts +6 -0
  71. package/src/testing/loopback-supervisor-connection.ts +71 -0
  72. package/src/testing/scripted-provider.ts +64 -0
  73. package/src/worker-runtime-client/command-pump.ts +127 -0
  74. package/src/worker-runtime-client/connection-attempt.ts +349 -0
  75. package/src/worker-runtime-client/control-authority-signer.ts +100 -0
  76. package/src/worker-runtime-client/e2b-supervisor-connection.ts +102 -0
  77. package/src/worker-runtime-client/frame-processor.ts +178 -0
  78. package/src/worker-runtime-client/index.ts +27 -0
  79. package/src/worker-runtime-client/lease-reconciler.ts +14 -0
  80. package/src/worker-runtime-client/ports.ts +139 -0
  81. package/src/worker-runtime-client/postgres-notification-listener.ts +91 -0
  82. package/src/worker-runtime-client/rpc-dispatcher.ts +27 -0
  83. package/src/worker-runtime-client/rpc-request-manager.ts +141 -0
  84. package/src/worker-runtime-client/sandbox-connection-runtime.ts +301 -0
  85. package/src/worker-runtime-client/token-crypto.ts +46 -0
@@ -0,0 +1,191 @@
1
+ import type { Options, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'
2
+ import type { ClaudeModelId } from '../../protocol'
3
+ import type { AgentOperationOptions } from '../agent-operation'
4
+ import type { AgentProvider, AgentSessionOpenInput, AgentSessionResumeInput } from '../agent-provider'
5
+ import type { AgentTool } from '../agent-tool'
6
+ import type { InstructionComposer } from '../instructions'
7
+ import { homedir } from 'node:os'
8
+ import { join } from 'node:path'
9
+ import { query as queryClaude } from '@anthropic-ai/claude-agent-sdk'
10
+ import { claudeModelIdSchema } from '../../protocol'
11
+ import { AgentRunStateError } from '../agent-runtime-error'
12
+ import { selectAgentTools } from '../agent-tool'
13
+ import { emptyInstructionComposer } from '../instructions'
14
+ import { removeUndefined } from '../normalize'
15
+ import { UserInputRequestStore, userInputRequestStoreRootFromTranscriptPath } from '../user-input-request'
16
+ import { createClaudeEventMapperState } from './claude-event-mapper'
17
+ import { ClaudeRun } from './claude-run'
18
+ import { claudeAllowedToolNames, claudeToolActorHooks, createClaudeToolRuntimeRef, toClaudeMcpServers } from './claude-tool-mapper'
19
+
20
+ export interface ClaudeProviderConfig {
21
+ tools?: readonly AgentTool[]
22
+ executable?: string
23
+ environment?: Record<string, string | undefined>
24
+ instructions?: InstructionComposer
25
+ }
26
+
27
+ export class ClaudeProvider implements AgentProvider<'claude'> {
28
+ readonly id = 'claude' as const
29
+ private readonly tools: readonly AgentTool[]
30
+ private readonly executable: string | undefined
31
+ private readonly environment: Record<string, string | undefined>
32
+ private readonly instructions: InstructionComposer
33
+
34
+ private constructor(config: ClaudeProviderConfig) {
35
+ this.tools = config.tools ?? []
36
+ this.executable = config.executable
37
+ this.environment = config.environment ?? {}
38
+ this.instructions = config.instructions ?? emptyInstructionComposer
39
+ }
40
+
41
+ static create(config: ClaudeProviderConfig = {}): ClaudeProvider {
42
+ return new ClaudeProvider(config)
43
+ }
44
+
45
+ async openSession(input: AgentSessionOpenInput<'claude'>, options: AgentOperationOptions = {}): Promise<ClaudeRun> {
46
+ // Prebind a caller-minted UUID (Options.sessionId) so the binding is known before any input.
47
+ const providerSessionId = crypto.randomUUID()
48
+ return await this.openRun({ input, session: { sessionId: providerSessionId }, expectedSessionId: providerSessionId, operationOptions: options })
49
+ }
50
+
51
+ async resumeSession(input: AgentSessionResumeInput<'claude'>, options: AgentOperationOptions = {}): Promise<ClaudeRun> {
52
+ return await this.openRun({ input, session: { resume: input.providerSessionId }, expectedSessionId: input.providerSessionId, operationOptions: options })
53
+ }
54
+
55
+ private async openRun(params: {
56
+ input: AgentSessionOpenInput<'claude'> | AgentSessionResumeInput<'claude'>
57
+ session: { sessionId?: string, resume?: string }
58
+ expectedSessionId: string
59
+ operationOptions: AgentOperationOptions
60
+ }): Promise<ClaudeRun> {
61
+ const { input, session, expectedSessionId, operationOptions } = params
62
+ operationOptions.signal?.throwIfAborted()
63
+ const abortController = new AbortController()
64
+ operationOptions.signal?.addEventListener('abort', () => abortController.abort(), { once: true })
65
+ // Lifecycle applies no user input: the stream stays empty until the run receives commands.
66
+ const inputQueue = createInputQueue()
67
+ const configDir = this.environment.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude')
68
+ const tools = selectAgentTools(this.tools, input.tools)
69
+ const state = createClaudeEventMapperState()
70
+ const runtime = createClaudeToolRuntimeRef({ turnId: () => state.currentTurnId })
71
+ const sdkOptions: Options = removeUndefined({
72
+ abortController,
73
+ cwd: input.cwd,
74
+ env: this.environment,
75
+ model: input.model,
76
+ thinking: { type: 'adaptive', display: 'summarized' },
77
+ effort: input.reasoningEffort,
78
+ sessionId: session.sessionId,
79
+ resume: session.resume,
80
+ includePartialMessages: true,
81
+ forwardSubagentText: true,
82
+ agentProgressSummaries: true,
83
+ permissionMode: 'bypassPermissions',
84
+ allowDangerouslySkipPermissions: true,
85
+ tools: { type: 'preset', preset: 'claude_code' },
86
+ systemPrompt: { type: 'preset', preset: 'claude_code', append: this.instructions({ provider: 'claude', tools }) },
87
+ disallowedTools: ['AskUserQuestion', 'EnterPlanMode', 'ExitPlanMode', 'NotebookEdit', 'EnterWorktree', 'ExitWorktree'],
88
+ mcpServers: toClaudeMcpServers({
89
+ tools,
90
+ runtime,
91
+ signal: abortController.signal,
92
+ }),
93
+ allowedTools: ['Bash', ...claudeAllowedToolNames(tools)],
94
+ hooks: claudeToolActorHooks(tools),
95
+ pathToClaudeCodeExecutable: this.executable,
96
+ })
97
+
98
+ const query = queryClaude({ prompt: inputQueue, options: sdkOptions })
99
+ try {
100
+ // Binding metadata comes from pre-input facts only: the live runtime emits system/init
101
+ // only after the first user message, so the run validates identity/config on the first
102
+ // input (init barrier). Observed runtime configuration arrives via session.started /
103
+ // session.configured after that validation.
104
+ const transcriptPath = join(configDir, 'projects', encodeClaudeProjectPath(input.cwd), `${expectedSessionId}.jsonl`)
105
+ const metadata = {
106
+ provider: 'claude' as const,
107
+ sessionId: input.sessionId,
108
+ providerSessionId: expectedSessionId,
109
+ cwd: input.cwd,
110
+ model: firstClaudeModelId(input.model),
111
+ transcriptPath,
112
+ }
113
+ const userInputRequests = await UserInputRequestStore.open({
114
+ sessionId: metadata.sessionId,
115
+ rootDir: userInputRequestStoreRootFromTranscriptPath(metadata.transcriptPath),
116
+ })
117
+
118
+ const run = new ClaudeRun({
119
+ metadata,
120
+ query,
121
+ userInputRequests,
122
+ input: inputQueue,
123
+ eventMapperState: state,
124
+ init: {
125
+ expectedSessionId,
126
+ requestedModel: input.model,
127
+ reasoningEffort: input.reasoningEffort,
128
+ environment: this.environment,
129
+ },
130
+ })
131
+ runtime.attach({
132
+ metadata: run.metadata,
133
+ requestUserInput: async request => await run.requestUserInput(request),
134
+ })
135
+ run.begin()
136
+ return run
137
+ }
138
+ catch (error) {
139
+ query.close()
140
+ inputQueue.close()
141
+ throw error
142
+ }
143
+ }
144
+ }
145
+
146
+ function firstClaudeModelId(...values: Array<string | null | undefined>): ClaudeModelId | undefined {
147
+ for (const value of values) {
148
+ const parsed = claudeModelIdSchema.safeParse(value)
149
+ if (parsed.success)
150
+ return parsed.data
151
+ }
152
+ return undefined
153
+ }
154
+
155
+ function createInputQueue(): AsyncIterable<SDKUserMessage> & { push: (message: SDKUserMessage) => Promise<void>, close: () => void } {
156
+ const items: SDKUserMessage[] = []
157
+ const waiters: Array<(value: IteratorResult<SDKUserMessage>) => void> = []
158
+ let closed = false
159
+
160
+ return {
161
+ async push(message) {
162
+ // Never drop input silently: a closed stream means the run has ended.
163
+ if (closed)
164
+ throw new AgentRunStateError({ message: 'Claude input stream is closed.' })
165
+ const waiter = waiters.shift()
166
+ if (waiter)
167
+ waiter({ value: message, done: false })
168
+ else items.push(message)
169
+ },
170
+ close() {
171
+ closed = true
172
+ for (const waiter of waiters.splice(0)) waiter({ value: undefined, done: true })
173
+ },
174
+ [Symbol.asyncIterator]() {
175
+ return {
176
+ async next(): Promise<IteratorResult<SDKUserMessage>> {
177
+ const item = items.shift()
178
+ if (item)
179
+ return await Promise.resolve({ value: item, done: false })
180
+ if (closed)
181
+ return await Promise.resolve({ value: undefined, done: true })
182
+ return await new Promise(resolve => waiters.push(resolve))
183
+ },
184
+ }
185
+ },
186
+ }
187
+ }
188
+
189
+ function encodeClaudeProjectPath(cwd: string): string {
190
+ return cwd.replaceAll('/', '-')
191
+ }
@@ -0,0 +1,468 @@
1
+ import type { Query, SDKMessage, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'
2
+ import type {
3
+ AgentError,
4
+ AgentReasoningEffort,
5
+ AgentRunMetadata,
6
+ InterruptAgentCommand,
7
+ RespondUserInputAgentCommand,
8
+ SendPromptCommand,
9
+ StopAgentCommand,
10
+ } from '../../protocol'
11
+ import type { AgentEventDraft } from '../agent-event-stream'
12
+ import type { AgentOperationOptions } from '../agent-operation'
13
+ import type { AgentPromptAcceptance, AgentRun, AgentUserInputSubmission } from '../agent-run'
14
+ import type { UserInputRequestInput, UserInputRequestStore } from '../user-input-request'
15
+ import type { ClaudeEventMapperState } from './claude-event-mapper'
16
+ import z from 'zod'
17
+ import { serializeAgentError } from '../agent-error-serializer'
18
+ import { AgentEventStream } from '../agent-event-stream'
19
+ import { AgentDeliveryUnknownError, AgentNotAcceptedError, AgentProviderMetadataError, AgentProviderProtocolError, AgentRunStateError } from '../agent-runtime-error'
20
+ import { sessionConfiguredEvent } from '../agent-session-events'
21
+ import { deriveMessageId } from '../message-id'
22
+ import { mapClaudeUserMessage, toClaudeUserMessage } from './claude-command-mapper'
23
+ import { mapClaudeMessage } from './claude-event-mapper'
24
+
25
+ // The ONE long-lived SDK input stream created with the query. SDK 0.3.207 Query.streamInput ends
26
+ // stdin after any finite iterable and the transport silently drops later writes, so every user
27
+ // message must go through this original stream instead.
28
+ interface ClaudeInputStream {
29
+ push: (message: SDKUserMessage) => Promise<void>
30
+ close: () => void
31
+ }
32
+
33
+ // Live runtime fact (CLI 2.1.207): system/init is emitted only after the first user message is
34
+ // consumed. The run therefore validates identity/config on the first input instead of at open.
35
+ interface ClaudeInitExpectation {
36
+ expectedSessionId: string
37
+ requestedModel?: string
38
+ reasoningEffort?: AgentReasoningEffort
39
+ environment: Record<string, string | undefined>
40
+ }
41
+
42
+ type ClaudeInitMessage = Extract<SDKMessage, { type: 'system', subtype: 'init' }>
43
+ interface ClaudeCommandLifecycleMessage {
44
+ type: 'command_lifecycle'
45
+ }
46
+
47
+ // The Claude prompt-acceptance receipt is a pinned, live-verified but undocumented CLI frame
48
+ // (SDK 0.3.207 / bundled CLI 2.1.207): it is absent from the SDK's exported SDKMessage union,
49
+ // and its exact shape below was proven by scripts/smoke/claude-command-acceptance-probe.ts —
50
+ // which must be rerun before any SDK/CLI upgrade. On the pinned runtime, any shape or state
51
+ // deviation is provider protocol drift and fails the run closed. The state enum mirrors the pinned
52
+ // CLI's embedded schema exactly: `queued`/`started`/`completed` are positive; `cancelled`
53
+ // (steered-over/aborted) and `discarded` (session ended with the command still queued) are
54
+ // schema-valid NEGATIVE terminals that never mean acceptance.
55
+ const commandLifecycleFrameSchema = z.strictObject({
56
+ type: z.literal('command_lifecycle'),
57
+ command_uuid: z.uuid(),
58
+ session_id: z.uuid(),
59
+ state: z.enum(['queued', 'started', 'completed', 'cancelled', 'discarded']),
60
+ uuid: z.uuid(),
61
+ })
62
+
63
+ interface AcceptanceWaiter {
64
+ promise: Promise<void>
65
+ resolve: () => void
66
+ reject: (error: unknown) => void
67
+ }
68
+
69
+ function interruptTargetsTurn(interrupt: ClaudeEventMapperState['interrupt'], turnId: string): boolean {
70
+ return interrupt.status !== 'none' && interrupt.turnId === turnId
71
+ }
72
+
73
+ interface ClaudeRunInput {
74
+ metadata: AgentRunMetadata
75
+ query: Query
76
+ userInputRequests: UserInputRequestStore
77
+ input: ClaudeInputStream
78
+ eventMapperState: ClaudeEventMapperState
79
+ init: ClaudeInitExpectation
80
+ }
81
+
82
+ export class ClaudeRun implements AgentRun {
83
+ readonly events: AgentEventStream
84
+ readonly metadata: AgentRunMetadata
85
+ private readonly query: Query
86
+ private readonly userInputRequests: UserInputRequestStore
87
+ private readonly input: ClaudeInputStream
88
+ private readonly eventMapperState: ClaudeEventMapperState
89
+ private readonly init: ClaudeInitExpectation
90
+ private readonly initialization: Promise<void>
91
+ private initializationResolve: () => void = () => undefined
92
+ private initializationReject: (error: unknown) => void = () => undefined
93
+ private initialized = false
94
+ private firstInputPushed = false
95
+ private finished = false
96
+ private interruptRequest?: { turnId: string, promise: Promise<void> }
97
+ private readonly acceptanceWaiters = new Map<string, AcceptanceWaiter>()
98
+
99
+ constructor({ metadata, query, userInputRequests, input, eventMapperState, init }: ClaudeRunInput) {
100
+ this.metadata = metadata
101
+ this.query = query
102
+ this.userInputRequests = userInputRequests
103
+ this.input = input
104
+ this.eventMapperState = eventMapperState
105
+ this.init = init
106
+ this.initialization = new Promise<void>((resolve, reject) => {
107
+ this.initializationResolve = resolve
108
+ this.initializationReject = reject
109
+ })
110
+ // The barrier can reject with nothing awaiting it (pre-init EOF/stop); keep that handled.
111
+ this.initialization.catch(() => undefined)
112
+ this.events = new AgentEventStream({
113
+ provider: 'claude',
114
+ sessionId: metadata.sessionId,
115
+ providerSessionId: metadata.providerSessionId,
116
+ })
117
+ }
118
+
119
+ // Starts the single background query consumer. command_lifecycle frames are the acceptance
120
+ // receipt: they are strictly validated and ROUTED to settle the matching sendPrompt waiter
121
+ // (never skipped, never mapped to an AgentEvent), including the first input's queued frame that
122
+ // legitimately precedes system/init. PRE_INIT is otherwise strict: the next non-lifecycle
123
+ // message must be a matching system/init, and the run's own session lifecycle AgentEvents
124
+ // (session.started/configured/state) are emitted only after that init validation.
125
+ begin(): void {
126
+ void this.consume()
127
+ }
128
+
129
+ pushEvent(draft: AgentEventDraft): void {
130
+ if (this.finished)
131
+ return
132
+ const decorated = this.userInputRequests.decorateTurnEnded(draft)
133
+ if (decorated.type === 'session.state.changed' && decorated.payload.state === 'idle' && this.userInputRequests.shouldHoldIdle()) {
134
+ this.events.push({ type: 'session.state.changed', payload: { state: 'waiting_for_user_input' } })
135
+ return
136
+ }
137
+ this.events.push(decorated)
138
+ }
139
+
140
+ async requestUserInput(input: UserInputRequestInput): Promise<{ requestId: string }> {
141
+ this.assertRunning()
142
+ const request = this.userInputRequests.create(input)
143
+ this.pushEvent(request.event)
144
+ return { requestId: request.requestId }
145
+ }
146
+
147
+ async prepareWorkspace(options: AgentOperationOptions = {}): Promise<void> {
148
+ options.signal?.throwIfAborted()
149
+ }
150
+
151
+ // Claude discovers repository guidance from the filesystem; there is no provider-side root registry.
152
+ async addWorkspaceRepositoryRoot(_root: string, options: AgentOperationOptions = {}): Promise<void> {
153
+ options.signal?.throwIfAborted()
154
+ }
155
+
156
+ async sendPrompt(command: SendPromptCommand, options: AgentOperationOptions = {}): Promise<AgentPromptAcceptance> {
157
+ this.assertCommandTargetsRun(command)
158
+ this.assertRunning()
159
+ options.signal?.throwIfAborted()
160
+ const input = { prompt: command.prompt, mode: command.mode, commandId: command.commandId, origin: command.origin, author: command.author, senderContext: options.senderContext }
161
+ const message = await mapClaudeUserMessage(input, options.inputAttachmentPreparation)
162
+ options.signal?.throwIfAborted()
163
+ // Register before the push: the CLI's queued receipt can outrun the local push resolution,
164
+ // and a receipt with no registered waiter would be lost.
165
+ const clientMessageId = deriveMessageId(command.commandId)
166
+ const receipt = this.registerAcceptanceWaiter(clientMessageId)
167
+ try {
168
+ await this.deliverInput(message)
169
+ // `submitted` requires the provider's positive acceptance receipt — the pinned
170
+ // command_lifecycle frame matching this command's derived uuid and this session —
171
+ // in addition to delivery (and, for the first input, validated init identity).
172
+ // Local queue push, transport write, and init alone are never acceptance. No timeout:
173
+ // provider close/error settles the wait as delivery-unknown.
174
+ await receipt.promise
175
+ }
176
+ finally {
177
+ this.acceptanceWaiters.delete(clientMessageId)
178
+ }
179
+ return { kind: 'submitted' }
180
+ }
181
+
182
+ async respondUserInput(command: RespondUserInputAgentCommand, options: AgentOperationOptions = {}): Promise<AgentUserInputSubmission> {
183
+ this.assertCommandTargetsRun(command)
184
+ this.assertRunning()
185
+ options.signal?.throwIfAborted()
186
+ const resolved = this.userInputRequests.resolve(command)
187
+ if (!resolved)
188
+ return 'already_delivered'
189
+ // Deliver first: on a prebound resume this is the run's first input, and the resolved
190
+ // event must not precede the lifecycle events the init barrier emits.
191
+ await this.deliverInput(toClaudeUserMessage({ prompt: resolved.prompt, mode: 'next', commandId: command.commandId }))
192
+ if (resolved.event)
193
+ this.pushEvent(resolved.event)
194
+ this.userInputRequests.markDelivered(command.requestId)
195
+ return 'submitted'
196
+ }
197
+
198
+ async interrupt(command: InterruptAgentCommand, options: AgentOperationOptions = {}): Promise<void> {
199
+ this.assertCommandTargetsRun(command)
200
+ this.assertRunning()
201
+ options.signal?.throwIfAborted()
202
+ const turnId = this.eventMapperState.currentTurnId
203
+ if (!turnId)
204
+ return
205
+ if (this.eventMapperState.interrupt.status === 'accepted' && this.eventMapperState.interrupt.turnId === turnId)
206
+ return
207
+ if (this.interruptRequest?.turnId === turnId)
208
+ return await this.interruptRequest.promise
209
+
210
+ const promise = this.requestInterrupt(turnId)
211
+ this.interruptRequest = { turnId, promise }
212
+ try {
213
+ await promise
214
+ }
215
+ finally {
216
+ if (this.interruptRequest?.promise === promise)
217
+ this.interruptRequest = undefined
218
+ }
219
+ }
220
+
221
+ private async requestInterrupt(turnId: string): Promise<void> {
222
+ this.eventMapperState.interrupt = { status: 'requesting', turnId }
223
+ try {
224
+ // `still_queued` describes future Claude inputs; foreground-turn interruption does not cancel them.
225
+ await this.query.interrupt()
226
+ if (this.eventMapperState.interrupt.status === 'requesting' && this.eventMapperState.interrupt.turnId === turnId)
227
+ this.eventMapperState.interrupt = { status: 'accepted', turnId }
228
+ }
229
+ catch (error) {
230
+ if (interruptTargetsTurn(this.eventMapperState.interrupt, turnId))
231
+ this.eventMapperState.interrupt = { status: 'none' }
232
+ throw error
233
+ }
234
+ }
235
+
236
+ async stop(command: StopAgentCommand, options: AgentOperationOptions = {}): Promise<void> {
237
+ this.assertCommandTargetsRun(command)
238
+ options.signal?.throwIfAborted()
239
+ if (this.finished)
240
+ return
241
+ this.events.push({ type: 'session.state.changed', payload: { state: 'stopping' } })
242
+ if (!this.initialized)
243
+ this.initializationReject(new AgentRunStateError({ message: 'Claude run stopped before initialization.' }))
244
+ this.query.close()
245
+ this.finish('stopped')
246
+ }
247
+
248
+ finish(reason: 'completed' | 'stopped' | 'interrupted' | 'failed', error?: AgentError): void {
249
+ if (this.finished)
250
+ return
251
+ this.finished = true
252
+ // The run ends before pending inputs saw their acceptance receipt: each may already be in
253
+ // the CLI stream, so their outcome is delivery-unknown — never a hang, retry, or terminal.
254
+ for (const waiter of this.acceptanceWaiters.values()) {
255
+ waiter.reject(new AgentDeliveryUnknownError({
256
+ message: `Claude run ended (${reason}) before the input's acceptance receipt was observed.`,
257
+ }))
258
+ }
259
+ this.acceptanceWaiters.clear()
260
+ this.events.push({ type: 'session.ended', payload: error ? { reason, error } : { reason } })
261
+ this.events.push({ type: 'session.state.changed', payload: { state: reason === 'failed' ? 'failed' : 'stopped' } })
262
+ this.events.end()
263
+ }
264
+
265
+ // One pending acceptance per derived command uuid; the promise is pre-observed so a rejection
266
+ // while the caller is still awaiting delivery/init can never become an unhandled rejection.
267
+ private registerAcceptanceWaiter(clientMessageId: string): AcceptanceWaiter {
268
+ if (this.finished)
269
+ throw new AgentRunStateError({ message: 'Claude run has stopped.' })
270
+ if (this.acceptanceWaiters.has(clientMessageId))
271
+ throw new AgentRunStateError({ message: 'A prompt with this command id is already awaiting acceptance.' })
272
+ let resolve: () => void = () => undefined
273
+ let reject: (error: unknown) => void = () => undefined
274
+ const promise = new Promise<void>((promiseResolve, promiseReject) => {
275
+ resolve = promiseResolve
276
+ reject = promiseReject
277
+ })
278
+ promise.catch(() => undefined)
279
+ const waiter: AcceptanceWaiter = { promise, resolve, reject }
280
+ this.acceptanceWaiters.set(clientMessageId, waiter)
281
+ return waiter
282
+ }
283
+
284
+ // Strict pinned-runtime boundary for the undocumented receipt frame: shape/state deviation and
285
+ // session identity mismatch are provider protocol drift and fail the run closed. `cancelled`
286
+ // (a steered-over/aborted command) and `discarded` (the session ended with the command still
287
+ // queued) are valid observed states but NEGATIVE terminals — never a positive acceptance, so
288
+ // they reject any pending waiter as delivery-unknown so command processing can advance.
289
+ // Valid positive frames without a pending
290
+ // waiter are internal lifecycle for already-settled or non-prompt inputs and are ignored.
291
+ private settleAcceptance(frame: ClaudeCommandLifecycleMessage): void {
292
+ const parsed = commandLifecycleFrameSchema.safeParse(frame)
293
+ if (!parsed.success) {
294
+ throw new AgentProviderProtocolError({
295
+ message: 'Claude emitted a command_lifecycle frame with an unrecognized shape or state.',
296
+ cause: parsed.error,
297
+ })
298
+ }
299
+ if (parsed.data.session_id !== this.metadata.providerSessionId) {
300
+ throw new AgentProviderProtocolError({
301
+ message: 'Claude command_lifecycle frame targets a different session.',
302
+ details: { expected: this.metadata.providerSessionId, actual: parsed.data.session_id },
303
+ })
304
+ }
305
+ if (parsed.data.state === 'cancelled' || parsed.data.state === 'discarded') {
306
+ this.acceptanceWaiters.get(parsed.data.command_uuid)?.reject(new AgentDeliveryUnknownError({
307
+ message: `Claude command ended as ${parsed.data.state} before acceptance was observed.`,
308
+ }))
309
+ return
310
+ }
311
+ this.acceptanceWaiters.get(parsed.data.command_uuid)?.resolve()
312
+ }
313
+
314
+ // Exactly one message may enter the stream before init: the first input wakes the CLI and then
315
+ // awaits identity/config validation; a concurrent input waits for the shared barrier first.
316
+ private async deliverInput(message: SDKUserMessage): Promise<void> {
317
+ if (this.initialized) {
318
+ await this.pushOwnedStream(message)
319
+ return
320
+ }
321
+ if (!this.firstInputPushed) {
322
+ // Claim the slot before the push so a concurrent input waits on the barrier, but
323
+ // release it on failure: the owned queue rejects before enqueue, so a failed first
324
+ // push provably wrote nothing and the next input may claim the slot again.
325
+ this.firstInputPushed = true
326
+ try {
327
+ await this.pushOwnedStream(message)
328
+ }
329
+ catch (error) {
330
+ this.firstInputPushed = false
331
+ throw error
332
+ }
333
+ try {
334
+ await this.initialization
335
+ }
336
+ catch (error) {
337
+ // The message already entered the CLI stream, so an init failure cannot prove
338
+ // non-delivery: retrying could duplicate the input.
339
+ throw new AgentDeliveryUnknownError({
340
+ message: 'Claude initialization failed after the first input entered the stream.',
341
+ cause: error,
342
+ })
343
+ }
344
+ return
345
+ }
346
+ try {
347
+ await this.initialization
348
+ }
349
+ catch (error) {
350
+ // This input was never written: the barrier failed while it waited (proven not accepted).
351
+ throw new AgentNotAcceptedError({
352
+ message: 'Claude initialization failed before this input was written.',
353
+ recovery: { kind: 'retry', reason: 'provider_initialization_failed' },
354
+ cause: error,
355
+ })
356
+ }
357
+ await this.pushOwnedStream(message)
358
+ }
359
+
360
+ // The owned input queue rejects synchronously before enqueue when the stream is closed, so a
361
+ // rejected push is proven non-delivery and safe to retry against a fresh run.
362
+ private async pushOwnedStream(message: SDKUserMessage): Promise<void> {
363
+ try {
364
+ await this.input.push(message)
365
+ }
366
+ catch (error) {
367
+ if (error instanceof AgentRunStateError) {
368
+ throw new AgentNotAcceptedError({
369
+ message: 'Claude input stream is closed; the input was not written.',
370
+ recovery: { kind: 'retry', reason: 'provider_stream_closed' },
371
+ cause: error,
372
+ })
373
+ }
374
+ throw error
375
+ }
376
+ }
377
+
378
+ private async consume(): Promise<void> {
379
+ try {
380
+ for await (const raw of this.query) {
381
+ const message = raw as SDKMessage | ClaudeCommandLifecycleMessage
382
+ // Acceptance receipts are routed before PRE_INIT and normal mapping: the first
383
+ // input's queued frame legitimately precedes system/init.
384
+ if (message.type === 'command_lifecycle') {
385
+ this.settleAcceptance(message)
386
+ continue
387
+ }
388
+ if (!this.initialized) {
389
+ this.completeInitialization(message)
390
+ continue
391
+ }
392
+ for (const draft of mapClaudeMessage(message, this.eventMapperState)) this.pushEvent(draft)
393
+ }
394
+ // An intentional stop already finalized the run; the ended stream is expected then.
395
+ if (this.finished)
396
+ return
397
+ if (!this.initialized) {
398
+ throw new AgentProviderProtocolError({ message: 'Claude SDK ended before emitting system init.' })
399
+ }
400
+ this.finish('completed')
401
+ }
402
+ catch (error) {
403
+ this.failRun(error)
404
+ }
405
+ finally {
406
+ this.input.close()
407
+ }
408
+ }
409
+
410
+ private completeInitialization(message: SDKMessage): void {
411
+ if (message.type !== 'system' || message.subtype !== 'init') {
412
+ throw new AgentProviderProtocolError({ message: 'Claude SDK emitted an unexpected message before system init.' })
413
+ }
414
+ const init: ClaudeInitMessage = message
415
+ if (!init.session_id) {
416
+ throw new AgentProviderMetadataError({ message: 'Claude SDK init did not include session_id.', details: { provider: 'claude' } })
417
+ }
418
+ if (init.session_id !== this.init.expectedSessionId) {
419
+ throw new AgentProviderProtocolError({
420
+ message: 'Claude SDK init session_id does not match the requested session.',
421
+ details: { expected: this.init.expectedSessionId, actual: init.session_id },
422
+ })
423
+ }
424
+ const effectiveModel = init.model ?? this.init.requestedModel
425
+ if (!effectiveModel) {
426
+ throw new AgentProviderMetadataError({ message: 'Claude SDK init did not include model.', details: { provider: 'claude' } })
427
+ }
428
+
429
+ this.pushEvent({ type: 'session.started', payload: this.metadata })
430
+ this.pushEvent(sessionConfiguredEvent({
431
+ model: effectiveModel,
432
+ cwd: init.cwd ?? this.metadata.cwd,
433
+ reasoningEffort: this.init.reasoningEffort,
434
+ toolNames: init.tools,
435
+ environment: this.init.environment,
436
+ }))
437
+ this.pushEvent({ type: 'session.state.changed', payload: { state: 'ready' } })
438
+ for (const draft of mapClaudeMessage(init, this.eventMapperState)) this.pushEvent(draft)
439
+ this.initialized = true
440
+ this.initializationResolve()
441
+ }
442
+
443
+ private failRun(error: unknown): void {
444
+ if (this.finished)
445
+ return
446
+ const agentError = serializeAgentError(error)
447
+ if (!this.initialized) {
448
+ // Pre-init failure: the first-input promise must reject typed instead of hanging.
449
+ this.initializationReject(error)
450
+ this.query.close()
451
+ this.finish('failed', agentError)
452
+ return
453
+ }
454
+ this.pushEvent({ type: 'error', payload: { message: agentError.message, fatal: true, source: 'runtime', error: agentError } })
455
+ this.finish('failed', agentError)
456
+ }
457
+
458
+ private assertRunning(): void {
459
+ if (this.finished)
460
+ throw new AgentRunStateError({ message: 'Claude run has stopped.' })
461
+ }
462
+
463
+ private assertCommandTargetsRun(command: { provider: string, sessionId: string, providerSessionId: string }): void {
464
+ if (command.provider !== 'claude' || command.sessionId !== this.metadata.sessionId || command.providerSessionId !== this.metadata.providerSessionId) {
465
+ throw new AgentRunStateError({ message: 'Command does not target this Claude run.' })
466
+ }
467
+ }
468
+ }