@tangle-network/sdk-provider-cli-base 0.0.0-stage → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,4394 @@
1
+ import { DEFAULT_INTERACTION_DECISION_TIMEOUT_MS as PERMISSION_DECISION_TIMEOUT_MS, InteractionBroker, InteractionBroker as InteractionBroker$1, InteractionHttpBridge, brokerInteractionTools, brokerInteractionTools as brokerInteractionTools$1 } from "@tangle-network/agent-core/interactions";
2
+ import { BridgeClosedError, InvalidAttachmentError, JsonRpcError, RpcRequestTimeoutError, execCli, launchCliProcess, resolveRawCapturePath, spawnCliProcess, spawnRpcBridge } from "@tangle-network/sdk-cli-runner";
3
+ import { createHash, createHmac, randomUUID } from "node:crypto";
4
+ import { AgentExecutionError, AgentExecutionOutcomeSchema, AgentRuntimeAttachmentsSchema, InteractionExecutionBindingSchema, InteractionKind, REASONING_EFFORTS, agentProfileSchema, nativeReasoningControl, normalizeInputParts } from "@tangle-network/agent-interface";
5
+ import { UNAVAILABLE_AGENT_PROFILE_SECRET_PROVIDER, applyWorkspacePlanWithSecretProvider, bindLaunchToolBinDirs, enforceWorkspacePlanSupport, ensurePublicConcurrentProfileRoots, joinSystemPromptAdditions, keepsHarnessSystemPrompt, materializeProfile, modelIdsMatch, parseSandboxToolBinDirs, preparePublicConcurrentProfileRevision, prepareSecretConcurrentProfileRevision, resolveAgentProfileResources } from "@tangle-network/agent-profile-materialize";
6
+ import { HarnessSessionContractError, assertArgvHonoursSessionContract, assertFlagsHonourSessionContract, assertStoreLocationHonoursSessionContract, compileSessionStoreGlob } from "@tangle-network/cli-agent-registry/session-contract";
7
+ import { MAX_SECRET_PROFILE_REPLAY_CHARS, RESTORED_SESSION_CONTEXT_PREAMBLE, RESTORED_SESSION_CONTEXT_TAG, buildCompleteForkContextBlock, buildForkContextBlock, createMessageStore, createSessionStore, forkSessionWithMessages, readProviderMessageIds } from "@tangle-network/sdk-session-persistence";
8
+ import { mkdir, open, readdir, writeFile } from "node:fs/promises";
9
+ import path, { basename, isAbsolute, normalize, resolve } from "node:path";
10
+ import fs, { constants, lstatSync, readFileSync } from "node:fs";
11
+ import { execFile } from "node:child_process";
12
+ import { isDeepStrictEqual, promisify } from "node:util";
13
+ import { messageStorageDirOf } from "@tangle-network/cli-agent-registry";
14
+ import { HARNESS_CONFORMANCE_PENDING, NATIVE_SESSION_CONTRACTS, configDirHomeEnv } from "@tangle-network/cli-agent-registry/native-session-contracts";
15
+ import { z } from "zod";
16
+ import { fileURLToPath, pathToFileURL } from "node:url";
17
+ import { StringDecoder } from "node:string_decoder";
18
+ import { DEFAULT_INTERACTION_MCP_SERVER_NAME, InteractionMcpServer as InteractionMcpServer$1, interactionMcpToolName } from "@tangle-network/agent-core/interactions/mcp";
19
+ //#region src/acp-process-launch.ts
20
+ /** Spawn an ACP agent over NDJSON stdio. The adapter owns abort, so no signal. */
21
+ function spawnAcpBridge(request) {
22
+ return spawnRpcBridge({
23
+ ...request,
24
+ maxStderrBytes: 65536
25
+ });
26
+ }
27
+ /**
28
+ * A JSON-RPC error with the cause the agent put in `data`: hermes sends
29
+ * `data.details`, codex-acp `data.message`.
30
+ */
31
+ function jsonRpcErrorText(err) {
32
+ const data = err.data && typeof err.data === "object" ? err.data : void 0;
33
+ const cause = [data?.details, data?.message].find((value) => typeof value === "string" && value.length > 0);
34
+ return cause && cause !== err.message ? `${err.message}: ${cause}` : err.message;
35
+ }
36
+ /**
37
+ * Overlay the agent environment on an ACP process env, dropping unset keys. An
38
+ * absent env means the inherited one, which the overlay then starts from.
39
+ */
40
+ function withAgentEnvironment(env, agent) {
41
+ if (Object.keys(agent).length === 0) return env;
42
+ const merged = {};
43
+ for (const [key, value] of Object.entries(env ?? process.env)) if (value !== void 0) merged[key] = value;
44
+ for (const [key, value] of Object.entries(agent)) if (value !== void 0) merged[key] = value;
45
+ return merged;
46
+ }
47
+ //#endregion
48
+ //#region src/acp-protocol.ts
49
+ /** The ACP protocol version this client speaks, and the oldest it accepts. */
50
+ const ACP_PROTOCOL_VERSION = 1;
51
+ const ACP_MIN_PROTOCOL_VERSION = 1;
52
+ /** Text carried by ACP content blocks, including tool-call content. */
53
+ function acpContentText(content) {
54
+ if (!content) return "";
55
+ const blocks = Array.isArray(content) ? content : [content];
56
+ const parts = [];
57
+ for (const block of blocks) {
58
+ if (!block || typeof block !== "object") continue;
59
+ if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
60
+ else if (block.type === "content" && block.content?.type === "text" && typeof block.content.text === "string") parts.push(block.content.text);
61
+ }
62
+ return parts.join("");
63
+ }
64
+ /** Map an ACP stop reason onto a turn status. */
65
+ function mapAcpStopReason(reason) {
66
+ switch (reason) {
67
+ case "end_turn": return { status: "completed" };
68
+ case "max_tokens": return {
69
+ status: "completed",
70
+ detail: "Stopped at max tokens (output may be truncated)"
71
+ };
72
+ case "max_turn_requests": return {
73
+ status: "failed",
74
+ detail: "Hit max turn-requests limit"
75
+ };
76
+ case "refusal": return {
77
+ status: "failed",
78
+ detail: "Agent refused the request"
79
+ };
80
+ case "cancelled": return {
81
+ status: "failed",
82
+ detail: "Cancelled"
83
+ };
84
+ default: return {
85
+ status: "failed",
86
+ detail: `Unknown stop reason: ${reason ?? "<missing>"}`
87
+ };
88
+ }
89
+ }
90
+ //#endregion
91
+ //#region src/reasoning-parts.ts
92
+ /**
93
+ * Assigns a stable part id and an append-only delta to a reasoning block
94
+ * emission, absorbing provider replay/growth patterns:
95
+ *
96
+ * - text exactly matches an already-tracked block → `null` (duplicate
97
+ * delivery, e.g. a final assistant message replaying streamed content —
98
+ * emit nothing). Byte-identical distinct blocks therefore dedupe to one
99
+ * part; safer than duplicate-part spam.
100
+ * - text extends the most recent block → same part id, delta = the suffix
101
+ * (cumulative re-emission of a growing block).
102
+ * - otherwise → a new block: fresh part id, delta = the full text, so
103
+ * consumers can be pure appenders (prev text for a fresh id is empty).
104
+ */
105
+ function trackReasoningBlock(state, blockText) {
106
+ if (blockText === "") return null;
107
+ state.reasoningParts ??= [];
108
+ const parts = state.reasoningParts;
109
+ if (parts.some((p) => p.text === blockText)) return null;
110
+ const last = parts[parts.length - 1];
111
+ if (last && last.text !== "" && blockText.startsWith(last.text)) {
112
+ const delta = blockText.slice(last.text.length);
113
+ last.text = blockText;
114
+ return {
115
+ partId: last.id,
116
+ text: blockText,
117
+ delta,
118
+ isNewBlock: false
119
+ };
120
+ }
121
+ const id = randomUUID();
122
+ parts.push({
123
+ id,
124
+ text: blockText
125
+ });
126
+ return {
127
+ partId: id,
128
+ text: blockText,
129
+ delta: blockText,
130
+ isNewBlock: true
131
+ };
132
+ }
133
+ //#endregion
134
+ //#region src/stream-parts.ts
135
+ /**
136
+ * The text, reasoning and tool part emissions every stream-json harness
137
+ * shares. A provider's event mapper decodes its own wire shape, then calls
138
+ * these so part ids, buffers, tool timing and processing phases stay one
139
+ * implementation across claude-code, codex, kimi, amp and factory-droids.
140
+ */
141
+ /**
142
+ * Records the CLI's native session id on the turn and announces it: the
143
+ * generic `session.updated` plus the provider-attributed observation the
144
+ * turn observer keys evidence on.
145
+ */
146
+ function sessionObservedEvents(state, provider, sessionId) {
147
+ state.sessionId = sessionId;
148
+ return [{
149
+ type: "session.updated",
150
+ sessionId
151
+ }, {
152
+ type: "native.session.observed",
153
+ provider,
154
+ nativeSessionId: sessionId
155
+ }];
156
+ }
157
+ /** Correlation fields every emitted part carries. */
158
+ function streamPartBase(state, partId) {
159
+ return {
160
+ id: partId ?? randomUUID(),
161
+ sessionID: state.sessionId ?? "",
162
+ messageID: state.messageId ?? ""
163
+ };
164
+ }
165
+ function textPartEvent(state, delta) {
166
+ state.textPartId ??= randomUUID();
167
+ return {
168
+ type: "message.part.updated",
169
+ part: {
170
+ ...streamPartBase(state, state.textPartId),
171
+ type: "text",
172
+ text: state.textBuffer
173
+ },
174
+ delta
175
+ };
176
+ }
177
+ /**
178
+ * Appends a text delta to the turn's single text part. The first text of a
179
+ * turn also signals the `generating` phase.
180
+ */
181
+ function textDeltaEvents(state, delta) {
182
+ const first = state.textBuffer.length === 0;
183
+ state.textBuffer += delta;
184
+ const update = textPartEvent(state, delta);
185
+ return first ? [{
186
+ type: "model-processing",
187
+ phase: "generating"
188
+ }, update] : [update];
189
+ }
190
+ /** The whole reply as one text part, for harnesses that do not stream. */
191
+ function finalTextEvent(state, text) {
192
+ state.textBuffer = text;
193
+ return textPartEvent(state, text);
194
+ }
195
+ /**
196
+ * Applies a cumulative text frame (the whole assistant text so far). Only the
197
+ * suffix past the buffer is emitted; a frame that does not extend the buffer
198
+ * replaces it and is emitted whole.
199
+ */
200
+ function textSnapshotEvents(state, text) {
201
+ const delta = text.startsWith(state.textBuffer) ? text.slice(state.textBuffer.length) : text;
202
+ if (!delta) return [];
203
+ state.textBuffer = text;
204
+ return [textPartEvent(state, delta)];
205
+ }
206
+ /**
207
+ * Records a thinking block under a stable part id (see trackReasoningBlock).
208
+ * With `signalPhase`, the turn's first block also signals `thinking`.
209
+ */
210
+ function reasoningEvents(state, thinking, options) {
211
+ const tracked = trackReasoningBlock(state, thinking);
212
+ if (!tracked) return [];
213
+ if (tracked.isNewBlock) state.reasoningBuffer.push(tracked.text);
214
+ else state.reasoningBuffer[state.reasoningBuffer.length - 1] = tracked.text;
215
+ const update = {
216
+ type: "message.part.updated",
217
+ part: {
218
+ ...streamPartBase(state, tracked.partId),
219
+ type: "reasoning",
220
+ text: tracked.text
221
+ },
222
+ delta: tracked.delta
223
+ };
224
+ return options.signalPhase && tracked.isNewBlock && state.reasoningBuffer.length === 1 ? [{
225
+ type: "model-processing",
226
+ phase: "thinking"
227
+ }, update] : [update];
228
+ }
229
+ /** Registers a running tool call and emits its running part. */
230
+ function toolStartEvents(state, call) {
231
+ const now = Date.now();
232
+ state.toolCalls.set(call.id, {
233
+ id: call.id,
234
+ name: call.name,
235
+ input: call.input,
236
+ startTime: now,
237
+ status: "running"
238
+ });
239
+ const toolState = {
240
+ status: "running",
241
+ input: call.input ?? {},
242
+ time: { start: now }
243
+ };
244
+ return [{
245
+ type: "message.part.updated",
246
+ part: {
247
+ ...streamPartBase(state, call.id),
248
+ type: "tool",
249
+ callID: call.id,
250
+ tool: call.name,
251
+ state: toolState
252
+ }
253
+ }];
254
+ }
255
+ /**
256
+ * Completes a tracked tool call. A successful result emits `afterSuccess`
257
+ * (file parts the provider detected) and then the `tool-result` phase. A
258
+ * result for an untracked call is a warning.
259
+ */
260
+ function toolResultEvents(state, result) {
261
+ const toolCall = state.toolCalls.get(result.id);
262
+ if (!toolCall) return [{
263
+ type: "warning",
264
+ code: "UNKNOWN_TOOL_RESULT",
265
+ message: `Received result for unknown tool call: ${result.id}`
266
+ }];
267
+ toolCall.status = result.isError ? "failed" : "completed";
268
+ toolCall.output = result.content;
269
+ const now = Date.now();
270
+ const input = toolCall.input ?? {};
271
+ const time = {
272
+ start: toolCall.startTime,
273
+ end: now
274
+ };
275
+ const metadata = result.metadata ? { metadata: result.metadata } : {};
276
+ const toolState = result.isError ? {
277
+ status: "failed",
278
+ input,
279
+ error: typeof result.content === "string" ? result.content : void 0,
280
+ output: result.content,
281
+ ...metadata,
282
+ time
283
+ } : {
284
+ status: "completed",
285
+ input,
286
+ output: result.content,
287
+ ...metadata,
288
+ time
289
+ };
290
+ const events = [{
291
+ type: "message.part.updated",
292
+ part: {
293
+ ...streamPartBase(state, result.id),
294
+ type: "tool",
295
+ callID: result.id,
296
+ tool: toolCall.name,
297
+ state: toolState
298
+ }
299
+ }];
300
+ if (!result.isError) events.push(...result.afterSuccess?.(toolCall) ?? [], {
301
+ type: "model-processing",
302
+ phase: "tool-result",
303
+ toolName: toolCall.name,
304
+ elapsedMs: now - toolCall.startTime
305
+ });
306
+ return events;
307
+ }
308
+ //#endregion
309
+ //#region src/acp-session-updates.ts
310
+ /** An agent message or thought chunk: text extends the message part, thought joins reasoning. */
311
+ function acpMessageChunkEvents(kind, update, state) {
312
+ const text = acpContentText(update.content);
313
+ if (!text) return [];
314
+ if (kind === "agent_message_chunk") return textDeltaEvents(state, text);
315
+ state.reasoningBuffer.push(text);
316
+ return [];
317
+ }
318
+ /** A new tool call: recorded as running and reported as its tool part. */
319
+ function acpToolCallEvents(update, state) {
320
+ const call = update;
321
+ const name = call.title ?? call.kind ?? "tool";
322
+ return toolStartEvents(state, {
323
+ id: call.toolCallId,
324
+ name,
325
+ input: call.rawInput
326
+ });
327
+ }
328
+ /**
329
+ * Fold one tool_call_update into the call's record. Tool output arrives as
330
+ * content blocks; rawOutput is the fallback. Returns the update's text output.
331
+ */
332
+ function applyAcpToolCallUpdate(existing, mapped, change) {
333
+ const outputText = acpContentText(change.content);
334
+ if (mapped === "running" && outputText) existing.output = typeof existing.output === "string" ? existing.output + outputText : outputText;
335
+ existing.status = mapped;
336
+ if (mapped === "completed" || mapped === "failed") {
337
+ if (outputText) existing.output = typeof existing.output === "string" && existing.output.length > 0 ? existing.output + outputText : outputText;
338
+ else if (change.rawOutput !== void 0) existing.output = change.rawOutput;
339
+ }
340
+ return outputText;
341
+ }
342
+ /** The events a call's current record reports: its settled result, or its open part. */
343
+ function acpToolCallUpdateEvents(existing, state) {
344
+ if (existing.status === "completed" || existing.status === "failed") return toolResultEvents(state, {
345
+ id: existing.id,
346
+ content: existing.output,
347
+ isError: existing.status === "failed"
348
+ });
349
+ const input = existing.input ?? {};
350
+ return [{
351
+ type: "message.part.updated",
352
+ part: {
353
+ ...streamPartBase(state, existing.id),
354
+ type: "tool",
355
+ tool: existing.name,
356
+ callID: existing.id,
357
+ state: existing.status === "running" ? {
358
+ status: "running",
359
+ input,
360
+ time: { start: existing.startTime }
361
+ } : {
362
+ status: "pending",
363
+ input
364
+ }
365
+ }
366
+ }];
367
+ }
368
+ /**
369
+ * Plan entries are the agent's step list. Fold their text into the
370
+ * reasoning record, bounded per notification and per turn so a
371
+ * misbehaving agent cannot grow turn memory or storage without limit.
372
+ */
373
+ function acpPlanEvents(update, state, provider) {
374
+ const MAX_ENTRIES_PER_UPDATE = 256;
375
+ const MAX_REASONING_ENTRIES_PER_TURN = 1024;
376
+ const MAX_ENTRY_LENGTH = 8 * 1024;
377
+ const raw = update.entries;
378
+ const entries = (Array.isArray(raw) ? raw : []).slice(0, MAX_ENTRIES_PER_UPDATE);
379
+ for (const entry of entries) {
380
+ if (state.reasoningBuffer.length >= MAX_REASONING_ENTRIES_PER_TURN) break;
381
+ if (typeof entry.content === "string" && entry.content.length > 0) state.reasoningBuffer.push(entry.content.slice(0, MAX_ENTRY_LENGTH));
382
+ }
383
+ return [{
384
+ type: "raw",
385
+ backend: provider,
386
+ event: {
387
+ sessionUpdate: "plan",
388
+ entries
389
+ }
390
+ }];
391
+ }
392
+ //#endregion
393
+ //#region src/provider-config.ts
394
+ /**
395
+ * Render a value as a digest that does not change with key order. The type
396
+ * parameter keeps the caller's own type at the call site, so the digest of a
397
+ * configuration cannot silently accept a value of an unrelated shape.
398
+ */
399
+ function stableSerialize(value) {
400
+ if (value === null) return "null";
401
+ if (value === void 0) return "undefined";
402
+ if (typeof value === "string") return JSON.stringify(value);
403
+ if (typeof value === "number") {
404
+ if (Number.isNaN(value)) return "number:NaN";
405
+ if (Object.is(value, -0)) return "number:-0";
406
+ return `number:${value}`;
407
+ }
408
+ if (typeof value === "boolean") return value ? "true" : "false";
409
+ if (typeof value === "bigint") return `bigint:${value.toString()}`;
410
+ if (Array.isArray(value)) return `[${value.map((entry) => stableSerialize(entry)).join(",")}]`;
411
+ if (typeof value === "object") return `{${Object.entries(value).filter(([, entry]) => entry !== void 0).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => `${JSON.stringify(key)}:${stableSerialize(entry)}`).join(",")}}`;
412
+ return `${typeof value}:${String(value)}`;
413
+ }
414
+ function cloneConfig(config) {
415
+ return structuredClone(config);
416
+ }
417
+ function isInteractionObject(value) {
418
+ return typeof value === "object" && value !== null && !Array.isArray(value);
419
+ }
420
+ function mergeCliProviderConfig(current, next) {
421
+ const merged = {
422
+ ...current,
423
+ ...next
424
+ };
425
+ const currentInteractions = current.interactions;
426
+ const nextInteractions = next.interactions;
427
+ if (isInteractionObject(currentInteractions) && isInteractionObject(nextInteractions)) merged.interactions = {
428
+ ...currentInteractions,
429
+ ...nextInteractions
430
+ };
431
+ return merged;
432
+ }
433
+ //#endregion
434
+ //#region src/adapter-initialization.ts
435
+ /**
436
+ * The in-flight and settled initialization of one adapter, and the pending
437
+ * shutdown: which `initialize()` call is current, which configuration it
438
+ * carries, and the generation that a `shutdown()` bumps to cancel it.
439
+ */
440
+ var InitializationTracker = class {
441
+ generation = 0;
442
+ promise = null;
443
+ configKey = null;
444
+ cancellation = null;
445
+ initializedConfigKey = null;
446
+ shutdownPromise;
447
+ providerTails = /* @__PURE__ */ new Set();
448
+ constructor(provider) {
449
+ this.provider = provider;
450
+ }
451
+ assertCurrent(generation, cancellation) {
452
+ if (generation !== this.generation || cancellation.cancelled) throw new Error(`${this.provider()} provider initialization cancelled by shutdown`);
453
+ }
454
+ /** Forget `promise` once it settles, unless a newer one replaced it. */
455
+ clear(promise) {
456
+ if (this.promise !== promise) return;
457
+ this.promise = null;
458
+ this.configKey = null;
459
+ this.cancellation = null;
460
+ }
461
+ /** Cancel the in-flight initialization and invalidate its generation. */
462
+ cancel() {
463
+ this.generation += 1;
464
+ if (this.cancellation) this.cancellation.cancelled = true;
465
+ this.promise = null;
466
+ this.configKey = null;
467
+ this.cancellation = null;
468
+ this.initializedConfigKey = null;
469
+ }
470
+ };
471
+ /**
472
+ * The adapter configuration an `initialize()` call produces: the initial
473
+ * configuration overlaid with the host's runtime values. Throws when a
474
+ * trusted-path or identity value is malformed.
475
+ */
476
+ function overlayProviderConfig(provider, initialConfig, config) {
477
+ const nextConfig = cloneConfig(initialConfig);
478
+ const runtimeAttachments = config.runtimeAttachments;
479
+ if (runtimeAttachments !== void 0) nextConfig.runtimeAttachments = structuredClone(runtimeAttachments);
480
+ const runtimeSecrets = config.runtimeSecrets;
481
+ if (runtimeSecrets !== void 0) nextConfig.runtimeSecrets = { ...runtimeSecrets };
482
+ const runtimeHome = config.runtimeHome;
483
+ if (runtimeHome !== void 0) {
484
+ if (typeof runtimeHome !== "string" || !path.isAbsolute(runtimeHome)) throw new Error(`${provider} runtimeHome must be an absolute filesystem path`);
485
+ nextConfig.runtimeHome = runtimeHome;
486
+ }
487
+ const agentHome = config.agentHome;
488
+ if (agentHome !== void 0) {
489
+ if (typeof agentHome !== "string" || !path.isAbsolute(agentHome)) throw new Error(`${provider} agentHome must be an absolute filesystem path`);
490
+ nextConfig.agentHome = agentHome;
491
+ }
492
+ const subprocessUser = config.subprocessUser;
493
+ if (subprocessUser !== void 0) {
494
+ if (process.getuid?.() !== 0 || !Number.isSafeInteger(subprocessUser.uid) || subprocessUser.uid <= 0 || !Number.isSafeInteger(subprocessUser.gid) || subprocessUser.gid <= 0) throw new Error(`${provider}: invalid trusted subprocess identity`);
495
+ nextConfig.subprocessUser = { ...subprocessUser };
496
+ if (config.stableIsolatedIdentity === true) nextConfig.stableIsolatedIdentity = true;
497
+ }
498
+ const publicProfileRevisionRoot = config.publicProfileRevisionRoot;
499
+ if (publicProfileRevisionRoot !== void 0) {
500
+ if (typeof publicProfileRevisionRoot !== "string" || !path.isAbsolute(publicProfileRevisionRoot)) throw new Error(`${provider} publicProfileRevisionRoot must be an absolute filesystem path`);
501
+ nextConfig.publicProfileRevisionRoot = publicProfileRevisionRoot;
502
+ }
503
+ const privateRevision = config;
504
+ if (privateRevision.secretProfileRevisionRoot !== void 0 || privateRevision.secretProfileNamingKeyPath !== void 0) {
505
+ if (!subprocessUser || publicProfileRevisionRoot !== void 0 || !privateRevision.secretProfileRevisionRoot || !path.isAbsolute(privateRevision.secretProfileRevisionRoot) || !privateRevision.secretProfileNamingKeyPath || !path.isAbsolute(privateRevision.secretProfileNamingKeyPath)) throw new Error(`${provider}: invalid secret profile revision root`);
506
+ nextConfig.secretProfileRevisionRoot = privateRevision.secretProfileRevisionRoot;
507
+ nextConfig.secretProfileNamingKeyPath = privateRevision.secretProfileNamingKeyPath;
508
+ }
509
+ const sandboxedRuntime = config.sandboxedRuntime;
510
+ if (sandboxedRuntime !== void 0) nextConfig.sandboxedRuntime = sandboxedRuntime === true;
511
+ const nativeAuthEnv = config.nativeAuthEnv;
512
+ if (nativeAuthEnv !== void 0) {
513
+ nextConfig.nativeAuthEnv = { ...nativeAuthEnv };
514
+ nextConfig.nativeAuthSecretEnv = config.nativeAuthSecretEnv;
515
+ }
516
+ if (config.model?.model !== void 0) nextConfig.model = config.model.model;
517
+ if (config.model?.provider !== void 0) nextConfig.provider = config.model.provider;
518
+ if (config.model?.apiKey !== void 0) nextConfig.apiKey = config.model.apiKey;
519
+ if (config.model?.baseUrl !== void 0) nextConfig.baseUrl = config.model.baseUrl;
520
+ if (config.model?.authMode !== void 0) nextConfig.authMode = config.model.authMode;
521
+ if (config.model?.authFiles !== void 0) nextConfig.authFiles = config.model.authFiles.map((file) => ({ ...file }));
522
+ return nextConfig;
523
+ }
524
+ //#endregion
525
+ //#region src/execution-lifecycle.ts
526
+ const lifecycles = /* @__PURE__ */ new WeakMap();
527
+ function executionLifecycle(owner) {
528
+ let lifecycle = lifecycles.get(owner);
529
+ if (!lifecycle) {
530
+ lifecycle = {
531
+ inFlightExecutions: /* @__PURE__ */ new Set(),
532
+ shuttingDown: false
533
+ };
534
+ lifecycles.set(owner, lifecycle);
535
+ }
536
+ return lifecycle;
537
+ }
538
+ function beginExecution(owner, callerAbortSignal) {
539
+ const lifecycle = executionLifecycle(owner);
540
+ if (lifecycle.shuttingDown) throw new Error(`${owner.provider} provider is shutting down`);
541
+ let resolveSettled;
542
+ const execution = {
543
+ controller: new AbortController(),
544
+ settled: new Promise((resolve) => {
545
+ resolveSettled = resolve;
546
+ }),
547
+ resolveSettled: () => resolveSettled()
548
+ };
549
+ lifecycle.inFlightExecutions.add(execution);
550
+ if (callerAbortSignal) {
551
+ const abortFromCaller = () => execution.controller.abort();
552
+ execution.callerAbortSignal = callerAbortSignal;
553
+ execution.callerAbortListener = abortFromCaller;
554
+ if (callerAbortSignal.aborted) execution.controller.abort();
555
+ else callerAbortSignal.addEventListener("abort", abortFromCaller, { once: true });
556
+ }
557
+ return execution;
558
+ }
559
+ function finishExecution(owner, execution) {
560
+ executionLifecycle(owner).inFlightExecutions.delete(execution);
561
+ if (execution.callerAbortSignal && execution.callerAbortListener) execution.callerAbortSignal.removeEventListener("abort", execution.callerAbortListener);
562
+ execution.resolveSettled();
563
+ }
564
+ //#endregion
565
+ //#region src/writable-home.ts
566
+ /**
567
+ * The nearest ancestor of `target` that exists, or null when even the root of
568
+ * the path is absent. A directory that does not exist yet is usable only if
569
+ * something above it can be written to, so the writability question always
570
+ * resolves against an ancestor that is really there.
571
+ */
572
+ function nearestExistingAncestor(target) {
573
+ let current = path.resolve(target);
574
+ for (;;) {
575
+ if (fs.existsSync(current)) return current;
576
+ const parent = path.dirname(current);
577
+ if (parent === current) return null;
578
+ current = parent;
579
+ }
580
+ }
581
+ /**
582
+ * True when a CLI can create and write `target`.
583
+ *
584
+ * A read-only mount is the case this exists for: the shared tool-cache root is
585
+ * mounted read-only with only named subdirectories bound read-write, so a path
586
+ * under it that has no bind mount refuses `mkdir` with `EROFS`. A CLI that
587
+ * creates its cache directory at startup then dies before it runs, and a
588
+ * caller waiting on that run observes silence, not an error.
589
+ *
590
+ * The check reads permissions and never creates anything. Creating the
591
+ * directory here would run as the sidecar's own user and leave a directory the
592
+ * agent user cannot write, which trades one unusable path for another.
593
+ *
594
+ * A directory the calling process may write but the agent subprocess may not
595
+ * still reads as usable. Ownership is the spawn layer's concern; this answers
596
+ * the mount-level question that silently kills a CLI.
597
+ */
598
+ function isWritableTarget(target) {
599
+ const ancestor = nearestExistingAncestor(target);
600
+ if (!ancestor) return false;
601
+ try {
602
+ fs.accessSync(ancestor, fs.constants.W_OK);
603
+ return true;
604
+ } catch {
605
+ return false;
606
+ }
607
+ }
608
+ /** `/root` belongs to the calling process, never to the agent subprocess. */
609
+ const READ_ONLY_ROOT_HOME = "/root";
610
+ function isRootHomePath(value) {
611
+ return value === READ_ONLY_ROOT_HOME || value.startsWith(`${READ_ONLY_ROOT_HOME}/`);
612
+ }
613
+ /**
614
+ * Whether an inherited path must not reach the CLI, given whether it can be
615
+ * written. Two independent reasons, kept apart from the filesystem probe so
616
+ * each is decidable on its own:
617
+ *
618
+ * - It sits under `/root`. The agent subprocess runs as its own user, so
619
+ * that path is wrong even while the calling process can write there.
620
+ * - Nothing can create it. A read-only mount refuses `mkdir` for every user,
621
+ * including root.
622
+ */
623
+ function shouldReplacePath(value, writable) {
624
+ return isRootHomePath(value) || !writable;
625
+ }
626
+ function mustReplace(value) {
627
+ return shouldReplacePath(value, isWritableTarget(value));
628
+ }
629
+ /**
630
+ * Point `HOME` and the XDG directories at somewhere a CLI can actually write.
631
+ *
632
+ * An inherited value is kept whenever it is usable, so an operator can still
633
+ * aim a cache at a large volume. An unusable value is replaced by the
634
+ * equivalent path under the writable home, because keeping it only moves the
635
+ * failure into the CLI, where it surfaces as a run that emits nothing.
636
+ */
637
+ function applyWritableHomeDefaults(env, workspaceRoot) {
638
+ const next = { ...env };
639
+ const writableHome = next.SIDECAR_AGENT_HOME?.trim() || next.AGENT_WORKSPACE_ROOT?.trim() || workspaceRoot;
640
+ const currentHome = next.HOME?.trim();
641
+ const home = currentHome && !mustReplace(currentHome) ? currentHome : writableHome;
642
+ for (const [key, fallback] of Object.entries(homeXdgEnv(home))) {
643
+ const current = next[key]?.trim();
644
+ if (key === "HOME" || !current || mustReplace(current)) next[key] = fallback;
645
+ }
646
+ return next;
647
+ }
648
+ /** HOME and the XDG base directories a CLI resolves under it. */
649
+ function homeXdgEnv(home) {
650
+ return {
651
+ HOME: home,
652
+ XDG_CONFIG_HOME: path.join(home, ".config"),
653
+ XDG_CACHE_HOME: path.join(home, ".cache"),
654
+ XDG_DATA_HOME: path.join(home, ".local", "share"),
655
+ XDG_STATE_HOME: path.join(home, ".local", "state")
656
+ };
657
+ }
658
+ //#endregion
659
+ //#region src/adapter-lifecycle.ts
660
+ const DEFAULT_CONTEXT_WINDOW = 2e5;
661
+ const execFileAsync = promisify(execFile);
662
+ /**
663
+ * Console-based logger fallback.
664
+ */
665
+ const consoleLogger = {
666
+ debug: (msg, meta) => console.debug(`[cli-provider] ${msg}`, meta ?? ""),
667
+ info: (msg, meta) => console.info(`[cli-provider] ${msg}`, meta ?? ""),
668
+ warn: (msg, meta) => console.warn(`[cli-provider] ${msg}`, meta ?? ""),
669
+ error: (msg, meta) => console.error(`[cli-provider] ${msg}`, meta ?? "")
670
+ };
671
+ /** Owns configuration, initialization, cancellation, and subprocess identity. */
672
+ var CliAdapterLifecycle = class {
673
+ /**
674
+ * The message log directory when the caller names none. Unset, it is the
675
+ * registry spec's `messageStorageDir` for `provider`, the one source; only
676
+ * an adapter for a provider the registry does not know sets it.
677
+ */
678
+ defaultStorageDir;
679
+ inheritProcessEnv = true;
680
+ config;
681
+ initialConfig;
682
+ logger;
683
+ sessionStore = null;
684
+ messageStore = null;
685
+ storagePath;
686
+ _storageDir;
687
+ agentEnvironment;
688
+ /** The consumer's executor for turn processes; a local spawn when absent. */
689
+ launcher;
690
+ /** Whether a turn's leftover process-group members are stopped at exit. */
691
+ ownProcessGroup;
692
+ initialized = false;
693
+ activeProcesses = /* @__PURE__ */ new Set();
694
+ initialization = new InitializationTracker(() => this.provider);
695
+ constructor(options = {}) {
696
+ executionLifecycle(this);
697
+ this.initialConfig = cloneConfig(options.config ?? {});
698
+ this.config = cloneConfig(this.initialConfig);
699
+ this.logger = options.logger ?? consoleLogger;
700
+ this.storagePath = options.storagePath ?? process.cwd();
701
+ this._storageDir = options.storageDir;
702
+ this.agentEnvironment = options.agentEnvironment;
703
+ this.launcher = options.launcher;
704
+ this.ownProcessGroup = options.ownProcessGroup ?? false;
705
+ }
706
+ /**
707
+ * Get the storage directory. Uses provided storageDir or falls back to defaultStorageDir.
708
+ */
709
+ get storageDir() {
710
+ return this._storageDir ?? this.defaultStorageDir ?? messageStorageDirOf(this.provider);
711
+ }
712
+ /** Bind a live interaction to the exact provider execution that originated it. */
713
+ interactionExecutionBinding(input, sessionId) {
714
+ if (!input.interactionBinding) throw new Error(`${this.provider} interactions require AgentExecutionInput.interactionBinding`);
715
+ const binding = InteractionExecutionBindingSchema.parse(input.interactionBinding);
716
+ if (binding.provider !== this.provider) throw new Error(`Interaction binding provider "${binding.provider}" does not match adapter "${this.provider}"`);
717
+ if (binding.sessionId !== sessionId) throw new Error(`Interaction binding session "${binding.sessionId}" does not match adapter session "${sessionId}"`);
718
+ return binding;
719
+ }
720
+ /**
721
+ * The uid/gid every CLI process this adapter spawns runs as, or undefined
722
+ * when the host process is already unprivileged.
723
+ *
724
+ * This is an adapter-wide invariant, not a per-callsite option, because
725
+ * these CLIs seed their own state on first use — a sqlite database, a log
726
+ * directory, a downloaded-binary cache under the harness HOME. An auxiliary
727
+ * invocation left at root (`mcp add`, `mcp list`, `--version`) creates that
728
+ * tree root-owned, and the streamed run, which does drop privileges, then
729
+ * cannot open its own log and exits before emitting a single event.
730
+ */
731
+ subprocessIdentity() {
732
+ const assigned = this.config.subprocessUser;
733
+ if (assigned) {
734
+ if (process.getuid?.() !== 0 || !Number.isSafeInteger(assigned.uid) || assigned.uid <= 0 || !Number.isSafeInteger(assigned.gid) || assigned.gid <= 0) throw new Error(`${this.provider}: invalid trusted subprocess identity`);
735
+ return assigned;
736
+ }
737
+ if (process.getuid?.() !== 0) return void 0;
738
+ const rawUid = process.env.AGENT_SUBPROCESS_UID ?? "1000";
739
+ const rawGid = process.env.AGENT_SUBPROCESS_GID ?? "1000";
740
+ const uid = Number(rawUid);
741
+ const gid = Number(rawGid);
742
+ if (!Number.isInteger(uid) || uid < 0 || !Number.isInteger(gid) || gid < 0) throw new Error(`${this.provider}: AGENT_SUBPROCESS_UID/AGENT_SUBPROCESS_GID must be non-negative integers (got ${rawUid}/${rawGid})`);
743
+ return {
744
+ uid,
745
+ gid
746
+ };
747
+ }
748
+ /** `subprocessIdentity` shaped for `spawn`'s options bag. */
749
+ subprocessSpawnOptions() {
750
+ return this.subprocessIdentity() ?? {};
751
+ }
752
+ /**
753
+ * Run a one-shot CLI command under this adapter's subprocess identity.
754
+ *
755
+ * Every auxiliary invocation an adapter makes goes through here so the
756
+ * identity cannot be forgotten at a call site. Auxiliary output stays out
757
+ * of the streamed turn's raw evidence.
758
+ */
759
+ runCli(config) {
760
+ return execCli({
761
+ ...config,
762
+ rawCapturePath: void 0,
763
+ ...this.config.subprocessUser ? { isolatedIdentity: this.config.subprocessUser } : {},
764
+ spawnOptions: {
765
+ ...this.subprocessSpawnOptions(),
766
+ ...config.spawnOptions
767
+ }
768
+ });
769
+ }
770
+ /**
771
+ * Run a builtin-only Node script under this adapter's subprocess identity
772
+ * and return its stdout. File work inside a directory the CLI user owns goes
773
+ * through here, so a root sidecar never creates, reads or writes there with
774
+ * its own rights: a link the agent plants reaches only what the agent could
775
+ * already reach. The script reads `args` from argv after `--` and `input`
776
+ * from stdin. A failure carries the script's stderr, not the script text.
777
+ */
778
+ async runNodeAsSubprocess(script, args, input = "") {
779
+ try {
780
+ const pending = execFileAsync(process.execPath, [
781
+ "-e",
782
+ script,
783
+ "--",
784
+ ...args
785
+ ], {
786
+ ...this.subprocessSpawnOptions(),
787
+ cwd: "/",
788
+ env: {},
789
+ timeout: 3e4,
790
+ maxBuffer: 64 * 1024 * 1024
791
+ });
792
+ pending.child.stdin?.on("error", () => {});
793
+ pending.child.stdin?.end(input);
794
+ return (await pending).stdout;
795
+ } catch (error) {
796
+ const stderr = String(error.stderr ?? "").trim();
797
+ throw new Error((stderr || (error instanceof Error ? error.message : String(error))).slice(-500));
798
+ }
799
+ }
800
+ getCapabilities() {
801
+ return {
802
+ streaming: true,
803
+ toolUse: true,
804
+ reasoning: true,
805
+ multimodal: false,
806
+ contextWindow: DEFAULT_CONTEXT_WINDOW
807
+ };
808
+ }
809
+ initialize(config) {
810
+ const attachments = config.runtimeAttachments === void 0 ? void 0 : AgentRuntimeAttachmentsSchema.safeParse(config.runtimeAttachments);
811
+ if (attachments && !attachments.success) return Promise.reject(/* @__PURE__ */ new Error("Invalid runtime MCP attachments"));
812
+ const capturedConfig = attachments?.success ? {
813
+ ...config,
814
+ runtimeAttachments: attachments.data
815
+ } : config;
816
+ const configKey = stableSerialize(capturedConfig);
817
+ if (this.initialization.promise) {
818
+ if (configKey === this.initialization.configKey) return this.initialization.promise;
819
+ return Promise.reject(/* @__PURE__ */ new Error(`${this.provider} provider cannot start a conflicting initialize() while initialization is in flight; concurrent calls must use the same configuration`));
820
+ }
821
+ if (this.initialized) {
822
+ if (configKey === this.initialization.initializedConfigKey) return Promise.resolve();
823
+ return Promise.reject(/* @__PURE__ */ new Error(`${this.provider} provider is already initialized with a different configuration; call shutdown() before reinitializing`));
824
+ }
825
+ const pendingShutdown = this.initialization.shutdownPromise;
826
+ const generation = this.initialization.generation;
827
+ const cancellation = { cancelled: false };
828
+ const initialization = Promise.resolve().then(async () => {
829
+ if (pendingShutdown) await pendingShutdown;
830
+ this.initialization.assertCurrent(generation, cancellation);
831
+ executionLifecycle(this).shuttingDown = false;
832
+ await this.initializeAtGeneration(capturedConfig, configKey, generation, cancellation);
833
+ });
834
+ this.initialization.promise = initialization;
835
+ this.initialization.configKey = configKey;
836
+ this.initialization.cancellation = cancellation;
837
+ initialization.then(() => this.initialization.clear(initialization), () => this.initialization.clear(initialization));
838
+ return initialization;
839
+ }
840
+ async initializeAtGeneration(config, configKey, generation, cancellation) {
841
+ this.initialization.assertCurrent(generation, cancellation);
842
+ const nextStoragePath = config.workspace?.rootPath || this.storagePath;
843
+ const nextConfig = overlayProviderConfig(this.provider, this.initialConfig, config);
844
+ const configuredProfile = config.profile ?? nextConfig.profile;
845
+ if (configuredProfile !== void 0) {
846
+ nextConfig.profile = await resolveAgentProfileResources(agentProfileSchema.parse(configuredProfile));
847
+ this.initialization.assertCurrent(generation, cancellation);
848
+ }
849
+ const contract = this.sessionContract();
850
+ if (contract) assertFlagsHonourSessionContract(this.provider, contract, nextConfig.extraFlags ?? [], "extraFlags");
851
+ const persistenceConfig = {
852
+ storagePath: nextStoragePath,
853
+ storageDir: this.storageDir
854
+ };
855
+ const nextSessionStore = createSessionStore(persistenceConfig);
856
+ const nextMessageStore = createMessageStore(persistenceConfig);
857
+ this.initialization.assertCurrent(generation, cancellation);
858
+ this.storagePath = nextStoragePath;
859
+ this.config = nextConfig;
860
+ this.sessionStore = nextSessionStore;
861
+ this.messageStore = nextMessageStore;
862
+ const providerTail = Promise.resolve().then(() => this.afterInitialize(config));
863
+ this.initialization.providerTails.add(providerTail);
864
+ try {
865
+ await providerTail;
866
+ this.initialization.assertCurrent(generation, cancellation);
867
+ this.initialized = true;
868
+ this.initialization.initializedConfigKey = configKey;
869
+ } catch (error) {
870
+ if (generation === this.initialization.generation) {
871
+ this.config = cloneConfig(this.initialConfig);
872
+ this.sessionStore = null;
873
+ this.messageStore = null;
874
+ this.initialized = false;
875
+ this.initialization.initializedConfigKey = null;
876
+ }
877
+ throw error;
878
+ } finally {
879
+ this.initialization.providerTails.delete(providerTail);
880
+ }
881
+ this.logger.info(`${this.provider} provider initialized`, {
882
+ storagePath: this.storagePath,
883
+ storageDir: this.storageDir,
884
+ model: this.config.model
885
+ });
886
+ }
887
+ /**
888
+ * Auth/config files the adapter needs on disk that do NOT arrive through
889
+ * `config.model.authFiles` — e.g. a synthesized `~/.openclaw/openclaw.json`
890
+ * pointing at the managed router. The sidecar materializes these through the
891
+ * hardened `materializeCliAuthFiles` path AFTER `initialize()`, so an adapter
892
+ * can derive them from the resolved config/env. Default: none.
893
+ */
894
+ getManagedAuthFiles() {
895
+ return [];
896
+ }
897
+ /** Provider-specific initialization runs inside the tracked lifecycle. */
898
+ async afterInitialize(_config) {}
899
+ /**
900
+ * The harness session contract this adapter honours (cli-agent-registry
901
+ * NATIVE_SESSION_CONTRACTS), or null for a provider that declares none.
902
+ * Registration refuses a harness spec without one; ACP overrides this per
903
+ * sub-agent.
904
+ */
905
+ sessionContract() {
906
+ return NATIVE_SESSION_CONTRACTS[this.provider] ?? null;
907
+ }
908
+ async shutdown() {
909
+ this.initialization.cancel();
910
+ this.initialized = false;
911
+ executionLifecycle(this).shuttingDown = true;
912
+ this.config = cloneConfig(this.initialConfig);
913
+ if (this.initialization.shutdownPromise) return this.initialization.shutdownPromise;
914
+ const executions = [...executionLifecycle(this).inFlightExecutions];
915
+ const processes = [...this.activeProcesses];
916
+ const providerInitializationTails = [...this.initialization.providerTails];
917
+ for (const execution of executions) execution.controller.abort();
918
+ const trackedShutdown = (async () => {
919
+ await Promise.allSettled([
920
+ ...providerInitializationTails,
921
+ ...processes.map((process) => terminateTurnProcess(process, this.activeProcesses)),
922
+ ...executions.map((execution) => execution.settled)
923
+ ]);
924
+ this.sessionStore = null;
925
+ this.messageStore = null;
926
+ executionLifecycle(this).shuttingDown = false;
927
+ this.logger.info(`${this.provider} provider shut down`);
928
+ })().finally(() => {
929
+ if (this.initialization.shutdownPromise === trackedShutdown) this.initialization.shutdownPromise = void 0;
930
+ });
931
+ this.initialization.shutdownPromise = trackedShutdown;
932
+ return trackedShutdown;
933
+ }
934
+ async healthCheck() {
935
+ try {
936
+ return (await this.runAuxiliaryCli(["--version"], 5e3)).exit.success;
937
+ } catch {
938
+ return false;
939
+ }
940
+ }
941
+ /** Run this CLI's own one-shot command (version, MCP) in the workspace. */
942
+ runAuxiliaryCli(args, timeoutMs) {
943
+ return this.runCli({
944
+ command: this.getCliPath(),
945
+ args,
946
+ cwd: this.storagePath,
947
+ env: this.applyWritableHomeDefaults(this.buildNativeExecutionEnvironment(this.config), this.storagePath),
948
+ inheritEnv: this.inheritProcessEnv,
949
+ timeoutMs
950
+ });
951
+ }
952
+ async updateConfig(config) {
953
+ const resolvedConfig = { ...config };
954
+ if (config.profile !== void 0) resolvedConfig.profile = await resolveAgentProfileResources(agentProfileSchema.parse(config.profile));
955
+ this.config = mergeCliProviderConfig(this.config, resolvedConfig);
956
+ return this.config;
957
+ }
958
+ /** Apply private native credentials to every CLI subprocess through one path. */
959
+ buildNativeExecutionEnvironment(config) {
960
+ const env = this.buildExecEnv(config);
961
+ if (config.nativeAuthSecretEnv) env[config.nativeAuthSecretEnv] = void 0;
962
+ return {
963
+ ...env,
964
+ ...config.nativeAuthEnv
965
+ };
966
+ }
967
+ getCliPath() {
968
+ return this.config.cliPath ?? this.defaultCliPath;
969
+ }
970
+ ensureInitialized() {
971
+ if (executionLifecycle(this).shuttingDown) throw new Error(`${this.provider} provider is shutting down`);
972
+ if (!this.initialized) throw new Error(`${this.provider} provider not initialized. Call initialize() first.`);
973
+ }
974
+ getSessionStore() {
975
+ if (!this.sessionStore) throw new Error("Session store not initialized");
976
+ return this.sessionStore;
977
+ }
978
+ getMessageStore() {
979
+ if (!this.messageStore) throw new Error("Message store not initialized");
980
+ return this.messageStore;
981
+ }
982
+ /** The agent environment the host supplied (CliProviderOptions.agentEnvironment). */
983
+ agentEnvironmentValues() {
984
+ return this.agentEnvironment?.() ?? {};
985
+ }
986
+ applyWritableHomeDefaults(adapterEnv, workspaceRoot) {
987
+ const env = {
988
+ ...this.agentEnvironmentValues(),
989
+ ...adapterEnv
990
+ };
991
+ const runtimeHome = this.config.runtimeHome?.trim();
992
+ if (!runtimeHome) return applyWritableHomeDefaults(env, workspaceRoot);
993
+ const agentHome = this.config.agentHome?.trim();
994
+ const configDir = agentHome ? configDirHomeEnv(this.provider, runtimeHome, agentHome) : void 0;
995
+ if (configDir) {
996
+ const adapterStores = Object.keys(configDir).flatMap((name) => {
997
+ if (name === "HOME" || name.startsWith("XDG_")) return [];
998
+ const value = adapterEnv[name]?.trim();
999
+ return value && value !== process.env[name] ? [[name, value]] : [];
1000
+ });
1001
+ return applyWritableHomeDefaults({
1002
+ ...env,
1003
+ ...configDir,
1004
+ ...Object.fromEntries(adapterStores)
1005
+ }, workspaceRoot);
1006
+ }
1007
+ return applyWritableHomeDefaults({
1008
+ ...env,
1009
+ ...homeXdgEnv(runtimeHome)
1010
+ }, workspaceRoot);
1011
+ }
1012
+ };
1013
+ async function terminateTurnProcess(process, activeProcesses) {
1014
+ process.kill();
1015
+ const escalation = setTimeout(() => process.kill("SIGKILL"), 1e3);
1016
+ escalation.unref();
1017
+ try {
1018
+ await process.exitPromise;
1019
+ } finally {
1020
+ clearTimeout(escalation);
1021
+ activeProcesses.delete(process);
1022
+ }
1023
+ }
1024
+ //#endregion
1025
+ //#region src/execution-request-headers.ts
1026
+ const headerValue = z.string().regex(/^[\x21-\x2b\x2d-\x7e]{1,256}$/);
1027
+ const CORRELATION_FIELDS = {
1028
+ "X-Tangle-Environment-ID": "environmentId",
1029
+ "X-Tangle-Session-ID": "sessionId",
1030
+ "X-Tangle-Execution-ID": "executionId"
1031
+ };
1032
+ /** Router column that joins a model request to the turn trace that caused it. */
1033
+ const EXECUTION_TRACE_HEADER = "X-Tangle-Trace-ID";
1034
+ const MANAGED_QUOTE_VERSION_HEADER = "X-Tangle-Managed-Quote-Version";
1035
+ const managedQuoteVersion = z.string().regex(/^mmq_[a-f0-9]{40}$/);
1036
+ /**
1037
+ * Capture diagnostic identity without forwarding caller-supplied transport headers.
1038
+ * `traceId` is the turn's trace id; a value no header can carry is omitted,
1039
+ * because a trace is diagnostic and must never refuse the run.
1040
+ */
1041
+ function executionCorrelationHeaders(binding, traceId, quoteVersion) {
1042
+ const quote = quoteVersion === void 0 ? void 0 : managedQuoteVersion.parse(quoteVersion);
1043
+ if (quote !== void 0 && binding === void 0) throw new Error("Managed quote requires an exact execution binding");
1044
+ const trace = headerValue.safeParse(traceId);
1045
+ const traced = trace.success ? { [EXECUTION_TRACE_HEADER]: trace.data } : {};
1046
+ if (binding === void 0) return trace.success ? Object.freeze(traced) : void 0;
1047
+ const headers = { ...traced };
1048
+ if (quote !== void 0) headers[MANAGED_QUOTE_VERSION_HEADER] = quote;
1049
+ for (const [name, field] of Object.entries(CORRELATION_FIELDS)) {
1050
+ const value = headerValue.safeParse(binding[field]);
1051
+ if (!value.success) throw new Error(`Execution correlation ${field} must contain 1–256 printable ASCII characters without spaces or commas`);
1052
+ headers[name] = value.data;
1053
+ }
1054
+ return Object.freeze(headers);
1055
+ }
1056
+ //#endregion
1057
+ //#region src/profile-config.ts
1058
+ /**
1059
+ * Per-turn credential delivery wins over host-env resolution; the host box
1060
+ * environment stays the fallback for references the turn did not deliver.
1061
+ * With no turn secrets the host provider is returned unchanged, so the
1062
+ * overlay adds no behavior to executions that never asked for it.
1063
+ */
1064
+ function runtimeSecretsOverlay(runtimeSecrets, fallback) {
1065
+ if (!runtimeSecrets || Object.keys(runtimeSecrets).length === 0) return fallback;
1066
+ return { get: async (key) => runtimeSecrets[key] ?? await fallback.get(key) };
1067
+ }
1068
+ function hasPortableProfileControls(profile) {
1069
+ return profile.prompt !== void 0 || profile.model !== void 0 || profile.harness !== void 0 || profile.permissions !== void 0 || profile.tools !== void 0 || profile.mcp !== void 0 || profile.connections !== void 0 || profile.subagents !== void 0 || profile.resources !== void 0 || profile.hooks !== void 0 || profile.modes !== void 0 || profile.confidential !== void 0 || profile.extensions !== void 0;
1070
+ }
1071
+ function resolveSystemPrompt(requestPrompt, profilePrompt) {
1072
+ if (profilePrompt === void 0) return requestPrompt;
1073
+ if (requestPrompt !== void 0 && requestPrompt.length > 0 && requestPrompt !== profilePrompt) throw new Error("execution system prompt conflicts with the materialized AgentProfile");
1074
+ return profilePrompt;
1075
+ }
1076
+ function profileForExecution(profile, requestPrompt) {
1077
+ const profilePrompt = profile?.prompt?.systemPrompt;
1078
+ if (!profile || profilePrompt === void 0 || requestPrompt === void 0 || requestPrompt.length === 0 || requestPrompt === profilePrompt) return profile;
1079
+ if (!requestPrompt.startsWith(`${profilePrompt}\n\n`)) throw new Error("execution system prompt conflicts with the materialized AgentProfile");
1080
+ return {
1081
+ ...profile,
1082
+ prompt: {
1083
+ ...profile.prompt,
1084
+ systemPrompt: requestPrompt
1085
+ }
1086
+ };
1087
+ }
1088
+ /**
1089
+ * Refuse to dispatch an execution against a model the profile did not
1090
+ * materialize — a caller must not silently get a different model than the one
1091
+ * the workspace was built for.
1092
+ *
1093
+ * The request and the profile carry the same model at different qualification
1094
+ * levels: the request is the id the caller sent, the profile default is that id
1095
+ * qualified with its provider. Both go through the one shared canonicalization
1096
+ * so only a genuinely different model is a conflict.
1097
+ */
1098
+ function resolveModel(requestModel, profileModel, configuredModel, profileProvider) {
1099
+ if (profileModel !== void 0 && requestModel !== void 0 && !modelIdsMatch(requestModel, profileModel, profileProvider)) throw new Error("execution model conflicts with the materialized AgentProfile");
1100
+ return profileModel ?? requestModel ?? configuredModel;
1101
+ }
1102
+ //#endregion
1103
+ //#region src/profile-revision-receipts.ts
1104
+ /** Receipts belong to their adapter; they never become subclass controls. */
1105
+ var ProfileRevisionReceipts = class {
1106
+ publicProfileRevisionReceipts = /* @__PURE__ */ new WeakMap();
1107
+ secretProfileRevisionReceipts = /* @__PURE__ */ new WeakMap();
1108
+ };
1109
+ const receiptsByAdapter = /* @__PURE__ */ new WeakMap();
1110
+ function profileRevisionReceipts(owner) {
1111
+ let receipts = receiptsByAdapter.get(owner);
1112
+ if (!receipts) {
1113
+ receipts = new ProfileRevisionReceipts();
1114
+ receiptsByAdapter.set(owner, receipts);
1115
+ }
1116
+ return receipts;
1117
+ }
1118
+ function assertExecutionProfileRevision(owner, getConfig, input, profilePlan) {
1119
+ if (getConfig().publicProfileRevisionRoot && profilePlan && !profileRevisionReceipts(owner).publicProfileRevisionReceipts.has(profilePlan)) throw new Error("Provider added public profile launch controls after immutable revision preparation");
1120
+ if (getConfig().secretProfileRevisionRoot && profilePlan && !profileRevisionReceipts(owner).secretProfileRevisionReceipts.has(profilePlan)) throw new Error("Provider added secret profile launch controls after immutable revision preparation");
1121
+ const expectedSecretReceipt = input.expectedSecretProfileReceipt;
1122
+ if (getConfig().secretProfileRevisionRoot) {
1123
+ const actual = profilePlan ? profileRevisionReceipts(owner).secretProfileRevisionReceipts.get(profilePlan) : void 0;
1124
+ if (!expectedSecretReceipt || !actual || actual.executorReceipt !== expectedSecretReceipt) throw new Error("Secret native profile receipt mismatch before native spawn");
1125
+ } else if (expectedSecretReceipt !== void 0) throw new Error("Secret native profile execution is unavailable");
1126
+ const expectedRevision = input.expectedPublicProfileRevision;
1127
+ if (expectedRevision !== void 0) {
1128
+ const actualRevision = profilePlan ? profileRevisionReceipts(owner).publicProfileRevisionReceipts.get(profilePlan) : void 0;
1129
+ if (!actualRevision || actualRevision.workspacePlanDigest !== expectedRevision.workspacePlanDigest || actualRevision.materializedDigest !== expectedRevision.materializedDigest || actualRevision.launchDigest !== expectedRevision.launchDigest) throw new Error("Public native profile revision mismatch before native spawn");
1130
+ }
1131
+ }
1132
+ //#endregion
1133
+ //#region src/errors.ts
1134
+ /**
1135
+ * A session's persisted transcript exists but could not be read for replay
1136
+ * into the fresh provider session this turn starts. The turn fails instead
1137
+ * of serving an empty conversation under the same session id. The sidecar's
1138
+ * client-visible whitelist matches on `code`; the wire contract is the
1139
+ * duck-typed field, not class identity.
1140
+ */
1141
+ var SessionRehydrationError = class extends Error {
1142
+ code = "session_rehydration_failed";
1143
+ constructor(message, cause) {
1144
+ super(message, { cause });
1145
+ this.name = "SessionRehydrationError";
1146
+ }
1147
+ };
1148
+ const NOT_LAUNCHED = Symbol.for("@tangle-network/sdk-provider-cli-base.not-launched");
1149
+ /**
1150
+ * Marks a turn failure the adapter raised before it planned the launch:
1151
+ * session preparation or profile materialization refused, so no native
1152
+ * process received this turn and it left no native transcript to copy.
1153
+ * Only the adapter marks, on the error object it caught; error text never
1154
+ * does, so a provider message that reads the same is not marked.
1155
+ */
1156
+ function rejectNotLaunched(error) {
1157
+ if (error instanceof Error && !Object.isFrozen(error)) Object.defineProperty(error, NOT_LAUNCHED, { value: true });
1158
+ throw error;
1159
+ }
1160
+ /** Whether this error is one the adapter raised before launching anything. */
1161
+ function wasNeverLaunched(error) {
1162
+ return Object.getOwnPropertyDescriptor(error, NOT_LAUNCHED)?.value === true;
1163
+ }
1164
+ //#endregion
1165
+ //#region src/file-parts.ts
1166
+ /** Extension → MIME type, keyed by lowercased extension without the dot. */
1167
+ const EXTENSION_TO_MEDIA_TYPE = {
1168
+ png: "image/png",
1169
+ jpg: "image/jpeg",
1170
+ jpeg: "image/jpeg",
1171
+ gif: "image/gif",
1172
+ webp: "image/webp",
1173
+ svg: "image/svg+xml",
1174
+ pdf: "application/pdf",
1175
+ mp3: "audio/mpeg",
1176
+ wav: "audio/wav",
1177
+ mp4: "video/mp4",
1178
+ webm: "video/webm",
1179
+ txt: "text/plain",
1180
+ md: "text/markdown",
1181
+ csv: "text/csv",
1182
+ json: "application/json",
1183
+ html: "text/html",
1184
+ markdown: "text/markdown",
1185
+ htm: "text/html",
1186
+ xml: "application/xml",
1187
+ yaml: "application/yaml",
1188
+ yml: "application/yaml",
1189
+ js: "text/javascript",
1190
+ ts: "application/typescript",
1191
+ py: "text/x-python"
1192
+ };
1193
+ /**
1194
+ * Inverse of the table above, for synthesizing a filename from a media type.
1195
+ * Built once from the first extension seen per media type (e.g. image/jpeg
1196
+ * → "jpg", not "jpeg") so the mapping is stable across calls.
1197
+ */
1198
+ const MEDIA_TYPE_TO_EXTENSION = Object.entries(EXTENSION_TO_MEDIA_TYPE).reduce((acc, [ext, mediaType]) => {
1199
+ if (!(mediaType in acc)) acc[mediaType] = ext;
1200
+ return acc;
1201
+ }, {});
1202
+ function sha1Hex(value) {
1203
+ return createHash("sha1").update(value).digest("hex");
1204
+ }
1205
+ /** Infers a MIME type from a filename's extension. Unknown/no extension → undefined. */
1206
+ function inferMediaType(filename) {
1207
+ const dot = filename.lastIndexOf(".");
1208
+ if (dot < 0 || dot === filename.length - 1) return void 0;
1209
+ return EXTENSION_TO_MEDIA_TYPE[filename.slice(dot + 1).toLowerCase()];
1210
+ }
1211
+ /**
1212
+ * Builds the canonical URL for a file part. A data URI passes through
1213
+ * verbatim; a path is returned unchanged if it already carries a recognized
1214
+ * scheme, else converted to a percent-encoded `file://` URL.
1215
+ *
1216
+ * Callers must pass an already-resolved absolute path — `trackFilePart`
1217
+ * resolves relative paths (or drops the observation when it can't) before
1218
+ * ever reaching here, so a relative path arriving at this layer means a
1219
+ * caller bypassed that resolution. We throw rather than mint a malformed
1220
+ * `file://` URL (e.g. `file://./chart.png`, whose authority is `.`).
1221
+ */
1222
+ function buildFileUrl(input) {
1223
+ if (input.dataUri !== void 0) return input.dataUri;
1224
+ if (input.path === void 0) throw new Error("buildFileUrl requires either `path` or `dataUri` to build a file part URL");
1225
+ if (input.path.startsWith("file://") || input.path.startsWith("http://") || input.path.startsWith("https://") || input.path.startsWith("data:")) return input.path;
1226
+ if (!isAbsolute(input.path)) throw new Error(`buildFileUrl requires an absolute path to build a file:// URL, got "${input.path}" — resolve it against a workspace root before calling buildFileUrl`);
1227
+ return pathToFileURL(input.path).href;
1228
+ }
1229
+ /** Deterministic, stable part id for a file announced within one message. */
1230
+ function deterministicFilePartId(messageId, key) {
1231
+ return `file-${sha1Hex(`${messageId}|${key}`).slice(0, 16)}`;
1232
+ }
1233
+ /**
1234
+ * Stateless derivation of the canonical file-part fields for one observed
1235
+ * path: resolve-once (normalize, then anchor a relative path to
1236
+ * `workspaceRoot`), and from that single resolved path derive the dedupe
1237
+ * `key`, the deterministic `id`, the `filename`, the inferred `mediaType`,
1238
+ * and the percent-encoded `file://` `url` — so every consumer that surfaces
1239
+ * the same file agrees on all five.
1240
+ *
1241
+ * Returns `null` for a relative path with no `workspaceRoot` to anchor it:
1242
+ * detection is opportunistic and a fabricated location is worse than a
1243
+ * missing part (see `trackFilePart` for the full rationale).
1244
+ *
1245
+ * This is the ONE derivation chain. `trackFilePart` (exec path, with
1246
+ * ExecutionState memoization) and stateless mappers that own no
1247
+ * ExecutionState (codex's app-server item mapper) both call it — never
1248
+ * re-implement the chain, or the two paths drift.
1249
+ */
1250
+ function deriveFilePart(input) {
1251
+ const normalized = normalize(input.path);
1252
+ let resolvedPath;
1253
+ if (isAbsolute(normalized)) resolvedPath = normalized;
1254
+ else if (input.workspaceRoot !== void 0) resolvedPath = resolve(input.workspaceRoot, normalized);
1255
+ else return null;
1256
+ const filename = basename(resolvedPath);
1257
+ return {
1258
+ id: deterministicFilePartId(input.messageId, resolvedPath),
1259
+ key: resolvedPath,
1260
+ filename,
1261
+ mediaType: inferMediaType(filename),
1262
+ url: buildFileUrl({ path: resolvedPath })
1263
+ };
1264
+ }
1265
+ /**
1266
+ * Tracks a file an adapter observed being created/modified (e.g. from a
1267
+ * write-tool call) and returns the canonical `FilePart` fields to stream,
1268
+ * deduping by resolved-absolute-path or data-URI content so repeated writes
1269
+ * to the same file update one part instead of appending duplicates.
1270
+ *
1271
+ * The path is normalized and resolved to an absolute form ONCE, and that same
1272
+ * resolved value drives both the dedupe key and the `file://` url — so two
1273
+ * observations of the same file that differ only in spelling (`./chart.png`
1274
+ * vs `/workspace/chart.png`, or `/workspace/sub/../chart.png` vs
1275
+ * `/workspace/chart.png`) unify on one part with one stable url, satisfying
1276
+ * the stream invariant that a given part id keeps a stable url.
1277
+ *
1278
+ * A relative path can only be resolved against `state.workspaceRoot` (set by
1279
+ * the adapter from the CLI's cwd). Detection here is opportunistic — a
1280
+ * tool-call observation, not a guarantee of an absolute path — so when no
1281
+ * workspaceRoot is available to anchor a relative path, we drop the
1282
+ * observation (`null`) rather than fabricate an anchor (e.g. resolving
1283
+ * against "/"): a wrong url is worse than a missing part, and a caller that
1284
+ * genuinely lost the file would get a wrong, permanent url once it's on the
1285
+ * stream.
1286
+ *
1287
+ * Detection is opportunistic for other reasons too: a delete, or an
1288
+ * observation carrying neither a path nor a data URI, also yields no part
1289
+ * (`null`) rather than an error — callers are not required to have a file
1290
+ * signal on every tool result.
1291
+ */
1292
+ function trackFilePart(state, args) {
1293
+ if (args.changeKind === "delete") return null;
1294
+ if (args.path === void 0 && args.dataUri === void 0) return null;
1295
+ if (state.messageId === void 0) throw new Error("trackFilePart requires state.messageId (set by createExecutionState) to derive a stable part id");
1296
+ let derived = null;
1297
+ if (args.path !== void 0) {
1298
+ derived = deriveFilePart({
1299
+ path: args.path,
1300
+ messageId: state.messageId,
1301
+ workspaceRoot: state.workspaceRoot
1302
+ });
1303
+ if (derived === null) return null;
1304
+ }
1305
+ state.fileParts ??= [];
1306
+ const parts = state.fileParts;
1307
+ const key = derived !== null ? derived.key : `data:${sha1Hex(args.dataUri)}`;
1308
+ const url = derived !== null ? derived.url : buildFileUrl({ dataUri: args.dataUri });
1309
+ const existing = parts.find((p) => p.key === key);
1310
+ let filename = args.filename ?? derived?.filename ?? existing?.filename;
1311
+ const mediaType = args.mediaType ?? (filename !== void 0 ? inferMediaType(filename) : void 0) ?? existing?.mediaType;
1312
+ if (filename === void 0 && args.path === void 0) {
1313
+ const priorDataUriEntries = parts.filter((p) => p.key.startsWith("data:")).length;
1314
+ const ext = mediaType ? MEDIA_TYPE_TO_EXTENSION[mediaType] ?? "bin" : "bin";
1315
+ filename = `attachment-${priorDataUriEntries + 1}.${ext}`;
1316
+ }
1317
+ if (existing) {
1318
+ existing.filename = filename;
1319
+ existing.mediaType = mediaType;
1320
+ existing.url = url;
1321
+ return {
1322
+ id: existing.id,
1323
+ filename: existing.filename,
1324
+ mediaType: existing.mediaType,
1325
+ url: existing.url
1326
+ };
1327
+ }
1328
+ const id = derived !== null ? derived.id : deterministicFilePartId(state.messageId, key);
1329
+ parts.push({
1330
+ id,
1331
+ key,
1332
+ filename,
1333
+ mediaType,
1334
+ url
1335
+ });
1336
+ return {
1337
+ id,
1338
+ filename,
1339
+ mediaType,
1340
+ url
1341
+ };
1342
+ }
1343
+ //#endregion
1344
+ //#region src/prompt-input.ts
1345
+ /** Raw-byte ceiling for inlining one image as base64 (~1.33x expansion in
1346
+ * memory and on the wire); larger images keep only their `path` and the
1347
+ * adapter falls back to path-based delivery. */
1348
+ const MAX_INLINE_IMAGE_BYTES = 10 * 1024 * 1024;
1349
+ /** Aggregate raw-byte budget for inlined images across one request's parts —
1350
+ * the request schema admits up to 64 parts, so a per-image cap alone would
1351
+ * still let one request hold hundreds of MB of base64 until execution. */
1352
+ const MAX_TOTAL_INLINE_IMAGE_BYTES = 32 * 1024 * 1024;
1353
+ async function readImageBase64(filePath, wroteFile, budgetBytes) {
1354
+ const limit = Math.min(MAX_INLINE_IMAGE_BYTES, budgetBytes);
1355
+ if (limit <= 0) return void 0;
1356
+ let handle;
1357
+ try {
1358
+ handle = await open(filePath, constants.O_RDONLY | constants.O_NONBLOCK);
1359
+ const stats = await handle.stat();
1360
+ if (!stats.isFile() || stats.size > limit) return void 0;
1361
+ const buffer = Buffer.alloc(stats.size + 1);
1362
+ let offset = 0;
1363
+ while (offset < buffer.length) {
1364
+ const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
1365
+ if (bytesRead === 0) break;
1366
+ offset += bytesRead;
1367
+ }
1368
+ if (offset > stats.size) return void 0;
1369
+ return {
1370
+ base64: buffer.subarray(0, offset).toString("base64"),
1371
+ rawBytes: offset
1372
+ };
1373
+ } catch (error) {
1374
+ if (wroteFile) throw error;
1375
+ return;
1376
+ } finally {
1377
+ await handle?.close();
1378
+ }
1379
+ }
1380
+ function inferExtension(mediaType) {
1381
+ if (!mediaType) return "bin";
1382
+ if (mediaType === "application/pdf") return "pdf";
1383
+ if (mediaType.startsWith("image/")) return mediaType.split("/")[1] || "img";
1384
+ if (mediaType.startsWith("audio/")) return mediaType.split("/")[1] || "audio";
1385
+ if (mediaType.startsWith("video/")) return mediaType.split("/")[1] || "video";
1386
+ return mediaType.includes("/") ? mediaType.split("/")[1] || "bin" : "bin";
1387
+ }
1388
+ function sanitizeFilename(filename) {
1389
+ return filename.replace(/[^a-zA-Z0-9._-]/g, "-") || `attachment-${randomUUID()}`;
1390
+ }
1391
+ const TEXT_MEDIA_TYPES = new Set([
1392
+ "application/json",
1393
+ "application/xml",
1394
+ "application/yaml",
1395
+ "application/x-yaml",
1396
+ "application/javascript",
1397
+ "application/csv"
1398
+ ]);
1399
+ function isTextMediaType(mediaType) {
1400
+ if (!mediaType) return true;
1401
+ const essence = (mediaType.split(";")[0] ?? "").trim().toLowerCase();
1402
+ if (essence.startsWith("text/")) return true;
1403
+ if (TEXT_MEDIA_TYPES.has(essence)) return true;
1404
+ return essence.endsWith("+json") || essence.endsWith("+xml");
1405
+ }
1406
+ async function materializeAttachmentPart(part, workspaceRoot, sessionId, index) {
1407
+ const attachmentDir = path.join(workspaceRoot, ".agent-inputs", sessionId);
1408
+ await mkdir(attachmentDir, { recursive: true });
1409
+ let dataUriMatch;
1410
+ if (part.url?.startsWith("data:")) {
1411
+ const match = part.url.match(/^data:([^;,]+)?;base64,(.*)$/s);
1412
+ if (!match) throw new InvalidAttachmentError(`Invalid data URI for ${part.type} attachment`);
1413
+ dataUriMatch = match;
1414
+ }
1415
+ const mediaType = part.mediaType || dataUriMatch?.[1] || void 0;
1416
+ const filename = sanitizeFilename(part.filename || (part.path ? path.basename(part.path) : void 0) || `${part.type}-${index + 1}.${inferExtension(mediaType)}`);
1417
+ const targetPath = path.join(attachmentDir, filename);
1418
+ let referencePath = part.path ? path.resolve(workspaceRoot, part.path) : targetPath;
1419
+ let wroteFile = false;
1420
+ const content = "content" in part ? part.content : void 0;
1421
+ const payload = dataUriMatch ? Buffer.from(dataUriMatch[2], "base64") : typeof content === "string" ? content : void 0;
1422
+ if (payload !== void 0) {
1423
+ if (!dataUriMatch && !isTextMediaType(part.mediaType)) throw new InvalidAttachmentError(`Cannot write binary attachment "${filename}" (mediaType: ${part.mediaType}) via "content" — deliver it as a base64 data: URL (data:${part.mediaType};base64,...) or via "path"/"file://" url instead.`);
1424
+ const partDirectory = path.join(attachmentDir, `part-${randomUUID()}`);
1425
+ await mkdir(partDirectory);
1426
+ referencePath = path.join(partDirectory, filename);
1427
+ await writeFile(referencePath, payload, { flag: "wx" });
1428
+ wroteFile = true;
1429
+ } else if (part.url?.startsWith("file://")) try {
1430
+ referencePath = fileURLToPath(part.url);
1431
+ } catch (err) {
1432
+ throw new InvalidAttachmentError(`Invalid file:// URL for ${part.type} attachment: ${JSON.stringify(part.url)} (${err instanceof Error ? err.message : String(err)})`);
1433
+ }
1434
+ else if (part.url && /^https?:\/\//i.test(part.url)) referencePath = part.url;
1435
+ const isHttpReference = referencePath.startsWith("http://") || referencePath.startsWith("https://");
1436
+ return {
1437
+ reference: referencePath,
1438
+ filename,
1439
+ ...mediaType ? { mediaType } : {},
1440
+ wroteFile,
1441
+ persistedPart: {
1442
+ type: "file",
1443
+ filename,
1444
+ mediaType,
1445
+ url: isHttpReference ? referencePath : buildFileUrl({ path: referencePath })
1446
+ }
1447
+ };
1448
+ }
1449
+ async function preparePromptInputParts(parts, workspaceRoot, sessionId, options) {
1450
+ const nativeKinds = new Set(options?.nativeAttachmentKinds ?? []);
1451
+ const promptSections = [];
1452
+ const attachmentRefs = [];
1453
+ const persistedParts = [];
1454
+ const attachments = [];
1455
+ const inlineImages = nativeKinds.has("image");
1456
+ let inlineBudget = options?.inlineImageBudgetBytes ?? 33554432;
1457
+ for (const [index, part] of parts.entries()) {
1458
+ if (part.type === "text") {
1459
+ if (!part.text) continue;
1460
+ promptSections.push(part.text);
1461
+ persistedParts.push({
1462
+ type: "text",
1463
+ text: part.text
1464
+ });
1465
+ continue;
1466
+ }
1467
+ const materialized = await materializeAttachmentPart(part, workspaceRoot, sessionId, index);
1468
+ const marker = `[${part.type === "image" ? "Image" : "File"}: ${materialized.reference}]`;
1469
+ if (!nativeKinds.has(part.type)) attachmentRefs.push(marker);
1470
+ persistedParts.push(materialized.persistedPart);
1471
+ const isRemote = /^https?:\/\//i.test(materialized.reference);
1472
+ const attachment = {
1473
+ kind: part.type,
1474
+ filename: materialized.filename,
1475
+ marker,
1476
+ ...materialized.mediaType ? { mediaType: materialized.mediaType } : {},
1477
+ ...isRemote ? { url: materialized.reference } : { path: materialized.reference }
1478
+ };
1479
+ if (part.type === "image" && attachment.path && inlineImages) {
1480
+ const inlined = await readImageBase64(attachment.path, materialized.wroteFile, inlineBudget);
1481
+ if (inlined) {
1482
+ attachment.base64 = inlined.base64;
1483
+ inlineBudget -= inlined.rawBytes;
1484
+ }
1485
+ }
1486
+ attachments.push(attachment);
1487
+ }
1488
+ if (attachmentRefs.length > 0) promptSections.push(`Attached files:\n${attachmentRefs.join("\n")}`);
1489
+ return {
1490
+ promptText: promptSections.join("\n\n").trim(),
1491
+ persistedParts,
1492
+ attachments
1493
+ };
1494
+ }
1495
+ /**
1496
+ * Partition a turn's image attachments into the ones the adapter will
1497
+ * forward natively and the "stranded" rest, folding each stranded image's
1498
+ * `[Image: ...]` marker back into the message text. Once an adapter's
1499
+ * nativeAttachmentKinds() claims "image", the base prompt renderer omits
1500
+ * every image marker — so any image the mechanism can't carry (no base64
1501
+ * for a stdin block, no local path for an argv flag, beyond a single-path
1502
+ * flag's capacity) MUST re-enter the prompt here or the model never sees
1503
+ * a reference to it. Uses the same "Attached files:" block format the
1504
+ * renderer itself emits for non-native kinds.
1505
+ */
1506
+ function partitionImageAttachments(options) {
1507
+ const { message, attachments, qualifies, maxNative } = options;
1508
+ const native = [];
1509
+ const strandedMarkers = [];
1510
+ for (const attachment of attachments ?? []) {
1511
+ if (attachment.kind !== "image") continue;
1512
+ if ((maxNative === void 0 || native.length < maxNative) && qualifies(attachment)) native.push(attachment);
1513
+ else strandedMarkers.push(attachment.marker);
1514
+ }
1515
+ return {
1516
+ native,
1517
+ message: strandedMarkers.length > 0 ? `${message}\n\nAttached files:\n${strandedMarkers.join("\n")}` : message
1518
+ };
1519
+ }
1520
+ /** Separates the sections a harness receives as one user message. */
1521
+ const PROMPT_SECTION_SEPARATOR = "\n\n---\n\n";
1522
+ /**
1523
+ * Puts `section` (a system prompt or replayed context) ahead of the message
1524
+ * for harnesses that take no separate channel for it.
1525
+ */
1526
+ function prependPromptSection(section, body) {
1527
+ return section ? `${section}${PROMPT_SECTION_SEPARATOR}${body}` : body;
1528
+ }
1529
+ //#endregion
1530
+ //#region src/types.ts
1531
+ function interactionToggleEnabled(config, kind) {
1532
+ return config === true || typeof config === "object" && config !== null && config[kind] === true;
1533
+ }
1534
+ function enabledInteractionKinds(config, supported) {
1535
+ if (config === true) return [...supported];
1536
+ if (typeof config !== "object" || config === null) return [];
1537
+ return supported.filter((kind) => config[kind] === true);
1538
+ }
1539
+ /**
1540
+ * Read a turn's ephemeral context, rejecting a wrong-typed value loudly.
1541
+ *
1542
+ * The published `AgentExecutionInput` does not declare the field, so nothing
1543
+ * type-checks it at the process boundary. Left unchecked, a caller passing a
1544
+ * non-string gets it stringified into the model prompt (`[object Object]`) —
1545
+ * a corrupted turn with no error anywhere. `undefined`/`null` mean "no context
1546
+ * this turn"; an empty string is likewise nothing to append.
1547
+ */
1548
+ function readEphemeralContext(input) {
1549
+ const context = input.ephemeralContext;
1550
+ if (context === void 0 || context === null) return void 0;
1551
+ if (typeof context !== "string") throw new TypeError(`ephemeralContext must be a string, received ${typeof context}`);
1552
+ return context || void 0;
1553
+ }
1554
+ /**
1555
+ * Compose the model-facing prompt for a turn: the caller's own text, then this
1556
+ * turn's ephemeral context. Call it at the point the prompt is handed to the
1557
+ * provider — NEVER on the parts handed to `recordUserMessage` or the message
1558
+ * store.
1559
+ */
1560
+ function withEphemeralContext(promptText, input) {
1561
+ const context = readEphemeralContext(input);
1562
+ if (!context) return promptText;
1563
+ return promptText ? `${promptText}\n\n${context}` : context;
1564
+ }
1565
+ /**
1566
+ * Resolve whether an interaction kind is active for THIS turn. When the turn
1567
+ * carries `interactions` it is authoritative — a complete set that can also
1568
+ * turn a kind OFF mid-session; otherwise the adapter falls back to its latched
1569
+ * config (the initialize-time wiring, or a direct non-sidecar caller's option).
1570
+ */
1571
+ function turnInteractionEnabled(input, latched, kind) {
1572
+ const perTurn = input.interactions;
1573
+ return interactionToggleEnabled(perTurn !== void 0 ? perTurn : latched, kind);
1574
+ }
1575
+ /**
1576
+ * Overlay this turn's interactions onto a latched config, returning a NEW
1577
+ * object (no shared mutation — safe when one adapter instance serves concurrent
1578
+ * turns from different sessions). When the turn omits `interactions` the config
1579
+ * is returned unchanged, preserving behavior for direct non-sidecar callers.
1580
+ * Adapters read plan/permission/question posture off the returned config so a
1581
+ * mid-session toggle takes effect and can turn a kind off (which the latched
1582
+ * config, only ever merged by updateConfig, cannot).
1583
+ */
1584
+ function withTurnInteractions(config, input) {
1585
+ if (input.interactions === void 0) return config;
1586
+ return {
1587
+ ...config,
1588
+ interactions: input.interactions
1589
+ };
1590
+ }
1591
+ //#endregion
1592
+ //#region src/adapter-profile.ts
1593
+ /**
1594
+ * Revisions that already passed the native health check in this process, by
1595
+ * the verifier's scope and the revision's materialized digest. The check asks
1596
+ * whether the pinned native CLI loads this revision's bytes: a function of
1597
+ * those bytes and that CLI, not of the session or subprocess UID that will
1598
+ * read them (the ACL grant that publishes a revision to a UID throws when it
1599
+ * fails). A session's identity changes whenever its profile variant or turn
1600
+ * credential does, so keying by the session's receipt checked an unchanged
1601
+ * profile again every turn. Secret values are excluded from the digest.
1602
+ * Agents are rebuilt per request, so this outlives any adapter.
1603
+ * Insertion-ordered and bounded: the oldest entry is forgotten first.
1604
+ */
1605
+ const verifiedSecretProfileRevisions = /* @__PURE__ */ new Set();
1606
+ const VERIFIED_SECRET_PROFILE_REVISIONS_MAX = 1024;
1607
+ function rememberVerifiedSecretProfileRevision(key) {
1608
+ verifiedSecretProfileRevisions.delete(key);
1609
+ verifiedSecretProfileRevisions.add(key);
1610
+ while (verifiedSecretProfileRevisions.size > VERIFIED_SECRET_PROFILE_REVISIONS_MAX) {
1611
+ const oldest = verifiedSecretProfileRevisions.values().next().value;
1612
+ if (oldest === void 0) break;
1613
+ verifiedSecretProfileRevisions.delete(oldest);
1614
+ }
1615
+ }
1616
+ /** The executor's naming key, refused unless root owns it alone. */
1617
+ function readSecretProfileNamingKey(keyPath) {
1618
+ if (!keyPath) throw new Error("Secret profile naming key is unavailable");
1619
+ const keyStat = lstatSync(keyPath);
1620
+ if (!keyStat.isFile() || keyStat.isSymbolicLink() || keyStat.uid !== 0 || (keyStat.mode & 511) !== 384 || keyStat.size !== 32) throw new Error("Secret profile naming key is not root-owned");
1621
+ return readFileSync(keyPath);
1622
+ }
1623
+ /** Owns native profile revisions, launch controls, and prompt composition. */
1624
+ var CliAdapterProfile = class extends CliAdapterLifecycle {
1625
+ /** Native profile lowering explicitly owned by this adapter, never inferred from a path. */
1626
+ profileHarness = void 0;
1627
+ /** Trusted implementation capabilities; never read from caller-owned configuration. */
1628
+ supportsSecretProfileRevisions = false;
1629
+ profileSecretProvider;
1630
+ secretProfileRuntimeKeyProvider;
1631
+ /**
1632
+ * The materialized digest behind each secret revision. It stays here: the
1633
+ * executor receipt is all a caller sees of a revision's resolved contents.
1634
+ */
1635
+ secretProfileMaterializedDigests = /* @__PURE__ */ new WeakMap();
1636
+ /**
1637
+ * The value behind each `{env:NAME}` reference in a secret revision's
1638
+ * config, by that revision's application. Executor memory only: no launch
1639
+ * environment, argv or file ever carries them.
1640
+ */
1641
+ secretProfileReferenceValues = /* @__PURE__ */ new WeakMap();
1642
+ constructor(options = {}) {
1643
+ super(options);
1644
+ profileRevisionReceipts(this);
1645
+ this.profileSecretProvider = options.profileSecretProvider ?? UNAVAILABLE_AGENT_PROFILE_SECRET_PROVIDER;
1646
+ this.secretProfileRuntimeKeyProvider = options.secretProfileRuntimeKeyProvider ?? UNAVAILABLE_AGENT_PROFILE_SECRET_PROVIDER;
1647
+ }
1648
+ /** Apply captured diagnostic identity to this spawn, after profile composition. */
1649
+ /** The exact child environment a turn uses; shared with prewarm paths. */
1650
+ buildTurnEnvironment(executionConfig, profilePlan, workspaceRoot, correlationHeaders) {
1651
+ const merged = this.mergeProfilePlanEnvironment(this.buildNativeExecutionEnvironment(executionConfig), profilePlan, executionConfig.nativeAuthEnv !== void 0 || (executionConfig.authFiles?.length ?? 0) > 0);
1652
+ return this.bindExecutionCorrelation(this.applyWritableHomeDefaults(this.bindProfileToolBinDirs(merged, profilePlan), workspaceRoot), correlationHeaders);
1653
+ }
1654
+ /**
1655
+ * A profile's declared executable tools, sealed in its revision, stand in
1656
+ * for the app's own tool dirs on this launch's PATH tail. A secret profile's
1657
+ * env no longer names the app's dirs (app env is scrubbed), so the sidecar's
1658
+ * own SANDBOX_TOOL_BIN_DIRS says which inherited PATH entries they are.
1659
+ */
1660
+ bindProfileToolBinDirs(env, profilePlan) {
1661
+ const toolBinDirs = profilePlan?.spawn.toolBinDirs;
1662
+ if (!toolBinDirs?.length) return env;
1663
+ return bindLaunchToolBinDirs("PATH" in env || !this.inheritProcessEnv || process.env.PATH === void 0 ? env : {
1664
+ ...env,
1665
+ PATH: process.env.PATH
1666
+ }, toolBinDirs, [...parseSandboxToolBinDirs(env.SANDBOX_TOOL_BIN_DIRS), ...parseSandboxToolBinDirs(process.env.SANDBOX_TOOL_BIN_DIRS)]);
1667
+ }
1668
+ bindExecutionCorrelation(environment, correlationHeaders) {
1669
+ if (correlationHeaders !== void 0 && Object.keys(correlationHeaders).some((name) => name !== "X-Tangle-Trace-ID")) this.logger.warn("Native model request correlation is unsupported", { provider: this.provider });
1670
+ return environment;
1671
+ }
1672
+ /**
1673
+ * Provision the inline profile's files and native execution controls with the
1674
+ * shared materializer used by cli-bridge. Unsupported portable controls fail
1675
+ * before spawn unless the profile is explicitly best-effort
1676
+ * (resources.failOnError: false), which materializes the supported subset
1677
+ * and warns about the rest.
1678
+ */
1679
+ /**
1680
+ * Launcher controls this adapter OWNS on the spawn it starts — see
1681
+ * `BINDABLE_LAUNCH_CONTROLS` in the materializer. Empty by default: a plan
1682
+ * whose intent needs a launcher binding then fails closed instead of being
1683
+ * silently dropped. An adapter that really applies the plan's launch flags
1684
+ * to its own spawn (sdk-provider-opencode selecting the plan's primary
1685
+ * agent via `--agent`) overrides this with the controls it honors.
1686
+ */
1687
+ profileMaterializationBinds() {
1688
+ return [];
1689
+ }
1690
+ /** Optional native instruction root. The adapter owns how its runtime locates this directory. */
1691
+ profileAgentDirectory(_workspaceRoot) {}
1692
+ /** Provider transport bound into the private immutable revision and receipt. */
1693
+ secretProfileProviderConfig() {}
1694
+ /**
1695
+ * The values behind a secret revision's `{env:NAME}` config references, for
1696
+ * an executor that serves those references itself. Never pass them to the
1697
+ * native process.
1698
+ */
1699
+ secretProfileReferences(application) {
1700
+ return application ? this.secretProfileReferenceValues.get(application) : void 0;
1701
+ }
1702
+ async materializeProfileResources(inputProfile, workspaceRoot, ownerId, sessionId, scope = "turn") {
1703
+ if (!inputProfile && !this.config.runtimeAttachments) return void 0;
1704
+ const started = performance.now();
1705
+ try {
1706
+ return await this.applyProfileResources(inputProfile, workspaceRoot, ownerId, sessionId);
1707
+ } finally {
1708
+ this.logger.observePhase?.("profile_materialize", performance.now() - started, scope);
1709
+ }
1710
+ }
1711
+ async applyProfileResources(inputProfile, workspaceRoot, ownerId, sessionId) {
1712
+ const profile = inputProfile ?? {};
1713
+ const harness = this.profileHarness;
1714
+ if (!harness) {
1715
+ if (hasPortableProfileControls(profile) || this.config.runtimeAttachments !== void 0) throw new Error(`${this.provider} does not support portable AgentProfile controls`);
1716
+ return;
1717
+ }
1718
+ const agentDir = this.profileAgentDirectory(workspaceRoot);
1719
+ const plan = materializeProfile(profile, harness, {
1720
+ runtimeAttachments: this.config.runtimeAttachments,
1721
+ binds: this.profileMaterializationBinds(),
1722
+ ...agentDir === void 0 ? {} : { instructionsRoot: "agent" }
1723
+ });
1724
+ const unsupported = enforceWorkspacePlanSupport(plan, profile.resources?.failOnError);
1725
+ if (unsupported.length > 0) this.logger.warn("profile contains unsupported best-effort resources", {
1726
+ harness,
1727
+ unsupported
1728
+ });
1729
+ if (this.config.secretProfileRevisionRoot) return await this.applySecretProfileRevision(plan, this.config.secretProfileRevisionRoot, workspaceRoot, ownerId, sessionId);
1730
+ if (this.config.publicProfileRevisionRoot) {
1731
+ if (!ownerId) throw new Error("Concurrent public profile launch requires a session scope; idle prewarm is unsupported");
1732
+ const revision = await preparePublicConcurrentProfileRevision({
1733
+ plan,
1734
+ ...ensurePublicConcurrentProfileRoots(this.config.publicProfileRevisionRoot),
1735
+ sharedWorkspaceDir: workspaceRoot,
1736
+ ownerId,
1737
+ secretProvider: runtimeSecretsOverlay(this.config.runtimeSecrets, this.profileSecretProvider)
1738
+ });
1739
+ this.logger.debug("materialized public concurrent profile revision", {
1740
+ harness,
1741
+ directory: revision.directory,
1742
+ digest: revision.manifest.materializedDigest
1743
+ });
1744
+ const launchDigest = revision.manifest.launchDigest;
1745
+ if (!launchDigest) throw new Error("Concurrent profile launch digest was not recorded");
1746
+ profileRevisionReceipts(this).publicProfileRevisionReceipts.set(revision.application, {
1747
+ workspacePlanDigest: revision.manifest.workspacePlanDigest,
1748
+ materializedDigest: revision.manifest.materializedDigest,
1749
+ launchDigest,
1750
+ conflicts: revision.manifest.conflicts
1751
+ });
1752
+ return revision.application;
1753
+ }
1754
+ const application = await applyWorkspacePlanWithSecretProvider(plan, workspaceRoot, runtimeSecretsOverlay(this.config.runtimeSecrets, this.profileSecretProvider), {
1755
+ ownership: this.subprocessIdentity(),
1756
+ ...agentDir === void 0 ? {} : { agentDir }
1757
+ });
1758
+ this.logger.debug("materialized profile resources", {
1759
+ harness,
1760
+ files: plan.files.length,
1761
+ flags: plan.flags.length,
1762
+ environment: Object.keys(plan.env).length
1763
+ });
1764
+ return application;
1765
+ }
1766
+ /**
1767
+ * Publish the profile as an executor-sealed revision for this session's
1768
+ * subprocess identity, and record the receipt that binds it to the turn.
1769
+ */
1770
+ async applySecretProfileRevision(plan, secretProfileRevisionRoot, workspaceRoot, ownerId, sessionId) {
1771
+ if (!this.supportsSecretProfileRevisions || !ownerId || !sessionId || !this.config.subprocessUser) throw new Error("Secret concurrent profile needs an opted-in adapter, a session, and per-turn runtime secrets only");
1772
+ const namingKey = readSecretProfileNamingKey(this.config.secretProfileNamingKeyPath);
1773
+ const roots = ensurePublicConcurrentProfileRoots(secretProfileRevisionRoot);
1774
+ const revision = await prepareSecretConcurrentProfileRevision({
1775
+ plan,
1776
+ providerConfig: this.secretProfileProviderConfig(),
1777
+ ...roots,
1778
+ sharedWorkspaceDir: workspaceRoot,
1779
+ ownerId,
1780
+ subprocessUid: this.config.subprocessUser.uid,
1781
+ namingKey,
1782
+ secretProvider: runtimeSecretsOverlay(this.config.runtimeSecrets, this.secretProfileRuntimeKeyProvider)
1783
+ });
1784
+ const launchDigest = revision.manifest.launchDigest;
1785
+ if (!launchDigest) throw new Error("Secret concurrent profile launch digest is absent");
1786
+ const executorReceipt = `hmac-sha256:${createHmac("sha256", namingKey).update(ownerId).update("\0").update(sessionId).update("\0").update(String(this.config.subprocessUser.uid)).update("\0").update(revision.manifest.workspacePlanDigest).update("\0").update(revision.manifest.materializedDigest).update("\0").update(launchDigest).digest("hex")}`;
1787
+ this.secretProfileReferenceValues.set(revision.application, revision.secretReferences);
1788
+ this.secretProfileMaterializedDigests.set(revision.application, revision.manifest.materializedDigest);
1789
+ profileRevisionReceipts(this).secretProfileRevisionReceipts.set(revision.application, {
1790
+ workspacePlanDigest: revision.manifest.workspacePlanDigest,
1791
+ executorReceipt,
1792
+ conflicts: revision.manifest.conflicts.map((conflict) => ({ ...conflict }))
1793
+ });
1794
+ return revision.application;
1795
+ }
1796
+ /**
1797
+ * Prepare the same provider-composed native plan that execute() will check.
1798
+ * This may publish an immutable public revision, but never starts a model turn.
1799
+ */
1800
+ async preparePublicProfileRevision(input) {
1801
+ if (!this.config.publicProfileRevisionRoot) throw new Error("Concurrent public profile materialization is disabled");
1802
+ const sessionScope = input.userId ?? input.sessionId;
1803
+ if (!sessionScope) throw new Error("Concurrent public profile preparation needs a session scope");
1804
+ const application = await this.materializeProfileResources(this.resolveExecutionProfileForInput(input), input.workspaceRoot ?? this.storagePath, sessionScope, input.sessionId);
1805
+ const receipt = application ? profileRevisionReceipts(this).publicProfileRevisionReceipts.get(application) : void 0;
1806
+ if (!receipt) throw new Error("Concurrent public profile preparation produced no native plan");
1807
+ return receipt;
1808
+ }
1809
+ /** Prepare the private native plan and return only an opaque executor receipt. */
1810
+ async prepareSecretProfileRevision(input) {
1811
+ if (!this.config.secretProfileRevisionRoot) throw new Error("Secret concurrent profile materialization is disabled");
1812
+ const sessionScope = input.userId ?? input.sessionId;
1813
+ if (!sessionScope) throw new Error("Secret profile preparation needs a session scope");
1814
+ const startedAt = performance.now();
1815
+ const application = await this.materializeProfileResources(this.resolveExecutionProfileForInput(input), input.workspaceRoot ?? this.storagePath, sessionScope, input.sessionId);
1816
+ const materializedAt = performance.now();
1817
+ const receipt = application ? profileRevisionReceipts(this).secretProfileRevisionReceipts.get(application) : void 0;
1818
+ if (!application || !receipt) throw new Error("Secret profile preparation produced no native plan");
1819
+ const materializedDigest = this.secretProfileMaterializedDigests.get(application);
1820
+ if (!materializedDigest) throw new Error("Secret profile preparation produced no revision digest");
1821
+ const verification = `${this.secretProfileVerificationScope()}\0${materializedDigest}`;
1822
+ const verifyCached = verifiedSecretProfileRevisions.has(verification);
1823
+ if (!verifyCached) await this.verifyPreparedSecretProfileRevision(input, application);
1824
+ rememberVerifiedSecretProfileRevision(verification);
1825
+ this.logger.info("prepared secret profile revision", {
1826
+ materializeMs: Math.round(materializedAt - startedAt),
1827
+ verifyMs: Math.round(performance.now() - materializedAt),
1828
+ verifyCached
1829
+ });
1830
+ return receipt;
1831
+ }
1832
+ /**
1833
+ * What the native health check's verdict depends on besides a revision's
1834
+ * bytes: the executable that loads them. A provider whose check runs
1835
+ * through a configurable binary names it here.
1836
+ */
1837
+ secretProfileVerificationScope() {
1838
+ return this.provider;
1839
+ }
1840
+ /** A secret receipt is issued only after the provider can load this revision. */
1841
+ async verifyPreparedSecretProfileRevision(_input, _application) {
1842
+ throw new Error(`${this.provider} has no native secret profile health check`);
1843
+ }
1844
+ /** Resolve the canonical profile used for this execution. */
1845
+ resolveExecutionProfile(requestPrompt) {
1846
+ return profileForExecution(this.config.profile, requestPrompt);
1847
+ }
1848
+ /** Select the profile materialized for this exact turn. */
1849
+ resolveExecutionProfileForInput(input) {
1850
+ return this.resolveExecutionProfile(input.systemPrompt);
1851
+ }
1852
+ /** Compose the provider's user-message channel for this exact turn. */
1853
+ composePromptText(promptText, input) {
1854
+ return withEphemeralContext(promptText, input);
1855
+ }
1856
+ withProfilePlan(config, profilePlan) {
1857
+ if (!profilePlan || profilePlan.spawn.flags.length === 0) return config;
1858
+ const contract = this.sessionContract();
1859
+ if (contract) assertFlagsHonourSessionContract(this.provider, contract, profilePlan.spawn.flags, "profile flags");
1860
+ return {
1861
+ ...config,
1862
+ extraFlags: [...config.extraFlags ?? [], ...profilePlan.spawn.flags]
1863
+ };
1864
+ }
1865
+ resolveProfileSystemPrompt(requestPrompt, profilePlan) {
1866
+ return resolveSystemPrompt(requestPrompt, profilePlan?.spawn.systemPrompt);
1867
+ }
1868
+ /**
1869
+ * The plan's additive prompt intent for this spawn. No request-level
1870
+ * counterpart exists (`AgentExecutionInput` carries only the replacement),
1871
+ * so unlike {@link resolveProfileSystemPrompt} there is nothing to
1872
+ * conflict-check against.
1873
+ */
1874
+ resolveProfileAppendSystemPrompt(profilePlan) {
1875
+ return profilePlan?.spawn.appendSystemPrompt;
1876
+ }
1877
+ resolveProfileModel(requestModel) {
1878
+ return resolveModel(requestModel, this.config.profile?.model?.default, this.config.model, this.config.profile?.model?.provider);
1879
+ }
1880
+ mergeProfilePlanEnvironment(environment, profilePlan, preserveClearedVariables = false) {
1881
+ if (!profilePlan || Object.keys(profilePlan.spawn.env).length === 0) return environment;
1882
+ const merged = { ...environment };
1883
+ for (const [name, value] of Object.entries(profilePlan.spawn.env)) {
1884
+ const existing = merged[name];
1885
+ if (existing !== void 0 && existing !== value || preserveClearedVariables && existing === void 0 && Object.hasOwn(environment, name) && !this.profilePlanMayFillClearedEnvironmentVariable(name, value)) throw new Error(`profile environment conflicts with provider environment: ${name}`);
1886
+ merged[name] = value;
1887
+ }
1888
+ return merged;
1889
+ }
1890
+ /** Provider-owned allowlist for a generated revision control after env scrub. */
1891
+ profilePlanMayFillClearedEnvironmentVariable(_name, _value) {
1892
+ return false;
1893
+ }
1894
+ async preparePromptInput(parts, workspaceRoot, sessionId, options) {
1895
+ return preparePromptInputParts(parts, workspaceRoot, sessionId, {
1896
+ nativeAttachmentKinds: options?.nativeAttachmentKinds ?? this.nativeAttachmentKinds(),
1897
+ ...options?.inlineImageBudgetBytes !== void 0 ? { inlineImageBudgetBytes: options.inlineImageBudgetBytes } : {}
1898
+ });
1899
+ }
1900
+ /**
1901
+ * Attachment kinds this adapter forwards natively via buildExecArgs (argv
1902
+ * flag / stdin content block) instead of the `[Image: ...]` / `[File: ...]`
1903
+ * text marker. The base adapter omits those kinds' markers from the prompt
1904
+ * text — an adapter that opts a kind in MUST deliver every attachment of
1905
+ * that kind itself, or the model never sees it.
1906
+ */
1907
+ nativeAttachmentKinds() {
1908
+ return [];
1909
+ }
1910
+ };
1911
+ //#endregion
1912
+ //#region src/native-session-enforcement.ts
1913
+ /** Store location and identity deadlines apply once a receipt proves the contract. */
1914
+ function sessionContractFullyEnforced(provider) {
1915
+ return !Object.hasOwn(HARNESS_CONFORMANCE_PENDING, provider);
1916
+ }
1917
+ //#endregion
1918
+ //#region src/adapter-sessions.ts
1919
+ /** Providers without a repair tool share one empty set. */
1920
+ const NO_REPAIR_TOOL_NAMES$1 = /* @__PURE__ */ new Set();
1921
+ /** Owns durable transcripts, provider-native session bindings and the turn hooks providers override. */
1922
+ var CliAdapterSessions = class extends CliAdapterProfile {
1923
+ async createSession(options) {
1924
+ this.ensureInitialized();
1925
+ const session = await this.getSessionStore().create({ workspaceRoot: options?.workspaceRoot ?? this.storagePath });
1926
+ this.logger.debug("Created session", { sessionId: session.id });
1927
+ return session.id;
1928
+ }
1929
+ async deleteSession(sessionId) {
1930
+ this.ensureInitialized();
1931
+ await this.getMessageStore().clear(sessionId);
1932
+ const deleted = await this.getSessionStore().delete(sessionId);
1933
+ this.logger.debug("Deleted session", {
1934
+ sessionId,
1935
+ deleted
1936
+ });
1937
+ return deleted;
1938
+ }
1939
+ /**
1940
+ * Choose the native ID before its argv check, durable binding, and
1941
+ * reservation. `input` and `timeoutMs` are the turn's, for a harness that
1942
+ * decides here whether a process started earlier can serve it.
1943
+ */
1944
+ mintNativeSessionId(_context) {
1945
+ return randomUUID();
1946
+ }
1947
+ /**
1948
+ * Make a pre-minted native session durable before the CLI starts, for a
1949
+ * harness that would otherwise write nothing until its first model reply
1950
+ * (Pi). Called after the argv check and before spawn.
1951
+ */
1952
+ reserveNativeSession(_nativeSessionId, _context) {}
1953
+ /**
1954
+ * Verify that the provider can resume the native session binding. By
1955
+ * default a session in a per-session store is resumable only when one of
1956
+ * its declared files exists under the runtime HOME; a pre-minted id whose
1957
+ * first turn died before the CLI wrote anything is not.
1958
+ */
1959
+ providerSessionIsResumable(runtimeHome, providerSessionId) {
1960
+ const contract = this.sessionContract();
1961
+ if (!contract) return true;
1962
+ if (!sessionContractFullyEnforced(this.provider) && contract.identity.kind !== "pre-minted") return true;
1963
+ return declaredSessionExists(contract, runtimeHome, providerSessionId);
1964
+ }
1965
+ /**
1966
+ * Resolve the provider-native session id the next turn can resume, or
1967
+ * undefined when no usable binding exists. Provider session data lives
1968
+ * under the runtime home that minted it. A different home or missing
1969
+ * provider storage cannot resume that session. A record without a home
1970
+ * stamp predates the field, so treat it as same-home.
1971
+ */
1972
+ async resumableProviderSessionId(session) {
1973
+ if (!session.providerSessionId) return void 0;
1974
+ const currentHome = this.config.runtimeHome?.trim() || void 0;
1975
+ const mintedHome = session.providerSessionHome?.trim() || void 0;
1976
+ if (currentHome !== void 0 && mintedHome !== void 0 && mintedHome !== currentHome) return;
1977
+ if (currentHome !== void 0) {
1978
+ if (!await this.providerSessionIsResumable(currentHome, session.providerSessionId)) return;
1979
+ }
1980
+ return session.providerSessionId;
1981
+ }
1982
+ /** Bind a checkpoint-proven native session to this provider identity before execution. */
1983
+ async seedNativeSessionForResume(input, nativeSessionId) {
1984
+ if (!input.sessionId) throw new Error("Native resume requires a named session");
1985
+ const runtimeHome = this.config.runtimeHome?.trim();
1986
+ if (!runtimeHome || !await this.providerSessionIsResumable(runtimeHome, nativeSessionId)) throw new Error("Native resume session is absent from this provider home");
1987
+ const store = this.getSessionStore();
1988
+ const existing = await store.get(input.sessionId);
1989
+ if (!existing) await store.create({
1990
+ id: input.sessionId,
1991
+ workspaceRoot: input.workspaceRoot ?? this.storagePath,
1992
+ providerSessionId: nativeSessionId,
1993
+ providerSessionHome: runtimeHome
1994
+ });
1995
+ else if (existing.providerSessionId !== nativeSessionId || existing.providerSessionHome !== runtimeHome) await store.update(input.sessionId, {
1996
+ providerSessionId: nativeSessionId,
1997
+ providerSessionHome: runtimeHome
1998
+ });
1999
+ }
2000
+ /**
2001
+ * Forks the persisted session and copies its messages up to `messageId`.
2002
+ * The forked record carries no provider session id, so its first turn
2003
+ * starts a fresh CLI session and receives the copied transcript as
2004
+ * execution-only context. Callers that address turns by their own session
2005
+ * id (the sidecar) pass that id as `targetSessionId` so later turns find
2006
+ * the forked record.
2007
+ */
2008
+ async forkSession(options) {
2009
+ this.ensureInitialized();
2010
+ const result = await forkSessionWithMessages(this.getSessionStore(), this.getMessageStore(), options.sessionId, {
2011
+ id: options.targetSessionId,
2012
+ messageId: options.messageId,
2013
+ workspaceRoot: options.workspaceRoot
2014
+ });
2015
+ this.logger.debug("Forked session", {
2016
+ parentId: options.sessionId,
2017
+ newId: result.session.id,
2018
+ messagesCopied: result.messagesCopied
2019
+ });
2020
+ return result.session.id;
2021
+ }
2022
+ async getMessages(options) {
2023
+ this.ensureInitialized();
2024
+ return (await this.getMessageStore().list(options.sessionId, {
2025
+ limit: options.limit,
2026
+ offset: options.offset
2027
+ })).map((msg) => this.toBackendMessage(msg));
2028
+ }
2029
+ toBackendMessage(msg) {
2030
+ return {
2031
+ id: msg.id,
2032
+ role: msg.role,
2033
+ parts: msg.parts,
2034
+ timestamp: msg.timestamp.toISOString(),
2035
+ metadata: msg.metadata
2036
+ };
2037
+ }
2038
+ buildAssistantParts(state) {
2039
+ const parts = [];
2040
+ for (const reasoning of state.reasoningBuffer) parts.push({
2041
+ type: "reasoning",
2042
+ text: reasoning
2043
+ });
2044
+ if (state.textBuffer) parts.push({
2045
+ type: "text",
2046
+ text: state.textBuffer
2047
+ });
2048
+ for (const toolCall of state.toolCalls.values()) {
2049
+ const inputRecord = toolCall.input ?? {};
2050
+ let toolState;
2051
+ if (toolCall.status === "completed") toolState = {
2052
+ status: "completed",
2053
+ input: inputRecord,
2054
+ output: toolCall.output
2055
+ };
2056
+ else if (toolCall.status === "failed") toolState = {
2057
+ status: "failed",
2058
+ input: inputRecord,
2059
+ error: toolCall.output ? String(toolCall.output) : void 0
2060
+ };
2061
+ else if (toolCall.status === "running") toolState = {
2062
+ status: "running",
2063
+ input: inputRecord
2064
+ };
2065
+ else toolState = {
2066
+ status: "pending",
2067
+ input: inputRecord
2068
+ };
2069
+ parts.push({
2070
+ type: "tool",
2071
+ tool: toolCall.name,
2072
+ callID: toolCall.id,
2073
+ state: toolState
2074
+ });
2075
+ }
2076
+ for (const file of state.fileParts ?? []) parts.push({
2077
+ type: "file",
2078
+ id: file.id,
2079
+ filename: file.filename,
2080
+ mediaType: file.mediaType,
2081
+ url: file.url
2082
+ });
2083
+ return parts;
2084
+ }
2085
+ extractToolInvocations(state) {
2086
+ const invocations = [];
2087
+ for (const toolCall of state.toolCalls.values()) invocations.push({
2088
+ toolName: toolCall.name,
2089
+ input: toolCall.input,
2090
+ result: toolCall.output,
2091
+ ...toolCall.status === "failed" ? { isError: true } : {}
2092
+ });
2093
+ return invocations;
2094
+ }
2095
+ /**
2096
+ * Names of tools this provider substitutes for a model tool call it could
2097
+ * not parse into any real tool.
2098
+ *
2099
+ * Default: none. A provider that has one must say so, because these calls
2100
+ * do not present as failures. OpenCode's `invalid` reports the parse error
2101
+ * as its output and completes, so the repeated-failure guard reads a run of
2102
+ * them as progress and clears its count on every one.
2103
+ */
2104
+ toolCallRepairToolNames() {
2105
+ return NO_REPAIR_TOOL_NAMES$1;
2106
+ }
2107
+ /**
2108
+ * Decide whether a non-zero exit should be treated as success.
2109
+ *
2110
+ * Default: no — any non-zero exit is a failure.
2111
+ *
2112
+ * Providers override this when their CLI has a known "exits non-zero
2113
+ * on a successful run" quirk (e.g. kimi writes its resume trailer to
2114
+ * stderr and exits non-zero even when the task completed cleanly).
2115
+ *
2116
+ * The decision is made AFTER the stream has drained and the exit
2117
+ * code is known, so `state` reflects the full set of events the
2118
+ * parser emitted (including any terminal `result` / `done` event).
2119
+ * A safe heuristic is: "non-zero exit + stderr matches known trailer
2120
+ * pattern + at least one assistant part was recorded".
2121
+ */
2122
+ isBenignNonZeroExit(_params) {
2123
+ return false;
2124
+ }
2125
+ /** Observe settled process output before success/failure classification. */
2126
+ async afterCliProcessExit(_params) {}
2127
+ executionOutcome(_state, _input) {
2128
+ return { type: "completed" };
2129
+ }
2130
+ /**
2131
+ * Format a message for stdin input.
2132
+ */
2133
+ formatStdinMessage(message) {
2134
+ return message;
2135
+ }
2136
+ };
2137
+ /**
2138
+ * Whether any file below `root` (relative path) matches one of `matchers`.
2139
+ * Bounded: native stores nest a few levels and hold one file per session.
2140
+ */
2141
+ async function anyFileMatches(root, matchers) {
2142
+ const pending = [""];
2143
+ let visited = 0;
2144
+ while (pending.length > 0 && visited < 2e4) {
2145
+ const relative = pending.pop();
2146
+ if (relative === void 0) break;
2147
+ let entries;
2148
+ try {
2149
+ entries = await readdir(path.join(root, relative), { withFileTypes: true });
2150
+ } catch {
2151
+ continue;
2152
+ }
2153
+ for (const entry of entries) {
2154
+ visited++;
2155
+ const child = relative ? `${relative}/${entry.name}` : entry.name;
2156
+ if (entry.isDirectory()) pending.push(child);
2157
+ else if (entry.isFile() && matchers.some((matcher) => matcher.test(child))) return true;
2158
+ }
2159
+ }
2160
+ return false;
2161
+ }
2162
+ /** Whether a per-session store holds a declared file for this id. */
2163
+ async function declaredSessionExists(contract, runtimeHome, nativeSessionId) {
2164
+ for (const store of contract.stores) {
2165
+ if (store.shared) return true;
2166
+ const matchers = store.files.map((glob) => compileSessionStoreGlob(glob, nativeSessionId));
2167
+ if (await anyFileMatches(path.join(runtimeHome, store.root), matchers)) return true;
2168
+ }
2169
+ return false;
2170
+ }
2171
+ //#endregion
2172
+ //#region src/cli-exit-diagnostics.ts
2173
+ /** Characters of stderr a failure message carries, taken from the tail. */
2174
+ const FAILURE_STDERR_TAIL_LIMIT = 600;
2175
+ /**
2176
+ * Attach the process-level diagnostics this run already collected to a failure
2177
+ * message.
2178
+ *
2179
+ * A provider that reports its OWN terminal failure — an empty event stream, an
2180
+ * unfinished model step — explains what it saw but not how the process ended,
2181
+ * and those are different defects: a CLI that exited 0 having emitted nothing
2182
+ * is misconfigured, one killed on the timeout is stuck, one that exited 9 with
2183
+ * stderr crashed. The exit status and the CLI's stderr are in hand at the
2184
+ * point the error is built; without this they reached only a log line and the
2185
+ * caller got a bare sentence to debug from.
2186
+ *
2187
+ * Each part is skipped when the message already states it, so a message
2188
+ * derived FROM stderr or from the exit code is never doubled.
2189
+ */
2190
+ function withCliExitDiagnostics(message, exit, stderr) {
2191
+ const parts = [];
2192
+ if (exit.stdinError && !message.includes(exit.stdinError)) parts.push(`stdin failed: ${exit.stdinError}`);
2193
+ if (exit.timedOut) {
2194
+ const timeoutDetail = exit.timeoutDiagnostic;
2195
+ if (timeoutDetail) {
2196
+ if (!message.includes(timeoutDetail)) parts.push(timeoutDetail);
2197
+ } else if (!/timed out/i.test(message)) parts.push("timed out");
2198
+ }
2199
+ if (exit.code !== null) {
2200
+ if (!message.includes(`code ${exit.code}`)) parts.push(`exit code ${exit.code}`);
2201
+ } else if (exit.signal) parts.push(`killed by ${exit.signal}`);
2202
+ const trimmedStderr = stderr.trim();
2203
+ if (trimmedStderr && !message.includes(trimmedStderr)) parts.push(`stderr: ${trimmedStderr.length > FAILURE_STDERR_TAIL_LIMIT ? `…${trimmedStderr.slice(-FAILURE_STDERR_TAIL_LIMIT)}` : trimmedStderr}`);
2204
+ return parts.length === 0 ? message : `${message} (${parts.join("; ")})`;
2205
+ }
2206
+ //#endregion
2207
+ //#region src/mcp-commands.ts
2208
+ /** Execute one prepared MCP registration command. Serialization belongs to the adapter. */
2209
+ async function addCliMcp(options) {
2210
+ const { name, config, run, logger } = options;
2211
+ logger.debug("Adding MCP server", {
2212
+ name,
2213
+ type: config.type
2214
+ });
2215
+ const result = await run();
2216
+ if (!result.exit.success) throw new Error(`Failed to add MCP server "${name}": ${result.stderr || `exit code ${result.exit.code}`}`);
2217
+ logger.info("Added MCP server", { name });
2218
+ }
2219
+ /** List native MCP servers with the provider's parser and command budget. */
2220
+ async function listCliMcps(options) {
2221
+ const { provider, args, run, logger, parse } = options;
2222
+ if (!args) {
2223
+ logger.warn(`getMcpStatus: ${provider} CLI does not support listing MCP servers`);
2224
+ return { servers: {} };
2225
+ }
2226
+ const result = await run(args);
2227
+ if (!result.exit.success) throw new Error(withCliExitDiagnostics(`${provider} failed to list MCP servers`, result.exit, result.stderr));
2228
+ return parse(result.stdout ?? "");
2229
+ }
2230
+ const MCP_STATUSES = [
2231
+ "connected",
2232
+ "disconnected",
2233
+ "error",
2234
+ "unknown"
2235
+ ];
2236
+ const MCP_TYPES = [
2237
+ "local",
2238
+ "remote",
2239
+ "stdio",
2240
+ "http"
2241
+ ];
2242
+ function pick(known, value, fallback) {
2243
+ return known.includes(value) ? value : fallback;
2244
+ }
2245
+ /**
2246
+ * Read an `mcp list` report: a JSON array of `{name, status, type}`, a JSON
2247
+ * object keyed by name, or a whitespace table whose first column is the name
2248
+ * and whose next columns may hold the type and the status.
2249
+ */
2250
+ function parseMcpListReport(output) {
2251
+ const servers = {};
2252
+ const add = (name, status, type) => {
2253
+ servers[name] = {
2254
+ name,
2255
+ status: pick(MCP_STATUSES, status, "unknown"),
2256
+ type: pick(MCP_TYPES, type, "stdio")
2257
+ };
2258
+ };
2259
+ let data;
2260
+ try {
2261
+ data = JSON.parse(output);
2262
+ } catch {
2263
+ for (const line of output.trim().split("\n")) {
2264
+ if (!line.trim() || line.includes("NAME") || line.startsWith("-")) continue;
2265
+ const [name = "", type, status] = line.trim().split(/\s{2,}/);
2266
+ add(name, status, type);
2267
+ }
2268
+ return { servers };
2269
+ }
2270
+ if (Array.isArray(data)) for (const server of data) add(server.name, server.status ?? "connected", server.type);
2271
+ else if (typeof data === "object" && data !== null) for (const [name, info] of Object.entries(data)) add(name, info.status, void 0);
2272
+ return { servers };
2273
+ }
2274
+ /** A local MCP server's argv: the `command` array, or `command` then `args`. */
2275
+ function localMcpArgv(config) {
2276
+ const local = config;
2277
+ return Array.isArray(local.command) ? local.command : [local.command, ...local.args ?? []];
2278
+ }
2279
+ /**
2280
+ * A local MCP server's environment, from `environment` (agent-backend-types)
2281
+ * or `env` (agent-interface): reading one drops the other family's variables.
2282
+ */
2283
+ function localMcpEnv(config) {
2284
+ return config.environment ?? config.env;
2285
+ }
2286
+ const isLocalMcpConfig = (c) => c.type === "local" || c.type === "stdio";
2287
+ /** Build MCP add command arguments. Override for CLI-specific syntax. */
2288
+ function defaultMcpAddArgs(name, config) {
2289
+ if (isLocalMcpConfig(config)) {
2290
+ const env = Object.entries(localMcpEnv(config) ?? {});
2291
+ return [
2292
+ "mcp",
2293
+ "add",
2294
+ name,
2295
+ "--",
2296
+ ...localMcpArgv(config),
2297
+ ...env.flatMap(([key, value]) => ["--env", `${key}=${value}`])
2298
+ ];
2299
+ }
2300
+ const headers = Object.entries(config.headers ?? {});
2301
+ return [
2302
+ "mcp",
2303
+ "add",
2304
+ name,
2305
+ config.url,
2306
+ ...headers.flatMap(([key, value]) => ["--header", `${key}: ${value}`])
2307
+ ];
2308
+ }
2309
+ //#endregion
2310
+ //#region src/adapter-mcp.ts
2311
+ /** Owns serialized MCP mutations and provider-native MCP status commands. */
2312
+ var CliAdapterMcp = class extends CliAdapterSessions {
2313
+ /** Budget for one `mcp list` run, CLI startup and every server's handshake. */
2314
+ mcpStatusTimeoutMs = 1e4;
2315
+ mcpMutationChain = Promise.resolve();
2316
+ async addMcp(name, config) {
2317
+ const run = this.mcpMutationChain.then(() => this.addMcpSerialized(name, config));
2318
+ this.mcpMutationChain = run.catch(() => void 0);
2319
+ return run;
2320
+ }
2321
+ async addMcpSerialized(name, config) {
2322
+ this.ensureInitialized();
2323
+ const args = this.buildMcpAddArgs(name, config);
2324
+ return addCliMcp({
2325
+ name,
2326
+ config,
2327
+ logger: this.logger,
2328
+ run: () => this.runAuxiliaryCli(args, 3e4)
2329
+ });
2330
+ }
2331
+ async getMcpStatus() {
2332
+ this.ensureInitialized();
2333
+ return listCliMcps({
2334
+ provider: this.provider,
2335
+ args: this.buildMcpListArgs(),
2336
+ logger: this.logger,
2337
+ run: (args) => this.runAuxiliaryCli(args, this.mcpStatusTimeoutMs),
2338
+ parse: (output) => this.parseMcpListOutput(output)
2339
+ });
2340
+ }
2341
+ async ensureMcps(required) {
2342
+ const currentStatus = await this.getMcpStatus();
2343
+ const existingServers = new Set(Object.keys(currentStatus.servers));
2344
+ for (const [name, config] of Object.entries(required)) {
2345
+ if (existingServers.has(name)) {
2346
+ this.logger.debug("MCP server already exists, skipping", { name });
2347
+ continue;
2348
+ }
2349
+ await this.addMcp(name, config);
2350
+ }
2351
+ }
2352
+ /** Build MCP add command arguments. Override for CLI-specific syntax. */
2353
+ buildMcpAddArgs(name, config) {
2354
+ return defaultMcpAddArgs(name, config);
2355
+ }
2356
+ /**
2357
+ * Build MCP list command arguments. Return null if not supported.
2358
+ */
2359
+ buildMcpListArgs() {
2360
+ return null;
2361
+ }
2362
+ /** Parse MCP list output. Override for CLI-specific output format. */
2363
+ parseMcpListOutput(output) {
2364
+ return parseMcpListReport(output);
2365
+ }
2366
+ };
2367
+ //#endregion
2368
+ //#region src/execution-state.ts
2369
+ /**
2370
+ * Creates a new execution state.
2371
+ */
2372
+ function createExecutionState(sessionId, messageId, workspaceRoot) {
2373
+ return {
2374
+ sessionId,
2375
+ messageId: messageId ?? randomUUID(),
2376
+ workspaceRoot,
2377
+ toolCalls: /* @__PURE__ */ new Map(),
2378
+ textBuffer: "",
2379
+ reasoningBuffer: []
2380
+ };
2381
+ }
2382
+ //#endregion
2383
+ //#region src/session-preparation.ts
2384
+ async function prepareExecutionSession(context, input) {
2385
+ let sessionId = input.sessionId;
2386
+ let providerSessionId;
2387
+ let transcriptReplay;
2388
+ if (sessionId) {
2389
+ const session = await context.getSessionStore().get(sessionId);
2390
+ if (session) {
2391
+ providerSessionId = await context.resumableProviderSessionId(session);
2392
+ if (session.parentId !== void 0 && session.providerSessionId === void 0) transcriptReplay = "fork";
2393
+ else if (context.config.secretProfileRevisionRoot && session.providerSessionId === void 0 && (await context.getMessageStore().list(sessionId)).length > 0) transcriptReplay = "restored";
2394
+ else if (session.providerSessionId && providerSessionId === void 0) {
2395
+ context.logger.info("Provider session data is not resumable; replaying durable history into a fresh provider session", {
2396
+ sessionId,
2397
+ providerSessionId: session.providerSessionId
2398
+ });
2399
+ transcriptReplay = "restored";
2400
+ }
2401
+ await context.getSessionStore().update(sessionId, {});
2402
+ } else {
2403
+ if (context.config.secretProfileRevisionRoot) try {
2404
+ transcriptReplay = (await context.getMessageStore().list(sessionId, {
2405
+ requireComplete: true,
2406
+ allowAbsent: true
2407
+ })).length > 0 ? "restored" : void 0;
2408
+ } catch (error) {
2409
+ throw new SessionRehydrationError(`Session ${sessionId} has incomplete persisted history`, error);
2410
+ }
2411
+ else transcriptReplay = "restored";
2412
+ await context.getSessionStore().create({
2413
+ id: sessionId,
2414
+ workspaceRoot: input.workspaceRoot ?? context.storagePath
2415
+ });
2416
+ }
2417
+ } else sessionId = await context.createSession({ workspaceRoot: input.workspaceRoot ?? context.storagePath });
2418
+ return {
2419
+ sessionId,
2420
+ providerSessionId,
2421
+ transcriptReplay
2422
+ };
2423
+ }
2424
+ //#endregion
2425
+ //#region src/turn-execution.ts
2426
+ /**
2427
+ * Turn idempotency: if the caller supplied a turnId and a previously-
2428
+ * completed turn with that id exists, return its cached result without
2429
+ * re-issuing the upstream LLM call. Both `findCompletedTurn` and
2430
+ * `setTurnId` are optional on the recorder interface so providers using
2431
+ * a no-op recorder (sdk-backend) still typecheck.
2432
+ */
2433
+ async function findCachedTurn(recorder, input, logger, sessionId) {
2434
+ if (!(input.turnId && recorder.findCompletedTurn)) return void 0;
2435
+ const cached = await recorder.findCompletedTurn(input.turnId);
2436
+ if (!cached) return void 0;
2437
+ logger.info("Returning cached turn result (idempotent retry)", {
2438
+ turnId: input.turnId,
2439
+ sessionId
2440
+ });
2441
+ const outcome = cached.outcome === void 0 ? { type: "completed" } : AgentExecutionOutcomeSchema.parse(cached.outcome);
2442
+ return {
2443
+ ...cached,
2444
+ outcome
2445
+ };
2446
+ }
2447
+ /**
2448
+ * The persisted transcript, read before this turn's user message is appended
2449
+ * so the block never contains the current message. Execution-only: it reaches
2450
+ * the model prompt and stays out of both stores.
2451
+ */
2452
+ async function buildTranscriptContext(params) {
2453
+ const { provider, sessionId, transcriptReplay, secretProfileRevisionRoot } = params;
2454
+ if (!transcriptReplay) return void 0;
2455
+ try {
2456
+ return (secretProfileRevisionRoot ? buildCompleteForkContextBlock : buildForkContextBlock)(await params.getMessageStore().list(sessionId, { requireComplete: Boolean(secretProfileRevisionRoot) }), transcriptReplay === "restored" ? {
2457
+ preamble: RESTORED_SESSION_CONTEXT_PREAMBLE,
2458
+ tag: RESTORED_SESSION_CONTEXT_TAG,
2459
+ ...secretProfileRevisionRoot ? { maxChars: MAX_SECRET_PROFILE_REPLAY_CHARS } : {}
2460
+ } : secretProfileRevisionRoot ? { maxChars: MAX_SECRET_PROFILE_REPLAY_CHARS } : void 0);
2461
+ } catch (error) {
2462
+ throw new SessionRehydrationError(`Session ${sessionId} has persisted history that could not be read for replay into a fresh ${provider} session: ${error instanceof Error ? error.message : String(error)}`, error);
2463
+ }
2464
+ }
2465
+ /**
2466
+ * A harness that can only report its id in an event must do so promptly; a
2467
+ * turn whose native session cannot be named is not traceable. Returns the
2468
+ * deadline timer, or undefined when the contract sets none.
2469
+ */
2470
+ function armNativeSessionDeadline(params) {
2471
+ const { provider, sessionId, sessionContract, state, observer, logger } = params;
2472
+ const identity = sessionContract?.identity;
2473
+ if (identity?.kind !== "first-event" || !sessionContractFullyEnforced(provider)) return;
2474
+ const deadline = setTimeout(() => {
2475
+ if (observer.nativeSessionObserved || state.terminalStatus !== void 0) return;
2476
+ const detail = new HarnessSessionContractError("native_session_id_unobserved", `${provider} reported no native session id within ${identity.deadlineMs} ms`).message;
2477
+ state.terminalStatus = "failed";
2478
+ state.lastDiagnostic = detail;
2479
+ logger.error("Native session id unobserved; stopping turn", { sessionId });
2480
+ params.kill();
2481
+ }, identity.deadlineMs);
2482
+ deadline.unref();
2483
+ return deadline;
2484
+ }
2485
+ /** Accumulate a turn process's stderr as text. */
2486
+ function collectStderr(cliProcess) {
2487
+ let text = "";
2488
+ const stderrDecoder = new StringDecoder("utf8");
2489
+ cliProcess.stderr?.on("data", (chunk) => {
2490
+ text += stderrDecoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
2491
+ });
2492
+ cliProcess.stderr?.on("end", () => {
2493
+ text += stderrDecoder.end();
2494
+ });
2495
+ return { get text() {
2496
+ return text;
2497
+ } };
2498
+ }
2499
+ /** The message a failed turn reports, from the strongest available cause. */
2500
+ function describeCliFailure(params) {
2501
+ const { provider, state, exitResult, stderr, command, nixBinPath } = params;
2502
+ const spawnFailure = exitResult.spawnError ? `Failed to spawn "${command}": ${exitResult.spawnError}` + (exitResult.spawnError.includes("ENOENT") ? ` — binary not found in PATH${nixBinPath ? ` (NIX_BIN_PATH=${nixBinPath})` : " (NIX_BIN_PATH unset — is the Nix profile mounted?)"}` : "") : null;
2503
+ const failureCause = state.failure?.message ?? state.lastDiagnostic ?? (exitResult.timedOut ? exitResult.timeoutDiagnostic ?? "Execution timed out" : void 0) ?? (stderr.trim() || (state.terminalStatus === "failed" ? `${provider} reported a failed terminal status` : `Process exited with code ${exitResult.code}`));
2504
+ return spawnFailure ?? withCliExitDiagnostics(failureCause, exitResult, stderr);
2505
+ }
2506
+ /** The error a failed turn throws, carrying the stream's failure details. */
2507
+ function buildTurnFailure(params) {
2508
+ const { provider, errorMessage, state, startTime, endTime } = params;
2509
+ const failure = new AgentExecutionError(`${provider} execution failed: ${errorMessage}`, {
2510
+ ...state.tokenUsage ? { tokenUsage: state.tokenUsage } : {},
2511
+ timing: {
2512
+ startedAt: startTime,
2513
+ completedAt: endTime,
2514
+ durationMs: endTime - startTime
2515
+ }
2516
+ });
2517
+ if (state.failure) Object.assign(failure, {
2518
+ ...state.failure.code ? { code: state.failure.code } : {},
2519
+ ...state.failure.statusCode === void 0 ? {} : {
2520
+ statusCode: state.failure.statusCode,
2521
+ status: state.failure.statusCode
2522
+ }
2523
+ });
2524
+ return failure;
2525
+ }
2526
+ /**
2527
+ * Best-effort: stamp the assistant message interrupted so reattach can
2528
+ * surface "this turn died" without inferring from absence. Swallow any
2529
+ * metadata-write error — the upstream failure is the one the caller
2530
+ * needs to see. The coalesced part updates drain first so no stale
2531
+ * snapshot lands after the terminal stamp.
2532
+ */
2533
+ async function recordTurnInterruption(params) {
2534
+ const { recorder, observer, logger, errorMessage, exitResult } = params;
2535
+ await observer.flushPartUpdates();
2536
+ if (recorder.markTurnInterrupted) try {
2537
+ await recorder.markTurnInterrupted({
2538
+ reason: errorMessage,
2539
+ exitCode: exitResult.code,
2540
+ timedOut: exitResult.timedOut,
2541
+ failureReceipt: params.failure.receipt
2542
+ });
2543
+ } catch (markError) {
2544
+ logger.warn("markTurnInterrupted failed (suppressed to preserve original error)", { error: markError instanceof Error ? markError.message : String(markError) });
2545
+ }
2546
+ }
2547
+ /** The result of a turn that completed. */
2548
+ function buildExecutionResult(params) {
2549
+ const { state, publicProfileRevision, startTime, endTime } = params;
2550
+ return {
2551
+ outcome: params.outcome,
2552
+ text: state.textBuffer,
2553
+ toolInvocations: params.toolInvocations,
2554
+ reasoning: state.reasoningBuffer.length > 0 ? state.reasoningBuffer : void 0,
2555
+ sessionId: params.sessionId,
2556
+ ...publicProfileRevision ? { metadata: { publicProfileRevision } } : {},
2557
+ tokenUsage: state.tokenUsage ? {
2558
+ inputTokens: state.tokenUsage.inputTokens ?? 0,
2559
+ outputTokens: state.tokenUsage.outputTokens ?? 0,
2560
+ totalTokens: state.tokenUsage.totalTokens,
2561
+ reasoningTokens: state.tokenUsage.reasoningTokens,
2562
+ cacheReadInputTokens: state.tokenUsage.cacheReadInputTokens,
2563
+ cacheCreationInputTokens: state.tokenUsage.cacheCreationInputTokens,
2564
+ cost: state.tokenUsage.cost
2565
+ } : void 0,
2566
+ timing: {
2567
+ startedAt: startTime,
2568
+ completedAt: endTime,
2569
+ durationMs: endTime - startTime
2570
+ }
2571
+ };
2572
+ }
2573
+ /** A turn whose provider session record failed to persist ends failed. */
2574
+ function failOnProviderSessionPersistence(observer, state) {
2575
+ if (!observer.providerSessionPersistenceError) return;
2576
+ const message = observer.providerSessionPersistenceError instanceof Error ? observer.providerSessionPersistenceError.message : String(observer.providerSessionPersistenceError);
2577
+ state.terminalStatus = "failed";
2578
+ state.lastDiagnostic = `Provider session persistence failed: ${message}`;
2579
+ }
2580
+ /** Log a non-zero exit the provider accepted, and the stderr a successful turn left. */
2581
+ function logAcceptedCliExit(logger, exitResult, stderr) {
2582
+ if (!exitResult.success) logger.info("CLI process exited non-zero but was treated as success by provider", {
2583
+ exitCode: exitResult.code,
2584
+ stderr: stderr.slice(0, 500)
2585
+ });
2586
+ if (stderr.trim()) logger.debug("CLI process stderr (success)", { stderr: stderr.slice(0, 1e3) });
2587
+ }
2588
+ //#endregion
2589
+ //#region src/part-update-queue.ts
2590
+ const MAX_PENDING_PART_UPDATES = 256;
2591
+ /**
2592
+ * Per-chunk persistence backpressure. Accumulated-text providers re-emit
2593
+ * the whole part on every token, and each update joins the recorder's
2594
+ * serialized write chain — if disk latency exceeds the token rate, the
2595
+ * chain grows for the turn's lifetime. Coalescing keeps only the newest
2596
+ * pending snapshot per partId (a stale one is a strict prefix of it), so
2597
+ * the queue stays O(distinct parts); a single drain loop preserves write
2598
+ * order. The terminal paths flush the drain before their final writes so
2599
+ * no stale snapshot can land after the consolidated parts.
2600
+ */
2601
+ var PartUpdateQueue = class {
2602
+ pending = /* @__PURE__ */ new Map();
2603
+ drain = null;
2604
+ constructor(recorder, logger) {
2605
+ this.recorder = recorder;
2606
+ this.logger = logger;
2607
+ }
2608
+ schedule(part, delta) {
2609
+ const partId = part.id;
2610
+ if (!partId) {
2611
+ this.recorder.recordAssistantPartUpdate?.(part, delta)?.catch((cause) => {
2612
+ this.logger.warn("Per-chunk persistence failed (continuing stream)", { error: cause instanceof Error ? cause.message : String(cause) });
2613
+ });
2614
+ return;
2615
+ }
2616
+ this.pending.set(partId, {
2617
+ part,
2618
+ delta
2619
+ });
2620
+ if (this.pending.size > MAX_PENDING_PART_UPDATES) {
2621
+ const oldestPending = this.pending.keys().next();
2622
+ if (!oldestPending.done) this.pending.delete(oldestPending.value);
2623
+ this.logger.warn("Part-update persist backlog overflow, dropped oldest pending snapshot", { pendingLimit: MAX_PENDING_PART_UPDATES });
2624
+ }
2625
+ if (!this.drain) this.drain = this.drainPending().finally(() => {
2626
+ this.drain = null;
2627
+ });
2628
+ }
2629
+ async flush() {
2630
+ while (this.drain) await this.drain;
2631
+ }
2632
+ async drainPending() {
2633
+ for (;;) {
2634
+ const oldest = this.pending.keys().next();
2635
+ if (oldest.done) break;
2636
+ const update = this.pending.get(oldest.value);
2637
+ this.pending.delete(oldest.value);
2638
+ if (!update) continue;
2639
+ try {
2640
+ await this.recorder.recordAssistantPartUpdate?.(update.part, update.delta);
2641
+ } catch (err) {
2642
+ this.logger.warn("Per-chunk persistence failed (continuing stream)", { error: err instanceof Error ? err.message : String(err) });
2643
+ }
2644
+ }
2645
+ }
2646
+ };
2647
+ //#endregion
2648
+ //#region src/repeated-tool-failure.ts
2649
+ const NO_REPAIR_TOOL_NAMES = /* @__PURE__ */ new Set();
2650
+ /** A part in one of these states has not resolved, so it decides nothing. */
2651
+ function isUnresolved(state) {
2652
+ return state.status === "pending" || state.status === "running";
2653
+ }
2654
+ function readString(input, key) {
2655
+ const value = input[key];
2656
+ return typeof value === "string" && value.trim() !== "" ? value : void 0;
2657
+ }
2658
+ /** The tool's own error text, for the states that carry one. */
2659
+ function readStateError(state) {
2660
+ if (state.status !== "error" && state.status !== "failed") return void 0;
2661
+ return state.error?.trim() ? state.error : void 0;
2662
+ }
2663
+ /**
2664
+ * Describe a resolved tool part as a failure, or return `undefined` when it
2665
+ * produced something.
2666
+ *
2667
+ * A repair tool reports the call it stood in for through its input, so the
2668
+ * trip names `write` rather than the substitute. That is the tool the reader
2669
+ * has to go and look at.
2670
+ */
2671
+ function describeFailure(part, repairToolNames) {
2672
+ const { state } = part;
2673
+ if (isUnresolved(state)) return void 0;
2674
+ if (repairToolNames.has(part.tool)) return {
2675
+ tool: readString(state.input, "tool") ?? part.tool,
2676
+ detail: readString(state.input, "error") ?? readStateError(state) ?? `${part.tool} reported a tool call it could not parse`
2677
+ };
2678
+ if (state.status !== "error" && state.status !== "failed") return void 0;
2679
+ return {
2680
+ tool: part.tool,
2681
+ detail: readStateError(state) ?? `${part.tool} failed without reporting a reason`
2682
+ };
2683
+ }
2684
+ function createRepeatedToolFailureGuard(options) {
2685
+ const { limit } = options;
2686
+ if (!Number.isInteger(limit) || limit < 1) throw new Error(`Repeated tool-failure limit must be a positive whole number (got ${limit})`);
2687
+ const repairToolNames = options.repairToolNames ?? NO_REPAIR_TOOL_NAMES;
2688
+ let signature;
2689
+ let previousInput;
2690
+ let count = 0;
2691
+ let lastScoredPartId;
2692
+ let tripped = false;
2693
+ return { observe(part) {
2694
+ if (tripped) return void 0;
2695
+ if (isUnresolved(part.state)) return void 0;
2696
+ if (part.id === lastScoredPartId) return void 0;
2697
+ lastScoredPartId = part.id;
2698
+ const failure = describeFailure(part, repairToolNames);
2699
+ if (!failure) {
2700
+ signature = void 0;
2701
+ previousInput = void 0;
2702
+ count = 0;
2703
+ return;
2704
+ }
2705
+ const next = JSON.stringify([failure.tool, failure.detail]);
2706
+ count = next === signature && isDeepStrictEqual(part.state.input, previousInput) ? count + 1 : 1;
2707
+ previousInput = structuredClone(part.state.input);
2708
+ signature = next;
2709
+ if (count < limit) return void 0;
2710
+ tripped = true;
2711
+ return {
2712
+ ...failure,
2713
+ count
2714
+ };
2715
+ } };
2716
+ }
2717
+ //#endregion
2718
+ //#region src/turn-liveness.ts
2719
+ /**
2720
+ * How long the provider stream stays quiet before the adapter emits a liveness
2721
+ * event, and how often it re-checks that quiet window.
2722
+ *
2723
+ * A model that reasons for a minute and a run that died look the same on the
2724
+ * wire: an open stream with nothing on it. A consumer that must show progress
2725
+ * resolves the ambiguity with an inactivity timeout. The Blueprint Agent
2726
+ * frontend allows 20 s (blueprint-agent
2727
+ * apps/web/src/ui-provider/devcontainer-events/hooks/useStreamWatchdog.ts).
2728
+ * It then aborts a turn that the CLI is still running.
2729
+ *
2730
+ * A ping on quiet feeds that timeout without inventing content. The check
2731
+ * interval bounds the longest silence at quiet plus check, so these values put
2732
+ * the worst case at 6 s. A provider that emits more often than the quiet
2733
+ * window never trips the ticker, so nothing double-emits.
2734
+ */
2735
+ const STREAM_LIVENESS_QUIET_MS = 5e3;
2736
+ const STREAM_LIVENESS_CHECK_MS = 1e3;
2737
+ /**
2738
+ * How long the adapter vouches for an unbroken silence.
2739
+ *
2740
+ * A ping says the CLI process runs. It cannot say the model makes progress.
2741
+ * An unbounded ping stream would hide a CLI that starts and then hangs.
2742
+ *
2743
+ * No model in use here emits nothing for ten minutes. Past that the ticker
2744
+ * stops so the consumer can observe the silence. A later provider event
2745
+ * restarts the window.
2746
+ */
2747
+ const STREAM_LIVENESS_MAX_QUIET_MS = 6e5;
2748
+ /**
2749
+ * The clocks and running-tool bookkeeping behind the stream liveness ping.
2750
+ *
2751
+ * `lastProviderEventAt` moves only on a real provider event, and it bounds
2752
+ * how long one silence can run. `lastLivenessAt` also moves on each ping, and
2753
+ * it paces the pings.
2754
+ */
2755
+ var TurnLiveness = class {
2756
+ lastProviderEventAt = Date.now();
2757
+ lastLivenessAt = this.lastProviderEventAt;
2758
+ runningToolParts = /* @__PURE__ */ new Map();
2759
+ constructor(state, logger, onEvent) {
2760
+ this.state = state;
2761
+ this.logger = logger;
2762
+ this.onEvent = onEvent;
2763
+ }
2764
+ /** A real provider event arrived: reset both clocks. */
2765
+ noteProviderEvent() {
2766
+ this.lastProviderEventAt = Date.now();
2767
+ this.lastLivenessAt = this.lastProviderEventAt;
2768
+ }
2769
+ /** Track a tool part by its reported state; call after `noteProviderEvent`. */
2770
+ noteToolPart(id, tool, status) {
2771
+ if (status === "running") {
2772
+ if (!this.runningToolParts.has(id)) this.runningToolParts.set(id, {
2773
+ toolName: tool,
2774
+ startedAt: this.lastProviderEventAt
2775
+ });
2776
+ } else this.runningToolParts.delete(id);
2777
+ }
2778
+ start(executionSignal, startTime) {
2779
+ const livenessTicker = setInterval(() => {
2780
+ const now = Date.now();
2781
+ if (executionSignal.aborted || this.state.terminalStatus !== void 0 || now - this.lastLivenessAt < STREAM_LIVENESS_QUIET_MS || now - this.lastProviderEventAt > STREAM_LIVENESS_MAX_QUIET_MS) return;
2782
+ this.lastLivenessAt = now;
2783
+ const oldestRunningTool = this.runningToolParts.entries().next();
2784
+ const ping = oldestRunningTool.done ? {
2785
+ type: "model-processing",
2786
+ phase: "thinking",
2787
+ elapsedMs: now - startTime
2788
+ } : {
2789
+ type: "tool-heartbeat",
2790
+ toolName: oldestRunningTool.value[1].toolName,
2791
+ partId: oldestRunningTool.value[0],
2792
+ elapsedMs: now - oldestRunningTool.value[1].startedAt
2793
+ };
2794
+ try {
2795
+ this.onEvent?.(ping);
2796
+ } catch (error) {
2797
+ this.logger.warn("Stream liveness ping failed (continuing turn)", { error: error instanceof Error ? error.message : String(error) });
2798
+ }
2799
+ }, STREAM_LIVENESS_CHECK_MS);
2800
+ livenessTicker.unref();
2801
+ return livenessTicker;
2802
+ }
2803
+ };
2804
+ //#endregion
2805
+ //#region src/turn-observer.ts
2806
+ /**
2807
+ * How many times a tool call may be rejected identically in a row before the
2808
+ * adapter kills the turn.
2809
+ *
2810
+ * Three separates a model that recovers from one that cannot. The first
2811
+ * attempt is the mistake. The second is the model reading the error. A third
2812
+ * identical attempt says the error does not reach it. Against a staging run
2813
+ * that made 76 attempts, this cuts the turn on the third.
2814
+ */
2815
+ const REPEATED_TOOL_FAILURE_LIMIT = 3;
2816
+ /**
2817
+ * Observes one turn's provider stream: keeps the execution state current,
2818
+ * persists parts and the provider session id as they arrive, stops a turn
2819
+ * that repeats a rejected tool call, and pings while the stream is quiet.
2820
+ */
2821
+ var TurnObserver = class {
2822
+ providerSessionPersistence = Promise.resolve();
2823
+ providerSessionPersistenceError;
2824
+ repeatedToolFailureTripped = false;
2825
+ nativeSessionObserved = false;
2826
+ partUpdates;
2827
+ liveness;
2828
+ repeatedToolFailureGuard;
2829
+ killForRepeatedToolFailure;
2830
+ constructor(options) {
2831
+ this.options = options;
2832
+ const { state, logger, recorder, repairToolNames, onEvent } = options;
2833
+ this.partUpdates = new PartUpdateQueue(recorder, logger);
2834
+ this.liveness = new TurnLiveness(state, logger, onEvent);
2835
+ this.repeatedToolFailureGuard = createRepeatedToolFailureGuard({
2836
+ limit: REPEATED_TOOL_FAILURE_LIMIT,
2837
+ repairToolNames
2838
+ });
2839
+ }
2840
+ flushPartUpdates() {
2841
+ return this.partUpdates.flush();
2842
+ }
2843
+ attachProcess(process) {
2844
+ this.killForRepeatedToolFailure = () => process.kill();
2845
+ }
2846
+ startLiveness(executionSignal, startTime) {
2847
+ return this.liveness.start(executionSignal, startTime);
2848
+ }
2849
+ onEvent = (event) => {
2850
+ const { provider, state, recorder } = this.options;
2851
+ this.liveness.noteProviderEvent();
2852
+ if (event.type === "message.part.updated" && event.part.type === "tool") this.observeToolPart(event.part);
2853
+ const providerSessionId = event.type === "native.session.observed" && event.provider === provider ? event.nativeSessionId : event.type === "session.updated" ? event.sessionId : void 0;
2854
+ if (event.type === "native.session.observed" && event.provider === provider) this.nativeSessionObserved = true;
2855
+ if (providerSessionId) {
2856
+ state.sessionId = providerSessionId;
2857
+ this.persistProviderSession(providerSessionId);
2858
+ }
2859
+ if (event.type === "warning") state.lastDiagnostic = event.message;
2860
+ if (event.type === "status" && (event.status === "completed" || event.status === "failed")) {
2861
+ state.terminalStatus = event.status;
2862
+ if (event.status === "failed" && event.detail) state.lastDiagnostic = event.detail;
2863
+ }
2864
+ if (event.type === "message.part.updated" && recorder.recordAssistantPartUpdate) this.partUpdates.schedule(event.part, event.delta);
2865
+ this.options.onEvent?.(event);
2866
+ };
2867
+ observeToolPart(part) {
2868
+ const { provider, state, logger, sessionId } = this.options;
2869
+ const { id, tool, state: toolState } = part;
2870
+ this.liveness.noteToolPart(id, tool, toolState.status);
2871
+ const repeated = this.repeatedToolFailureGuard.observe({
2872
+ id,
2873
+ tool,
2874
+ state: toolState
2875
+ });
2876
+ if (repeated) {
2877
+ const detail = `${provider} sent the same ${repeated.tool} call ${repeated.count} times and every one was rejected. The turn stopped before it ran the model again: ${repeated.detail}`;
2878
+ this.repeatedToolFailureTripped = true;
2879
+ state.terminalStatus = "failed";
2880
+ state.lastDiagnostic = detail;
2881
+ logger.error("Repeated tool-call rejection stopped the turn", {
2882
+ tool: repeated.tool,
2883
+ attempts: repeated.count,
2884
+ reason: repeated.detail,
2885
+ sessionId
2886
+ });
2887
+ this.killForRepeatedToolFailure?.();
2888
+ }
2889
+ }
2890
+ persistProviderSession(providerSessionId) {
2891
+ const { getRuntimeHome, getSessionStore, sessionId } = this.options;
2892
+ this.providerSessionPersistence = this.providerSessionPersistence.then(async () => {
2893
+ const runtimeHome = getRuntimeHome();
2894
+ if (!await getSessionStore().update(sessionId, {
2895
+ providerSessionId,
2896
+ ...runtimeHome?.trim() ? { providerSessionHome: runtimeHome.trim() } : {}
2897
+ })) throw new Error(`Session ${sessionId} disappeared before its provider session could be persisted`);
2898
+ }).catch((error) => {
2899
+ this.providerSessionPersistenceError ??= error;
2900
+ });
2901
+ }
2902
+ };
2903
+ //#endregion
2904
+ //#region src/turn-process.ts
2905
+ /** How one turn's argv runs: cwd, environment, identity or launcher, stdio. */
2906
+ function cliSpawnConfig(params) {
2907
+ const { execArgs, launcher, subprocessUser } = params;
2908
+ const stdio = !execArgs.useStdin ? { stdio: [
2909
+ "ignore",
2910
+ "pipe",
2911
+ "pipe"
2912
+ ] } : {};
2913
+ return {
2914
+ command: execArgs.command,
2915
+ args: execArgs.args,
2916
+ cwd: execArgs.cwd ?? params.workspaceRoot,
2917
+ env: params.env,
2918
+ inheritEnv: params.inheritEnv,
2919
+ ...params.ownProcessGroup ? { ownProcessGroup: true } : {},
2920
+ ...launcher ? {
2921
+ launcher,
2922
+ spawnOptions: stdio
2923
+ } : {
2924
+ ...subprocessUser ? { isolatedIdentity: subprocessUser } : {},
2925
+ spawnOptions: {
2926
+ ...params.subprocessSpawnOptions,
2927
+ ...stdio
2928
+ }
2929
+ }
2930
+ };
2931
+ }
2932
+ /** A started CLI as the turn drives it, timing its spawn and first output. */
2933
+ function cliTurnProcess(child, logger, spawnStarted) {
2934
+ if (logger.observePhase) {
2935
+ child.process.once("spawn", () => {
2936
+ logger.observePhase?.("cli_spawn", performance.now() - spawnStarted);
2937
+ });
2938
+ child.process.stdout?.once("data", () => {
2939
+ logger.observePhase?.("cli_boot", performance.now() - spawnStarted);
2940
+ });
2941
+ }
2942
+ return {
2943
+ pid: child.pid,
2944
+ stderr: child.process.stderr ?? void 0,
2945
+ write: child.write,
2946
+ closeInput: child.closeInput,
2947
+ kill: child.kill,
2948
+ exitPromise: child.exitPromise,
2949
+ beginRawCapture: child.beginRawCapture
2950
+ };
2951
+ }
2952
+ //#endregion
2953
+ //#region src/base-adapter.ts
2954
+ /**
2955
+ * Base adapter for CLI-based AI agent providers.
2956
+ *
2957
+ * Subclasses must implement:
2958
+ * - provider: string identifier
2959
+ * - defaultCliPath: default CLI executable name
2960
+ * - buildExecArgs(): build CLI execution arguments
2961
+ * - buildExecEnv(): build environment variables
2962
+ * - createEventParser(): create JSONL event parser
2963
+ * - mapEventToStreamEvents(): map parsed events to StreamEvents
2964
+ *
2965
+ * The message log directory is the registry spec's `messageStorageDir` for
2966
+ * `provider` (cli-agent-registry `messageStorageDirOf`); only an adapter for
2967
+ * a provider the registry does not know sets `defaultStorageDir`.
2968
+ *
2969
+ * Optional overrides:
2970
+ * - buildMcpAddArgs(): MCP add command arguments
2971
+ * - buildMcpListArgs(): MCP list command arguments (if supported)
2972
+ * - parseMcpListOutput(): parse MCP list output
2973
+ */
2974
+ var CliProviderAdapterBase = class extends CliAdapterMcp {
2975
+ supportsManagedQuote = false;
2976
+ async execute(input, services, onEvent) {
2977
+ this.ensureInitialized();
2978
+ const quoteVersion = input.managedQuoteVersion;
2979
+ if (quoteVersion !== void 0 && !this.supportsManagedQuote) throw new Error(`${this.provider} does not support the managed-quote native model transport`);
2980
+ const correlationHeaders = executionCorrelationHeaders(input.interactionBinding, input.traceId, quoteVersion);
2981
+ const execution = beginExecution(this, input.abortSignal);
2982
+ const result = this.executeInternal(input, services, onEvent, execution.controller.signal, correlationHeaders);
2983
+ result.then(() => finishExecution(this, execution), () => {
2984
+ execution.controller.abort();
2985
+ finishExecution(this, execution);
2986
+ });
2987
+ return result;
2988
+ }
2989
+ async executeInternal(input, services, onEvent, executionSignal, correlationHeaders) {
2990
+ const startTime = Date.now();
2991
+ const recorder = services.recorder;
2992
+ const opened = await this.openTurnSession(input, recorder, onEvent);
2993
+ if (opened.cached) return opened.cached;
2994
+ const { sessionId, providerSessionId, providerMessageIds } = opened;
2995
+ const { executionProfile, profilePlan } = await this.materializeTurnProfile(input, sessionId).catch(rejectNotLaunched);
2996
+ const { preparedInput, promptText } = await this.recordTurnPrompt(input, opened, recorder).catch(rejectNotLaunched);
2997
+ onEvent?.({
2998
+ type: "status",
2999
+ status: "started"
3000
+ });
3001
+ const launch = await this.planLaunch({
3002
+ input,
3003
+ sessionId,
3004
+ providerSessionId,
3005
+ executionSignal,
3006
+ correlationHeaders,
3007
+ executionProfile,
3008
+ profilePlan,
3009
+ attachments: preparedInput.attachments,
3010
+ promptText
3011
+ });
3012
+ const { workspaceRoot } = launch;
3013
+ const state = createExecutionState(sessionId, providerMessageIds?.assistantMessageId, workspaceRoot);
3014
+ const observer = new TurnObserver({
3015
+ provider: this.provider,
3016
+ state,
3017
+ sessionId,
3018
+ getRuntimeHome: () => this.config.runtimeHome,
3019
+ recorder,
3020
+ getSessionStore: () => this.getSessionStore(),
3021
+ logger: this.logger,
3022
+ repairToolNames: this.toolCallRepairToolNames(),
3023
+ onEvent
3024
+ });
3025
+ await this.bindNativeSession({
3026
+ launch,
3027
+ providerSessionId,
3028
+ observer
3029
+ });
3030
+ const { exitResult, stderr } = await this.runCliTurn({
3031
+ input,
3032
+ sessionId,
3033
+ providerSessionId,
3034
+ executionSignal,
3035
+ launch,
3036
+ promptText,
3037
+ state,
3038
+ observer,
3039
+ startTime
3040
+ });
3041
+ await observer.providerSessionPersistence;
3042
+ failOnProviderSessionPersistence(observer, state);
3043
+ const endTime = Date.now();
3044
+ const benignNonZeroExit = !exitResult.success && !exitResult.timedOut && exitResult.stdinError === void 0 && this.isBenignNonZeroExit({
3045
+ exitCode: exitResult.code,
3046
+ stderr,
3047
+ state
3048
+ });
3049
+ if (!exitResult.success && !benignNonZeroExit || state.terminalStatus === "failed" || observer.repeatedToolFailureTripped) return this.failCliTurn({
3050
+ onEvent,
3051
+ recorder,
3052
+ observer,
3053
+ state,
3054
+ exitResult,
3055
+ stderr,
3056
+ launch,
3057
+ startTime,
3058
+ endTime
3059
+ });
3060
+ logAcceptedCliExit(this.logger, exitResult, stderr);
3061
+ return this.completeTurn({
3062
+ input,
3063
+ sessionId,
3064
+ state,
3065
+ observer,
3066
+ recorder,
3067
+ providerMessageIds,
3068
+ profilePlan,
3069
+ startTime,
3070
+ endTime
3071
+ });
3072
+ }
3073
+ /**
3074
+ * Record a successful turn: the provider session id, the assistant message,
3075
+ * and the result, cached for a same-turnId retry.
3076
+ */
3077
+ async completeTurn(params) {
3078
+ const { input, sessionId, state, recorder, profilePlan } = params;
3079
+ if (state.sessionId && sessionId && state.sessionId !== sessionId) await this.getSessionStore().update(sessionId, {
3080
+ providerSessionId: state.sessionId,
3081
+ ...this.config.runtimeHome?.trim() ? { providerSessionHome: this.config.runtimeHome.trim() } : {}
3082
+ });
3083
+ const assistantParts = this.buildAssistantParts(state);
3084
+ await params.observer?.flushPartUpdates();
3085
+ await recorder.appendAssistantParts(assistantParts);
3086
+ await this.getMessageStore().append({
3087
+ sessionId,
3088
+ ...params.providerMessageIds ? { id: params.providerMessageIds.assistantMessageId } : {},
3089
+ role: "assistant",
3090
+ parts: assistantParts,
3091
+ tokenUsage: state.tokenUsage ? {
3092
+ inputTokens: state.tokenUsage.inputTokens,
3093
+ outputTokens: state.tokenUsage.outputTokens
3094
+ } : void 0
3095
+ });
3096
+ const publicProfileRevision = profilePlan ? profileRevisionReceipts(this).publicProfileRevisionReceipts.get(profilePlan) : void 0;
3097
+ const result = buildExecutionResult({
3098
+ outcome: await this.executionOutcome(state, input),
3099
+ state,
3100
+ toolInvocations: this.extractToolInvocations(state),
3101
+ sessionId,
3102
+ publicProfileRevision,
3103
+ startTime: params.startTime,
3104
+ endTime: params.endTime
3105
+ });
3106
+ if (recorder.markTurnCompleted) await recorder.markTurnCompleted({ result });
3107
+ return result;
3108
+ }
3109
+ /** Report and record a failed turn, then throw its error. */
3110
+ async failCliTurn(params) {
3111
+ const { onEvent, recorder, observer, state, exitResult, stderr } = params;
3112
+ const { launch, startTime, endTime } = params;
3113
+ const { env, execArgs } = launch;
3114
+ const errorMessage = describeCliFailure({
3115
+ provider: this.provider,
3116
+ state,
3117
+ exitResult,
3118
+ stderr,
3119
+ command: execArgs.command,
3120
+ nixBinPath: env.NIX_BIN_PATH
3121
+ });
3122
+ this.logger.error("CLI process failed", {
3123
+ exitCode: exitResult.code,
3124
+ timedOut: exitResult.timedOut,
3125
+ timeoutReason: exitResult.timeoutReason,
3126
+ timeoutDiagnostic: exitResult.timeoutDiagnostic,
3127
+ spawnError: exitResult.spawnError,
3128
+ stdinError: exitResult.stdinError,
3129
+ stderr: stderr.slice(0, 2e3),
3130
+ diagnostic: state.lastDiagnostic,
3131
+ durationMs: endTime - startTime,
3132
+ command: execArgs.command
3133
+ });
3134
+ if (exitResult.timedOut) onEvent?.({
3135
+ type: "warning",
3136
+ code: "TIMEOUT",
3137
+ message: exitResult.timeoutDiagnostic ?? "Execution timed out"
3138
+ });
3139
+ onEvent?.({
3140
+ type: "status",
3141
+ status: "failed",
3142
+ detail: errorMessage
3143
+ });
3144
+ const failure = buildTurnFailure({
3145
+ provider: this.provider,
3146
+ errorMessage,
3147
+ state,
3148
+ startTime,
3149
+ endTime
3150
+ });
3151
+ await recordTurnInterruption({
3152
+ recorder,
3153
+ observer,
3154
+ logger: this.logger,
3155
+ errorMessage,
3156
+ exitResult,
3157
+ failure
3158
+ });
3159
+ throw failure;
3160
+ }
3161
+ /**
3162
+ * Open the turn's session (sidecar/provider persistence only; native CLI
3163
+ * startup is measured separately). A same-turnId retry of a completed turn
3164
+ * gets that turn's cached result instead of running again.
3165
+ */
3166
+ async openTurnSession(input, recorder, onEvent) {
3167
+ const sessionPrepareStarted = performance.now();
3168
+ const adapter = this;
3169
+ const prepared = await prepareExecutionSession({
3170
+ get config() {
3171
+ return adapter.config;
3172
+ },
3173
+ get logger() {
3174
+ return adapter.logger;
3175
+ },
3176
+ get storagePath() {
3177
+ return adapter.storagePath;
3178
+ },
3179
+ get getSessionStore() {
3180
+ return adapter.getSessionStore.bind(adapter);
3181
+ },
3182
+ get getMessageStore() {
3183
+ return adapter.getMessageStore.bind(adapter);
3184
+ },
3185
+ get resumableProviderSessionId() {
3186
+ return adapter.resumableProviderSessionId.bind(adapter);
3187
+ },
3188
+ get createSession() {
3189
+ return adapter.createSession.bind(adapter);
3190
+ }
3191
+ }, input).catch(rejectNotLaunched);
3192
+ this.logger.observePhase?.("session_prepare", performance.now() - sessionPrepareStarted);
3193
+ this.logger.observePhase?.(prepared.transcriptReplay ? "history_replay" : prepared.providerSessionId ? "native_resume" : "native_session_new", 0);
3194
+ const providerMessageIds = readProviderMessageIds(input);
3195
+ const cached = await findCachedTurn(recorder, input, this.logger, prepared.sessionId);
3196
+ if (cached) {
3197
+ onEvent?.({
3198
+ type: "status",
3199
+ status: "completed"
3200
+ });
3201
+ return {
3202
+ ...prepared,
3203
+ providerMessageIds,
3204
+ cached
3205
+ };
3206
+ }
3207
+ if (input.turnId && recorder.setTurnId) recorder.setTurnId(input.turnId);
3208
+ return {
3209
+ ...prepared,
3210
+ providerMessageIds,
3211
+ cached: void 0
3212
+ };
3213
+ }
3214
+ /** Provision the profile's cwd-native dimensions for this turn. */
3215
+ async materializeTurnProfile(input, sessionId) {
3216
+ const executionProfile = this.resolveExecutionProfileForInput(input);
3217
+ const profilePlan = await this.materializeProfileResources(executionProfile, input.workspaceRoot ?? this.storagePath, input.userId ?? sessionId, sessionId);
3218
+ assertExecutionProfileRevision(this, () => this.config, input, profilePlan);
3219
+ return {
3220
+ executionProfile,
3221
+ profilePlan
3222
+ };
3223
+ }
3224
+ /**
3225
+ * Prepare the prompt and record the caller's own parts. `promptText`
3226
+ * additionally carries the replayed transcript and this turn's ephemeral
3227
+ * context, which are execution-only and stay out of both stores.
3228
+ */
3229
+ async recordTurnPrompt(input, session, recorder) {
3230
+ const { sessionId, providerMessageIds } = session;
3231
+ const preparedInput = await this.preparePromptInput(normalizeInputParts(input), input.workspaceRoot ?? this.storagePath, sessionId);
3232
+ const transcriptContext = await buildTranscriptContext({
3233
+ provider: this.provider,
3234
+ sessionId,
3235
+ transcriptReplay: session.transcriptReplay,
3236
+ secretProfileRevisionRoot: this.config.secretProfileRevisionRoot,
3237
+ getMessageStore: () => this.getMessageStore()
3238
+ });
3239
+ await recorder.recordUserMessage(preparedInput.persistedParts);
3240
+ await this.getMessageStore().append({
3241
+ sessionId,
3242
+ ...providerMessageIds ? { id: providerMessageIds.userMessageId } : {},
3243
+ role: "user",
3244
+ parts: preparedInput.persistedParts
3245
+ });
3246
+ return {
3247
+ preparedInput,
3248
+ promptText: this.composePromptText(prependPromptSection(transcriptContext, preparedInput.promptText), input)
3249
+ };
3250
+ }
3251
+ /** Resolve this turn's config, environment and argv, and check them against the session contract. */
3252
+ async planLaunch(params) {
3253
+ const { input, sessionId, providerSessionId, executionSignal, correlationHeaders, executionProfile, profilePlan, attachments, promptText } = params;
3254
+ const workspaceRoot = input.workspaceRoot ?? this.storagePath;
3255
+ const turnConfig = withTurnInteractions(this.config, input);
3256
+ const executionConfig = this.withProfilePlan(turnConfig, profilePlan);
3257
+ const sessionContract = this.sessionContract();
3258
+ const executionModel = this.resolveProfileModel(input.model?.model);
3259
+ const env = this.buildTurnEnvironment(executionConfig, profilePlan, workspaceRoot, correlationHeaders);
3260
+ const buildExecArgs = (mintedSessionId) => this.buildExecArgs({
3261
+ message: promptText,
3262
+ config: executionConfig,
3263
+ correlationHeaders,
3264
+ model: executionModel,
3265
+ sessionId: providerSessionId,
3266
+ ...mintedSessionId ? { mintedSessionId } : {},
3267
+ workspaceRoot,
3268
+ ...keepsHarnessSystemPrompt(executionProfile, this.profileHarness) ? {
3269
+ systemPrompt: void 0,
3270
+ appendSystemPrompt: joinSystemPromptAdditions(this.resolveProfileAppendSystemPrompt(profilePlan), input.systemPrompt, input.appendSystemPrompt)
3271
+ } : {
3272
+ systemPrompt: this.resolveProfileSystemPrompt(input.systemPrompt, profilePlan),
3273
+ appendSystemPrompt: input.appendSystemPrompt ? joinSystemPromptAdditions(this.resolveProfileAppendSystemPrompt(profilePlan), input.appendSystemPrompt) : this.resolveProfileAppendSystemPrompt(profilePlan)
3274
+ },
3275
+ attachments,
3276
+ profilePlan
3277
+ });
3278
+ const timeoutMs = input.timeoutMs;
3279
+ const mintedSessionId = sessionContract?.identity.kind === "pre-minted" && !providerSessionId ? params.reserveHeldProcess === false ? randomUUID() : await this.mintNativeSessionId({
3280
+ sessionId,
3281
+ workspaceRoot,
3282
+ config: executionConfig,
3283
+ model: executionModel,
3284
+ env,
3285
+ signal: executionSignal,
3286
+ buildExecArgs,
3287
+ input,
3288
+ ...timeoutMs === void 0 ? {} : { timeoutMs }
3289
+ }) : void 0;
3290
+ const execArgs = buildExecArgs(mintedSessionId);
3291
+ if (sessionContract) {
3292
+ assertArgvHonoursSessionContract(this.provider, sessionContract, execArgs.args, { ...mintedSessionId ? { mintedId: mintedSessionId } : {} });
3293
+ const runtimeHome = this.config.runtimeHome?.trim();
3294
+ if (runtimeHome && sessionContractFullyEnforced(this.provider)) assertStoreLocationHonoursSessionContract(this.provider, sessionContract, {
3295
+ runtimeHome,
3296
+ cwd: workspaceRoot,
3297
+ env,
3298
+ argv: execArgs.args
3299
+ });
3300
+ }
3301
+ this.logger.debug("CLI execution", {
3302
+ command: execArgs.command,
3303
+ cwd: workspaceRoot,
3304
+ hasSessionId: !!providerSessionId
3305
+ });
3306
+ return {
3307
+ workspaceRoot,
3308
+ executionConfig,
3309
+ executionModel,
3310
+ env,
3311
+ sessionContract,
3312
+ timeoutMs,
3313
+ mintedSessionId,
3314
+ execArgs
3315
+ };
3316
+ }
3317
+ /** Reserve and announce the native session id a pre-minted harness already has. */
3318
+ async bindNativeSession(params) {
3319
+ const { launch, providerSessionId, observer } = params;
3320
+ const { workspaceRoot, sessionContract, mintedSessionId } = launch;
3321
+ const nativeBindStarted = performance.now();
3322
+ if (mintedSessionId) await this.reserveNativeSession(mintedSessionId, { workspaceRoot });
3323
+ const knownNativeSessionId = sessionContract?.identity.kind === "pre-minted" ? mintedSessionId ?? providerSessionId : void 0;
3324
+ if (knownNativeSessionId) {
3325
+ observer.onEvent({
3326
+ type: "native.session.observed",
3327
+ provider: this.provider,
3328
+ nativeSessionId: knownNativeSessionId
3329
+ });
3330
+ await observer.providerSessionPersistence;
3331
+ if (observer.providerSessionPersistenceError) throw observer.providerSessionPersistenceError;
3332
+ }
3333
+ if (knownNativeSessionId) this.logger.observePhase?.("native_session_bind", performance.now() - nativeBindStarted);
3334
+ }
3335
+ /** Spawn the turn's process, feed its input, and wait for it to exit. */
3336
+ async runCliTurn(params) {
3337
+ const { input, sessionId, providerSessionId, executionSignal } = params;
3338
+ const { launch, promptText, state, observer, startTime } = params;
3339
+ const { execArgs, workspaceRoot, env } = launch;
3340
+ const parserConfig = this.createEventParser(state, observer.onEvent);
3341
+ const spawnedProcess = this.spawnExecutionProcess({
3342
+ input,
3343
+ sessionId,
3344
+ providerSessionId,
3345
+ config: launch.executionConfig,
3346
+ execArgs,
3347
+ workspaceRoot,
3348
+ env,
3349
+ signal: executionSignal,
3350
+ timeoutMs: launch.timeoutMs,
3351
+ parser: parserConfig
3352
+ });
3353
+ const cliProcess = spawnedProcess instanceof Promise ? await spawnedProcess : spawnedProcess;
3354
+ this.activeProcesses.add(cliProcess);
3355
+ observer.attachProcess(cliProcess);
3356
+ const identityDeadline = armNativeSessionDeadline({
3357
+ provider: this.provider,
3358
+ sessionId,
3359
+ sessionContract: launch.sessionContract,
3360
+ state,
3361
+ observer,
3362
+ logger: this.logger,
3363
+ kill: () => cliProcess.kill()
3364
+ });
3365
+ const stderr = collectStderr(cliProcess);
3366
+ const livenessTicker = observer.startLiveness(executionSignal, startTime);
3367
+ let exitResult;
3368
+ try {
3369
+ if (execArgs.useStdin) {
3370
+ const stdinMessage = execArgs.stdin && execArgs.stdin.length > 0 ? execArgs.stdin : promptText;
3371
+ cliProcess.write(this.formatStdinMessage(stdinMessage));
3372
+ cliProcess.closeInput();
3373
+ }
3374
+ exitResult = await cliProcess.exitPromise;
3375
+ } catch (error) {
3376
+ cliProcess.kill();
3377
+ await cliProcess.exitPromise;
3378
+ throw error;
3379
+ } finally {
3380
+ clearInterval(livenessTicker);
3381
+ if (identityDeadline) clearTimeout(identityDeadline);
3382
+ this.activeProcesses.delete(cliProcess);
3383
+ }
3384
+ await this.afterCliProcessExit({
3385
+ state,
3386
+ stderr: stderr.text,
3387
+ exit: exitResult,
3388
+ workspaceRoot,
3389
+ env,
3390
+ onEvent: observer.onEvent
3391
+ });
3392
+ return {
3393
+ exitResult,
3394
+ stderr: stderr.text
3395
+ };
3396
+ }
3397
+ /** How one turn's argv runs: cwd, environment, identity or launcher, stdio. */
3398
+ turnSpawnConfig(execArgs, workspaceRoot, env) {
3399
+ return cliSpawnConfig({
3400
+ execArgs,
3401
+ workspaceRoot,
3402
+ env,
3403
+ inheritEnv: this.inheritProcessEnv,
3404
+ launcher: this.launcher,
3405
+ ownProcessGroup: this.ownProcessGroup,
3406
+ subprocessUser: this.config.subprocessUser,
3407
+ subprocessSpawnOptions: this.subprocessSpawnOptions()
3408
+ });
3409
+ }
3410
+ /**
3411
+ * The launch one turn would make, without starting it: the spawn config
3412
+ * (argv, cwd, environment, identity or launcher), the stdin payload, and
3413
+ * the profile files written into the workspace. Session and message
3414
+ * stores are untouched and no held process is reserved, so a caller that
3415
+ * runs the process itself (`execCli`) owns its output and lifecycle.
3416
+ */
3417
+ async prepareLaunch(input, options = {}) {
3418
+ this.ensureInitialized();
3419
+ const sessionId = input.sessionId ?? randomUUID();
3420
+ const { executionProfile, profilePlan } = await this.materializeTurnProfile(input, sessionId);
3421
+ const workspaceRoot = input.workspaceRoot ?? this.storagePath;
3422
+ const preparedInput = await this.preparePromptInput(normalizeInputParts(input), workspaceRoot, sessionId);
3423
+ const promptText = this.composePromptText(preparedInput.promptText, input);
3424
+ const launch = await this.planLaunch({
3425
+ input,
3426
+ sessionId,
3427
+ providerSessionId: options.providerSessionId,
3428
+ executionSignal: input.abortSignal ?? new AbortController().signal,
3429
+ correlationHeaders: executionCorrelationHeaders(input.interactionBinding, input.traceId, void 0),
3430
+ executionProfile,
3431
+ profilePlan,
3432
+ attachments: preparedInput.attachments,
3433
+ promptText,
3434
+ reserveHeldProcess: false
3435
+ });
3436
+ const { execArgs } = launch;
3437
+ return {
3438
+ spawn: this.turnSpawnConfig(execArgs, launch.workspaceRoot, launch.env),
3439
+ ...execArgs.useStdin ? { stdin: this.formatStdinMessage(execArgs.stdin && execArgs.stdin.length > 0 ? execArgs.stdin : promptText) } : {},
3440
+ ...launch.mintedSessionId ? { nativeSessionId: launch.mintedSessionId } : {},
3441
+ ...profilePlan ? { workspacePlan: profilePlan.receipt } : {}
3442
+ };
3443
+ }
3444
+ /** Override only when the provider has a turn transport besides a CLI child. */
3445
+ spawnExecutionProcess(request) {
3446
+ const { execArgs, workspaceRoot, env, signal, timeoutMs, parser } = request;
3447
+ const spawnStarted = performance.now();
3448
+ const config = {
3449
+ ...this.turnSpawnConfig(execArgs, workspaceRoot, env),
3450
+ signal,
3451
+ ...globalThis.process.getuid?.() === 0 && this.config.runtimeHome?.trim() ? {
3452
+ rawCapturePath: resolveRawCapturePath(this.config.runtimeHome.trim()),
3453
+ ...request.deferRawCapture ? { rawCaptureDeferred: true } : {}
3454
+ } : {},
3455
+ ...timeoutMs === void 0 ? {} : { timeoutMs }
3456
+ };
3457
+ const turnProcess = (process) => cliTurnProcess(process, this.logger, spawnStarted);
3458
+ return this.launcher ? launchCliProcess(config, parser).then(turnProcess) : turnProcess(spawnCliProcess(config, parser));
3459
+ }
3460
+ };
3461
+ //#endregion
3462
+ //#region src/acp-session-adapter.ts
3463
+ const DEFAULT_PROMPT_TIMEOUT_MS = 6e5;
3464
+ const DEFAULT_BOOTSTRAP_TIMEOUT_MS = 12e4;
3465
+ const DEFAULT_BRIDGE_IDLE_MS = 300 * 1e3;
3466
+ /**
3467
+ * Grace between `session/cancel` and closing the bridge: long enough for the
3468
+ * agent to answer the in-flight `session/prompt` with `stopReason:
3469
+ * "cancelled"`, short enough that abort stays prompt.
3470
+ */
3471
+ const ABORT_GRACE_MS = 250;
3472
+ /**
3473
+ * Shared lifecycle of a provider that drives an Agent Client Protocol agent
3474
+ * over a JSON-RPC stdio bridge: one cached process and native ACP session per
3475
+ * Tangle session, `initialize` then `session/new` (or `session/load` for a
3476
+ * provider that resumes), serialized `session/prompt` turns, the cancel
3477
+ * sequence (`session/cancel`, grace, close), `session/update` translation,
3478
+ * idle reaping and eviction on process exit or turn failure.
3479
+ *
3480
+ * A provider supplies only its translation: how a turn resolves its config,
3481
+ * how the process starts, the prompt blocks, and its own failure signals.
3482
+ */
3483
+ var AcpProviderAdapterBase = class extends CliProviderAdapterBase {
3484
+ acpSessions = /* @__PURE__ */ new Map();
3485
+ /** Spawn the bridge. Tests override it to swap the child process. */
3486
+ spawnBridge(request) {
3487
+ return spawnAcpBridge(request);
3488
+ }
3489
+ /** Refuse an agent version beyond the provider's own floor. */
3490
+ checkAgentProtocolVersion(_advertised, _turn) {}
3491
+ /**
3492
+ * Configure a native session the process just opened or loaded, before its
3493
+ * first prompt (e.g. `session/set_model`). A failure fails the acquire.
3494
+ */
3495
+ async onSessionOpened(_bridge, _acpSessionId, _turn, _options) {}
3496
+ /** Install reverse-call handlers on a new bridge, before `initialize`. */
3497
+ onBridgeSpawned(_bridge, _sessionId, _turn) {}
3498
+ /** Start per-turn state; the returned function ends it on every exit. */
3499
+ beginTurn(_sessionId, _input, _onEvent) {
3500
+ return () => {};
3501
+ }
3502
+ /** Inspect a failed tool's output; returned events precede its part update. */
3503
+ onToolFailed(_sessionId, _failureText) {
3504
+ return [];
3505
+ }
3506
+ /**
3507
+ * A failure the provider recorded for this session during the turn,
3508
+ * cleared on read. It outranks the error the turn surfaced as.
3509
+ */
3510
+ takeTurnFailure(_sessionId) {}
3511
+ /** Release provider state tied to a session's process. */
3512
+ onSessionEvicted(_sessionId) {}
3513
+ /** Extra fields for the turn-failure log line. */
3514
+ turnLogContext(_turn) {
3515
+ return {};
3516
+ }
3517
+ /**
3518
+ * One turn through the base turn pipeline: session open with transcript
3519
+ * replay and cached-turn replay, then input and result recording around the
3520
+ * bridge's `session/prompt`. The agent owns its profile, so the base's
3521
+ * profile materialization does not run.
3522
+ */
3523
+ async execute(input, services, onEvent) {
3524
+ this.ensureInitialized();
3525
+ const turn = this.resolveTurn(input);
3526
+ const { config } = turn;
3527
+ const { recorder } = services;
3528
+ const startTime = Date.now();
3529
+ const workspaceRoot = input.workspaceRoot ?? this.storagePath;
3530
+ const turnInput = input.sessionId ? input : {
3531
+ ...input,
3532
+ sessionId: input.interactionBinding?.sessionId
3533
+ };
3534
+ const opened = await this.openTurnSession(turnInput, recorder, onEvent);
3535
+ if (opened.cached) return opened.cached;
3536
+ const { sessionId, providerSessionId, providerMessageIds } = opened;
3537
+ const endTurn = this.beginTurn(sessionId, input, onEvent);
3538
+ try {
3539
+ const { preparedInput, promptText } = await this.recordTurnPrompt(turnInput, opened, recorder);
3540
+ onEvent?.({
3541
+ type: "status",
3542
+ status: "started"
3543
+ });
3544
+ const state = createExecutionState(sessionId, providerMessageIds?.assistantMessageId);
3545
+ let entry;
3546
+ try {
3547
+ entry = await this.acquireAcpSession({
3548
+ sessionId,
3549
+ ...this.loadsNativeSessions && providerSessionId ? { providerSessionId } : {},
3550
+ turn,
3551
+ workspaceRoot,
3552
+ onEvent
3553
+ });
3554
+ } catch (err) {
3555
+ onEvent?.({
3556
+ type: "status",
3557
+ status: "failed",
3558
+ detail: err instanceof Error ? err.message : String(err)
3559
+ });
3560
+ throw err instanceof Error ? err : new Error(String(err));
3561
+ }
3562
+ const { bridge, acpSessionId } = entry;
3563
+ onEvent?.({
3564
+ type: "session.updated",
3565
+ sessionId
3566
+ });
3567
+ onEvent?.({
3568
+ type: "native.session.observed",
3569
+ provider: this.provider,
3570
+ nativeSessionId: acpSessionId
3571
+ });
3572
+ try {
3573
+ const promptResult = await this.promptAcpSession(entry, {
3574
+ prompt: this.promptBlocks(input, {
3575
+ ...preparedInput,
3576
+ promptText
3577
+ }, turn),
3578
+ timeoutMs: config.promptTimeoutMs ?? DEFAULT_PROMPT_TIMEOUT_MS,
3579
+ abortSignal: input.abortSignal,
3580
+ onUpdate: (params) => {
3581
+ for (const event of this.mapAcpUpdate(params, state, sessionId)) onEvent?.(event);
3582
+ }
3583
+ });
3584
+ const recorded = this.takeTurnFailure(sessionId);
3585
+ if (recorded) throw recorded;
3586
+ const finalStatus = mapAcpStopReason(promptResult.stopReason);
3587
+ onEvent?.(finalStatus.detail ? {
3588
+ type: "status",
3589
+ status: finalStatus.status,
3590
+ detail: finalStatus.detail
3591
+ } : {
3592
+ type: "status",
3593
+ status: finalStatus.status
3594
+ });
3595
+ await this.getSessionStore().update(sessionId, { providerSessionId: acpSessionId });
3596
+ return await this.completeTurn({
3597
+ input: turnInput,
3598
+ sessionId,
3599
+ state,
3600
+ recorder,
3601
+ providerMessageIds,
3602
+ startTime,
3603
+ endTime: Date.now()
3604
+ });
3605
+ } catch (err) {
3606
+ return this.failAcpTurn(err, sessionId, turn, bridge, onEvent);
3607
+ } finally {
3608
+ this.scheduleIdleReap(sessionId, config.bridgeIdleMs);
3609
+ }
3610
+ } finally {
3611
+ endTurn();
3612
+ }
3613
+ }
3614
+ /** Report a failed turn, retire its process, and throw its error. */
3615
+ failAcpTurn(err, sessionId, turn, bridge, onEvent) {
3616
+ const finalError = this.takeTurnFailure(sessionId) ?? err;
3617
+ const detail = this.formatBridgeError(finalError);
3618
+ this.logger.error(`${this.provider} execute failed`, {
3619
+ ...this.turnLogContext(turn),
3620
+ error: detail,
3621
+ stderrTail: bridge.getStderrTail().slice(-500)
3622
+ });
3623
+ onEvent?.({
3624
+ type: "status",
3625
+ status: "failed",
3626
+ detail
3627
+ });
3628
+ this.evictAcpSession(sessionId, "execute-error");
3629
+ if (finalError instanceof JsonRpcError && detail !== finalError.message) throw new Error(detail, { cause: finalError });
3630
+ throw finalError instanceof Error ? finalError : new Error(detail);
3631
+ }
3632
+ async acquireAcpSession(opts) {
3633
+ const { sessionId, turn, workspaceRoot, onEvent } = opts;
3634
+ const existing = this.acpSessions.get(sessionId);
3635
+ if (existing && !existing.bridge.closed) {
3636
+ if (existing.processKey === turn.processKey) {
3637
+ this.cancelIdleReap(existing);
3638
+ return existing;
3639
+ }
3640
+ this.logger.warn(`${this.provider}: cached session was started for another process configuration — respawning`, { sessionId });
3641
+ this.evictAcpSession(sessionId, "process-change");
3642
+ }
3643
+ const spec = await this.processSpec(turn, {
3644
+ sessionId,
3645
+ workspaceRoot
3646
+ });
3647
+ onEvent?.({
3648
+ type: "status",
3649
+ status: "processing",
3650
+ detail: spec.statusDetail
3651
+ });
3652
+ let bridge;
3653
+ const spawnStarted = performance.now();
3654
+ try {
3655
+ const runtimeHome = this.config.runtimeHome?.trim();
3656
+ bridge = this.spawnBridge({
3657
+ command: spec.command,
3658
+ args: spec.args,
3659
+ cwd: workspaceRoot,
3660
+ env: withAgentEnvironment(spec.env, this.agentEnvironmentValues()),
3661
+ ...this.subprocessSpawnOptions(),
3662
+ ...globalThis.process.getuid?.() === 0 && runtimeHome ? { rawCapturePath: resolveRawCapturePath(runtimeHome) } : {}
3663
+ });
3664
+ } catch (err) {
3665
+ const message = err instanceof Error ? err.message : String(err);
3666
+ throw new Error(this.spawnFailureMessage({
3667
+ message,
3668
+ missingBinary: message.includes("ENOENT") || message.includes("not found")
3669
+ }, turn));
3670
+ }
3671
+ bridge.process?.once("spawn", () => {
3672
+ this.logger.observePhase?.("cli_spawn", performance.now() - spawnStarted);
3673
+ });
3674
+ this.onBridgeSpawned(bridge, sessionId, turn);
3675
+ const timeoutMs = turn.config.bootstrapTimeoutMs ?? DEFAULT_BOOTSTRAP_TIMEOUT_MS;
3676
+ let acpSessionId;
3677
+ const initializeStarted = performance.now();
3678
+ try {
3679
+ const requested = turn.config.protocolVersion ?? 1;
3680
+ const init = await bridge.request("initialize", {
3681
+ protocolVersion: requested,
3682
+ clientCapabilities: {}
3683
+ }, { timeoutMs });
3684
+ this.checkProtocolVersion(init, requested, turn);
3685
+ acpSessionId = await this.openNativeSession(bridge, {
3686
+ providerSessionId: opts.providerSessionId,
3687
+ cwd: workspaceRoot,
3688
+ timeoutMs
3689
+ });
3690
+ await this.onSessionOpened(bridge, acpSessionId, turn, { timeoutMs });
3691
+ this.logger.observePhase?.("cli_initialize", performance.now() - initializeStarted);
3692
+ } catch (err) {
3693
+ try {
3694
+ await bridge.close();
3695
+ } catch {}
3696
+ throw this.sessionSetupError(err, bridge);
3697
+ }
3698
+ const unsubscribeClose = bridge.onClose((reason) => {
3699
+ this.logger.warn(`${this.provider}: agent process closed unexpectedly — evicting`, {
3700
+ sessionId,
3701
+ reason: reason.reason,
3702
+ code: reason.code,
3703
+ signal: reason.signal
3704
+ });
3705
+ this.evictAcpSession(sessionId, "process-exit");
3706
+ });
3707
+ const entry = {
3708
+ bridge,
3709
+ acpSessionId,
3710
+ processKey: turn.processKey,
3711
+ inflight: Promise.resolve(),
3712
+ unsubscribeClose
3713
+ };
3714
+ this.acpSessions.set(sessionId, entry);
3715
+ return entry;
3716
+ }
3717
+ checkProtocolVersion(init, requested, turn) {
3718
+ const advertised = init.protocolVersion;
3719
+ if (typeof advertised !== "number") throw new Error(`${this.provider}: initialize response missing protocolVersion (got ${JSON.stringify(init)})`);
3720
+ if (advertised > requested) throw new Error(`${this.provider}: agent advertises protocolVersion ${advertised} but client speaks at most ${requested}. Upgrade the provider package or pin an older agent binary.`);
3721
+ if (advertised < 1) throw new Error(`${this.provider}: agent advertises protocolVersion ${advertised} but client requires at least 1. The backing agent binary is too old; upgrade it.`);
3722
+ this.checkAgentProtocolVersion(advertised, turn);
3723
+ }
3724
+ /**
3725
+ * Open the native ACP session. A recorded session is loaded when the agent
3726
+ * can; an agent that refuses the load starts a new native session, and the
3727
+ * Tangle transcript stays the record of the earlier turns.
3728
+ */
3729
+ async openNativeSession(bridge, opts) {
3730
+ const params = {
3731
+ cwd: opts.cwd,
3732
+ mcpServers: []
3733
+ };
3734
+ if (opts.providerSessionId) try {
3735
+ return (await bridge.request("session/load", {
3736
+ sessionId: opts.providerSessionId,
3737
+ ...params
3738
+ }, { timeoutMs: opts.timeoutMs }))?.sessionId ?? opts.providerSessionId;
3739
+ } catch (err) {
3740
+ this.logger.debug("session/load failed, creating new", { error: err instanceof Error ? err.message : String(err) });
3741
+ }
3742
+ return (await bridge.request("session/new", params, { timeoutMs: opts.timeoutMs })).sessionId;
3743
+ }
3744
+ /**
3745
+ * One `session/prompt` turn. Abort sends `session/cancel` before closing so
3746
+ * the agent can settle the turn; the bridge's own signal handling would
3747
+ * close first and lose the notify.
3748
+ */
3749
+ async promptAcpSession(entry, turn) {
3750
+ const { bridge, acpSessionId } = entry;
3751
+ const unsubscribe = bridge.onNotification("session/update", (params) => {
3752
+ if (!params || params.sessionId !== acpSessionId) return;
3753
+ turn.onUpdate(params);
3754
+ });
3755
+ const onAbort = () => {
3756
+ try {
3757
+ bridge.notify("session/cancel", { sessionId: acpSessionId });
3758
+ } catch (err) {
3759
+ this.logger.warn(`${this.provider}: session/cancel notify failed on abort`, { error: err instanceof Error ? err.message : String(err) });
3760
+ }
3761
+ setTimeout(() => {
3762
+ bridge.close().catch(() => {});
3763
+ }, ABORT_GRACE_MS).unref();
3764
+ };
3765
+ const { abortSignal } = turn;
3766
+ if (abortSignal) if (abortSignal.aborted) onAbort();
3767
+ else abortSignal.addEventListener("abort", onAbort, { once: true });
3768
+ const previous = entry.inflight;
3769
+ let releaseLock;
3770
+ const lock = new Promise((resolve) => {
3771
+ releaseLock = resolve;
3772
+ });
3773
+ entry.inflight = previous.then(() => lock).catch(() => lock);
3774
+ const readyWaitStarted = performance.now();
3775
+ await previous.catch(() => {});
3776
+ this.logger.observePhase?.("cli_ready_wait", performance.now() - readyWaitStarted);
3777
+ try {
3778
+ return await bridge.request("session/prompt", {
3779
+ sessionId: acpSessionId,
3780
+ prompt: turn.prompt
3781
+ }, { timeoutMs: turn.timeoutMs });
3782
+ } finally {
3783
+ unsubscribe();
3784
+ abortSignal?.removeEventListener("abort", onAbort);
3785
+ releaseLock();
3786
+ }
3787
+ }
3788
+ scheduleIdleReap(sessionId, bridgeIdleMs) {
3789
+ const entry = this.acpSessions.get(sessionId);
3790
+ if (!entry) return;
3791
+ const idleMs = bridgeIdleMs ?? DEFAULT_BRIDGE_IDLE_MS;
3792
+ if (idleMs <= 0) {
3793
+ this.evictAcpSession(sessionId, "idle-disabled");
3794
+ return;
3795
+ }
3796
+ if (entry.idleTimer) clearTimeout(entry.idleTimer);
3797
+ entry.idleTimer = setTimeout(() => {
3798
+ this.evictAcpSession(sessionId, "idle-reap");
3799
+ }, idleMs);
3800
+ entry.idleTimer.unref();
3801
+ }
3802
+ cancelIdleReap(entry) {
3803
+ if (entry.idleTimer) {
3804
+ clearTimeout(entry.idleTimer);
3805
+ entry.idleTimer = void 0;
3806
+ }
3807
+ }
3808
+ /** Close a session's agent process and drop it. Idempotent. */
3809
+ evictAcpSession(sessionId, reason) {
3810
+ const entry = this.acpSessions.get(sessionId);
3811
+ if (!entry) return;
3812
+ this.acpSessions.delete(sessionId);
3813
+ this.onSessionEvicted(sessionId);
3814
+ this.cancelIdleReap(entry);
3815
+ try {
3816
+ entry.unsubscribeClose();
3817
+ } catch {}
3818
+ if (!entry.bridge.closed) entry.bridge.close().catch((err) => {
3819
+ this.logger.warn(`${this.provider}: bridge.close threw during evict`, {
3820
+ sessionId,
3821
+ reason,
3822
+ error: err instanceof Error ? err.message : String(err)
3823
+ });
3824
+ });
3825
+ }
3826
+ /** Evict every cached session whose process key matches. */
3827
+ evictAcpSessionsWhere(matches, reason) {
3828
+ for (const [sessionId, entry] of [...this.acpSessions]) if (matches(entry.processKey)) this.evictAcpSession(sessionId, reason);
3829
+ }
3830
+ /** Translate one `session/update` notification into stream events. */
3831
+ mapAcpUpdate(params, state, sessionId) {
3832
+ const update = params.update;
3833
+ if (!update || typeof update !== "object") return [];
3834
+ const kind = update.sessionUpdate;
3835
+ if (kind === "agent_message_chunk" || kind === "agent_thought_chunk") return acpMessageChunkEvents(kind, update, state);
3836
+ if (kind === "tool_call") return acpToolCallEvents(update, state);
3837
+ if (kind === "tool_call_update") {
3838
+ const change = update;
3839
+ const existing = state.toolCalls.get(change.toolCallId);
3840
+ if (!existing) return [];
3841
+ const mapped = this.mapToolStatus(change.status);
3842
+ const outputText = applyAcpToolCallUpdate(existing, mapped, change);
3843
+ return [...mapped === "failed" ? this.onToolFailed(sessionId, typeof existing.output === "string" ? existing.output : outputText || void 0) : [], ...acpToolCallUpdateEvents(existing, state)];
3844
+ }
3845
+ if (kind === "plan") return acpPlanEvents(update, state, this.provider);
3846
+ if (kind === "available_commands_update") return [{
3847
+ type: "raw",
3848
+ backend: this.provider,
3849
+ event: {
3850
+ sessionUpdate: "available_commands_update",
3851
+ availableCommands: update.availableCommands ?? []
3852
+ }
3853
+ }];
3854
+ return [{
3855
+ type: "raw",
3856
+ backend: this.provider,
3857
+ event: update
3858
+ }];
3859
+ }
3860
+ mapToolStatus(status) {
3861
+ switch (status) {
3862
+ case "completed": return "completed";
3863
+ case "failed": return "failed";
3864
+ case "in_progress": return "running";
3865
+ case "pending":
3866
+ case void 0: return "pending";
3867
+ default:
3868
+ this.logger.warn(`${this.provider}: unknown tool_call_update status`, {
3869
+ status,
3870
+ mappedTo: "running"
3871
+ });
3872
+ return "running";
3873
+ }
3874
+ }
3875
+ formatBridgeError(raw) {
3876
+ if (raw instanceof BridgeClosedError) return `${this.provider} bridge closed unexpectedly: ${raw.message}`;
3877
+ if (raw instanceof RpcRequestTimeoutError) return `${this.provider} request "${raw.method}" timed out after ${raw.timeoutMs}ms`;
3878
+ if (raw instanceof JsonRpcError) return jsonRpcErrorText(raw);
3879
+ if (raw instanceof Error) return raw.message;
3880
+ return String(raw);
3881
+ }
3882
+ /**
3883
+ * The error a failed process start surfaces. An agent that exits before its
3884
+ * session opens reports why only on stderr, and an agent's JSON-RPC error
3885
+ * carries its cause in `data`; a bare "process-exit" or "Internal error"
3886
+ * hides both from the caller.
3887
+ */
3888
+ sessionSetupError(err, bridge) {
3889
+ if (err instanceof BridgeClosedError) {
3890
+ const stderr = bridge.getStderrTail().trim().slice(-500);
3891
+ return new Error(`${this.provider}: agent exited before its session opened (${err.message})${stderr ? `; stderr: ${stderr}` : ""}`, { cause: err });
3892
+ }
3893
+ if (err instanceof JsonRpcError) return new Error(`${this.provider}: ${jsonRpcErrorText(err)}`, { cause: err });
3894
+ return err instanceof Error ? err : new Error(String(err));
3895
+ }
3896
+ async healthCheck() {
3897
+ return true;
3898
+ }
3899
+ async shutdown() {
3900
+ this.evictAcpSessionsWhere(() => true, "adapter-shutdown");
3901
+ await super.shutdown();
3902
+ }
3903
+ async addMcp(_name, _config) {
3904
+ throw new Error(`${this.provider}: addMcp is not supported — an ACP agent takes MCP servers through session/new`);
3905
+ }
3906
+ async getMcpStatus() {
3907
+ return { servers: {} };
3908
+ }
3909
+ buildExecArgs() {
3910
+ throw new Error(`${this.provider}: buildExecArgs is not used — ACP turns run over the RPC bridge`);
3911
+ }
3912
+ buildExecEnv(_config) {
3913
+ return { ...process.env };
3914
+ }
3915
+ createEventParser() {
3916
+ throw new Error(`${this.provider}: createEventParser is not used — session/update notifications arrive over the RPC bridge`);
3917
+ }
3918
+ buildMcpAddArgs(_name, _config) {
3919
+ throw new Error(`${this.provider}: buildMcpAddArgs is not used`);
3920
+ }
3921
+ };
3922
+ //#endregion
3923
+ //#region src/event-parsers.ts
3924
+ /**
3925
+ * Parser for harnesses that print one JSON event per line (claude-code,
3926
+ * codex, amp, factory-droids). Values that fail `accept` are logged and
3927
+ * dropped; unparseable lines become a `PARSE_ERROR` warning. The first event
3928
+ * `isReady` accepts reports the CLI's own start time as `cli_initialize`,
3929
+ * measured from parser creation, which immediately precedes the spawn.
3930
+ */
3931
+ function jsonEventParser(logger, onEvent, spec) {
3932
+ const startedAt = performance.now();
3933
+ let readyReported = false;
3934
+ return {
3935
+ transform: (raw) => {
3936
+ if (spec.accept(raw)) return raw;
3937
+ logger.warn(`Received non-${spec.label} event`, { raw });
3938
+ return null;
3939
+ },
3940
+ onEvent: (event) => {
3941
+ if (!readyReported && spec.isReady?.(event)) {
3942
+ readyReported = true;
3943
+ logger.observePhase?.("cli_initialize", performance.now() - startedAt);
3944
+ }
3945
+ for (const stream of spec.map(event)) onEvent?.(stream);
3946
+ },
3947
+ onError: (error, line) => {
3948
+ logger.warn("Failed to parse event", {
3949
+ error: error.message,
3950
+ line
3951
+ });
3952
+ onEvent?.({
3953
+ type: "warning",
3954
+ code: "PARSE_ERROR",
3955
+ message: `Failed to parse event: ${error.message}`
3956
+ });
3957
+ }
3958
+ };
3959
+ }
3960
+ /**
3961
+ * Parser for harnesses that print their reply as plain stdout lines (hermes,
3962
+ * nanoclaw, forge). Lines classify into provider events; the reply is
3963
+ * emitted as one text part when stdout closes. A turn that printed errors
3964
+ * but no reply ends `failed` with the last error instead of an empty
3965
+ * `completed`.
3966
+ */
3967
+ function plaintextResponseParser(state, onEvent, spec) {
3968
+ const lines = [];
3969
+ let errorSeen = false;
3970
+ let lastError;
3971
+ const response = {
3972
+ text: (line) => {
3973
+ lines.push(line);
3974
+ },
3975
+ error: (message) => {
3976
+ errorSeen = true;
3977
+ lastError = message;
3978
+ }
3979
+ };
3980
+ return {
3981
+ parseAs: "lines",
3982
+ transform: (raw) => spec.classify(String(raw)),
3983
+ onEvent: (event) => {
3984
+ for (const stream of spec.map(event, response)) onEvent?.(stream);
3985
+ },
3986
+ onComplete: () => {
3987
+ const text = lines.join("\n").trimEnd();
3988
+ if (text.length > 0) {
3989
+ const reply = finalTextEvent(state, text);
3990
+ onEvent?.(reply);
3991
+ onEvent?.({
3992
+ type: "status",
3993
+ status: "completed"
3994
+ });
3995
+ } else if (errorSeen) onEvent?.({
3996
+ type: "status",
3997
+ status: "failed",
3998
+ detail: lastError ?? spec.emptyFailureDetail
3999
+ });
4000
+ }
4001
+ };
4002
+ }
4003
+ //#endregion
4004
+ //#region src/interaction-mcp-server.ts
4005
+ var InteractionMcpServer = class extends InteractionMcpServer$1 {
4006
+ constructor(config) {
4007
+ super({
4008
+ ...config,
4009
+ onStop: config.onStop ?? (() => void 0)
4010
+ });
4011
+ }
4012
+ };
4013
+ //#endregion
4014
+ //#region src/terminal-plan-support.ts
4015
+ /** One-shot durable plan submission for a single provider turn. */
4016
+ var TerminalPlanSupport = class {
4017
+ committed;
4018
+ submittedBody;
4019
+ inFlight;
4020
+ confirmInFlight;
4021
+ confirmed = false;
4022
+ constructor(options) {
4023
+ this.options = options;
4024
+ }
4025
+ get plan() {
4026
+ return this.committed;
4027
+ }
4028
+ /** Wait for a submission already accepted by this turn before failure cleanup. */
4029
+ async settleSubmission() {
4030
+ await this.inFlight?.catch(() => void 0);
4031
+ }
4032
+ async submit(body, sourceToolCallId) {
4033
+ const normalized = body.trim();
4034
+ if (!normalized) throw new Error("Plan body must not be empty");
4035
+ if (this.submittedBody && this.submittedBody !== normalized) throw new Error(this.committed ? "This turn already submitted a different plan" : "This turn is already submitting a different plan");
4036
+ if (this.committed) return this.committed;
4037
+ if (this.inFlight) return this.inFlight;
4038
+ this.submittedBody = normalized;
4039
+ this.inFlight = this.options.planHost.submit({
4040
+ body: normalized,
4041
+ sourceToolCallId,
4042
+ providerState: this.options.providerState()
4043
+ }).then(async (plan) => {
4044
+ this.committed = plan;
4045
+ await this.options.onCommitted?.(plan);
4046
+ return plan;
4047
+ }).catch((error) => {
4048
+ this.inFlight = void 0;
4049
+ if (!this.committed) this.submittedBody = void 0;
4050
+ throw error;
4051
+ });
4052
+ return this.inFlight;
4053
+ }
4054
+ async confirm() {
4055
+ if (!this.committed) throw new Error("No durable plan has been committed by this turn");
4056
+ if (this.confirmed) return this.committed;
4057
+ if (this.confirmInFlight) return this.confirmInFlight;
4058
+ const committed = this.committed;
4059
+ this.confirmInFlight = this.options.planHost.confirm(committed.id).then(() => {
4060
+ this.confirmed = true;
4061
+ try {
4062
+ this.options.emit?.({
4063
+ type: "plan.submitted",
4064
+ plan: committed
4065
+ });
4066
+ } catch {}
4067
+ return committed;
4068
+ }).catch((error) => {
4069
+ this.confirmInFlight = void 0;
4070
+ throw error;
4071
+ });
4072
+ return this.confirmInFlight;
4073
+ }
4074
+ async withdraw(reason) {
4075
+ if (!this.committed) return;
4076
+ await this.options.planHost.withdraw(this.committed.id, reason);
4077
+ }
4078
+ };
4079
+ //#endregion
4080
+ //#region src/interaction-support.ts
4081
+ /**
4082
+ * Per-execution lifecycle wrapper for an MCP-capable harness. Permission and
4083
+ * question tools use the live {@link InteractionBroker}; plan tools commit
4084
+ * through {@link TerminalPlanSupport} and end the provider turn.
4085
+ *
4086
+ * Mirrors the claude-code permission-server pattern, but for the GENERIC
4087
+ * `request_permission` / `ask_user` / `request_plan` tools the agent calls
4088
+ * itself — there is no harness-specific reply contract, so any MCP-capable CLI
4089
+ * (factory-droids, …) composes this with ~10 lines: declare the capability
4090
+ * kinds, start/stop around the spawn, inject the server via the CLI's MCP
4091
+ * mechanism, and forward `respondToInteraction` here.
4092
+ *
4093
+ * Unlike claude-code's `--permission-prompt-tool`, these generic tools fire
4094
+ * only when the model chooses to call them. Permission is therefore advisory,
4095
+ * while plan-capable adapters separately prove the terminal turn boundary.
4096
+ */
4097
+ var McpInteractionSupport = class {
4098
+ /** The shared emit → block → respond → fail-closed round-trip. */
4099
+ broker = new InteractionBroker$1();
4100
+ activeServer;
4101
+ terminalPlan;
4102
+ constructor(serverName, kinds) {
4103
+ this.serverName = serverName;
4104
+ this.kinds = kinds;
4105
+ }
4106
+ /** The interaction kinds to advertise from `getCapabilities().interactions`. */
4107
+ capabilityKinds() {
4108
+ return [...this.kinds];
4109
+ }
4110
+ /** The active per-execution server (its `url`/`token`), once started. */
4111
+ get server() {
4112
+ return this.activeServer;
4113
+ }
4114
+ /**
4115
+ * Start a per-execution server bound to 127.0.0.1 with an ephemeral bearer
4116
+ * token, registering only the enabled tools. `emit` is the live event sink;
4117
+ * broker-backed tools block on an answer, while plans emit only after their
4118
+ * terminal confirmation. Returns the server for CLI injection.
4119
+ */
4120
+ async start(sessionId, binding, emit, plan) {
4121
+ const tools = brokerInteractionTools$1(this.broker, {
4122
+ sessionId,
4123
+ binding,
4124
+ emit
4125
+ });
4126
+ const has = (kind) => this.kinds.includes(kind);
4127
+ if (has(InteractionKind.Plan) && !plan) throw new Error("Plan interaction requires a durable plan host and provider resume state");
4128
+ this.terminalPlan = plan ? new TerminalPlanSupport({
4129
+ ...plan,
4130
+ emit
4131
+ }) : void 0;
4132
+ const server = new InteractionMcpServer$1({
4133
+ serverName: this.serverName,
4134
+ onStop: tools.onStop,
4135
+ ...has(InteractionKind.Permission) ? { requestPermission: tools.requestPermission } : {},
4136
+ ...has(InteractionKind.Question) ? { askUser: tools.askUser } : {},
4137
+ ...has(InteractionKind.Plan) && this.terminalPlan ? { requestPlan: ({ plan: body, sourceToolCallId }) => this.terminalPlan.submit(body, sourceToolCallId) } : {}
4138
+ });
4139
+ await server.start();
4140
+ this.activeServer = server;
4141
+ return server;
4142
+ }
4143
+ async confirmSubmittedPlan() {
4144
+ if (!this.terminalPlan) throw new Error("No terminal plan support is active for this turn");
4145
+ return this.terminalPlan.confirm();
4146
+ }
4147
+ async withdrawSubmittedPlan(reason) {
4148
+ await this.terminalPlan?.withdraw(reason);
4149
+ }
4150
+ /** Close the request boundary before inspecting or withdrawing its plan. */
4151
+ async drainSubmittedPlan(sessionId) {
4152
+ this.broker.failSession(sessionId);
4153
+ let stopError;
4154
+ try {
4155
+ await this.activeServer?.stop();
4156
+ this.activeServer = void 0;
4157
+ } catch (error) {
4158
+ stopError = error;
4159
+ }
4160
+ await this.terminalPlan?.settleSubmission();
4161
+ if (stopError) throw stopError;
4162
+ }
4163
+ /**
4164
+ * Fail-closed every pending interaction for the session, then stop the server.
4165
+ * `failSession` runs FIRST so any request still blocked in the server resolves
4166
+ * (deny) before close, and the whole thing swallows errors — it runs in adapter
4167
+ * `finally` blocks and must never mask the turn's real error.
4168
+ */
4169
+ async stop(sessionId) {
4170
+ try {
4171
+ this.broker.failSession(sessionId);
4172
+ const server = this.activeServer;
4173
+ this.activeServer = void 0;
4174
+ await server?.stop();
4175
+ } catch {}
4176
+ }
4177
+ /**
4178
+ * Route an inbound live `/interactions` response for permission or question.
4179
+ * Plans are durable records and never enter this response channel.
4180
+ */
4181
+ respond(response) {
4182
+ if (this.broker.respond(response)) return true;
4183
+ return this.broker.respondQuestion(response);
4184
+ }
4185
+ };
4186
+ //#endregion
4187
+ //#region src/metering.ts
4188
+ const UsageRecordSchema = z.record(z.string(), z.unknown());
4189
+ const UsageNumberSchema = z.union([z.number(), z.string().trim().min(1)]).transform(Number).pipe(z.number().finite().nonnegative());
4190
+ const TOKEN_USAGE_INPUT_KEYS = Object.freeze([
4191
+ "inputTokens",
4192
+ "input",
4193
+ "input_tokens",
4194
+ "promptTokens",
4195
+ "prompt_tokens"
4196
+ ]);
4197
+ const TOKEN_USAGE_OUTPUT_KEYS = Object.freeze([
4198
+ "outputTokens",
4199
+ "output",
4200
+ "output_tokens",
4201
+ "completionTokens",
4202
+ "completion_tokens"
4203
+ ]);
4204
+ const TOKEN_USAGE_TOTAL_KEYS = Object.freeze([
4205
+ "totalTokens",
4206
+ "total_tokens",
4207
+ "total"
4208
+ ]);
4209
+ const TOKEN_USAGE_REASONING_KEYS = Object.freeze([
4210
+ "reasoningTokens",
4211
+ "reasoning_tokens",
4212
+ "reasoning",
4213
+ "reasoning_output_tokens"
4214
+ ]);
4215
+ const TOKEN_USAGE_COST_KEYS = Object.freeze([
4216
+ "totalCostUsd",
4217
+ "costUsd",
4218
+ "total_cost_usd",
4219
+ "cost_usd",
4220
+ "cost"
4221
+ ]);
4222
+ const CACHE_READ_INPUT_KEYS = Object.freeze([
4223
+ "cacheReadInputTokens",
4224
+ "cache_read_input_tokens",
4225
+ "cacheRead",
4226
+ "cache_read",
4227
+ "cached_input_tokens"
4228
+ ]);
4229
+ const CACHE_CREATION_INPUT_KEYS = Object.freeze([
4230
+ "cacheCreationInputTokens",
4231
+ "cache_creation_input_tokens",
4232
+ "cacheWrite",
4233
+ "cache_write"
4234
+ ]);
4235
+ const NESTED_COST_KEYS = Object.freeze([
4236
+ "total",
4237
+ "totalUsd",
4238
+ "total_usd",
4239
+ "usd"
4240
+ ]);
4241
+ function asRecord(raw) {
4242
+ const parsed = UsageRecordSchema.safeParse(raw);
4243
+ return parsed.success ? parsed.data : void 0;
4244
+ }
4245
+ function tokenCount(raw) {
4246
+ const parsed = UsageNumberSchema.safeParse(raw);
4247
+ return parsed.success ? Math.trunc(parsed.data) : void 0;
4248
+ }
4249
+ function firstTokenCount(source, keys) {
4250
+ for (const key of keys) {
4251
+ const count = tokenCount(source[key]);
4252
+ if (count !== void 0) return count;
4253
+ }
4254
+ }
4255
+ function firstUsageCostUsd(source, keys = TOKEN_USAGE_COST_KEYS) {
4256
+ for (const key of keys) {
4257
+ const parsed = UsageNumberSchema.safeParse(source[key]);
4258
+ if (parsed.success) return parsed.data;
4259
+ }
4260
+ }
4261
+ function usageRecord(source) {
4262
+ return asRecord(source.tokenUsage) ?? asRecord(source.usage) ?? source;
4263
+ }
4264
+ function readTokenUsage(source) {
4265
+ const usageSource = usageRecord(source);
4266
+ const inputTokens = firstTokenCount(usageSource, TOKEN_USAGE_INPUT_KEYS);
4267
+ const outputTokens = firstTokenCount(usageSource, TOKEN_USAGE_OUTPUT_KEYS);
4268
+ return inputTokens !== void 0 || outputTokens !== void 0 ? {
4269
+ inputTokens,
4270
+ outputTokens
4271
+ } : void 0;
4272
+ }
4273
+ function readTokenCostUsd(source) {
4274
+ const usageSource = usageRecord(source);
4275
+ return firstUsageCostUsd(source) ?? firstUsageCostUsd(usageSource) ?? readNestedCostUsd(source) ?? readNestedCostUsd(usageSource);
4276
+ }
4277
+ function readNestedCostUsd(source) {
4278
+ const cost = asRecord(source.cost);
4279
+ return cost ? firstUsageCostUsd(cost, NESTED_COST_KEYS) : void 0;
4280
+ }
4281
+ function readCliTokenUsage(raw) {
4282
+ const parsed = UsageRecordSchema.safeParse(raw);
4283
+ if (!parsed.success) return void 0;
4284
+ const source = parsed.data;
4285
+ const usageSource = usageRecord(source);
4286
+ const usage = readTokenUsage(source) ?? readTokenUsage(usageSource);
4287
+ const cost = readTokenCostUsd(source);
4288
+ const totalTokens = firstTokenCount(usageSource, TOKEN_USAGE_TOTAL_KEYS);
4289
+ const reasoningTokens = firstTokenCount(usageSource, TOKEN_USAGE_REASONING_KEYS);
4290
+ const cacheReadInputTokens = firstTokenCount(usageSource, CACHE_READ_INPUT_KEYS);
4291
+ const cacheCreationInputTokens = firstTokenCount(usageSource, CACHE_CREATION_INPUT_KEYS);
4292
+ if (!usage && totalTokens === void 0 && reasoningTokens === void 0 && cost === void 0 && cacheReadInputTokens === void 0 && cacheCreationInputTokens === void 0) return;
4293
+ return {
4294
+ inputTokens: usage?.inputTokens,
4295
+ outputTokens: usage?.outputTokens,
4296
+ totalTokens,
4297
+ reasoningTokens,
4298
+ cacheReadInputTokens,
4299
+ cacheCreationInputTokens,
4300
+ cost
4301
+ };
4302
+ }
4303
+ function recordCliTokenUsage(state, ...sources) {
4304
+ for (const source of sources) {
4305
+ const tokenUsage = readCliTokenUsage(source);
4306
+ if (tokenUsage) {
4307
+ state.tokenUsage = tokenUsage;
4308
+ return true;
4309
+ }
4310
+ }
4311
+ return false;
4312
+ }
4313
+ function addUsage(left, right) {
4314
+ if (left === void 0) return right;
4315
+ if (right === void 0) return left;
4316
+ const sum = left + right;
4317
+ return Number.isFinite(sum) ? sum : Math.max(left, right);
4318
+ }
4319
+ /**
4320
+ * Adds one usage report to the running total instead of replacing it.
4321
+ *
4322
+ * For a CLI that reports usage PER TURN (pi) rather than once per run,
4323
+ * `recordCliTokenUsage` would leave the execution billed as its final turn
4324
+ * alone. Use this wherever a single execution can produce more than one usage
4325
+ * report; use `recordCliTokenUsage` when the CLI emits one cumulative total.
4326
+ *
4327
+ * Returns true when a usage report was found in `sources` and folded in.
4328
+ */
4329
+ function accumulateCliTokenUsage(state, ...sources) {
4330
+ for (const source of sources) {
4331
+ const tokenUsage = readCliTokenUsage(source);
4332
+ if (!tokenUsage) continue;
4333
+ const total = state.tokenUsage;
4334
+ state.tokenUsage = total ? {
4335
+ inputTokens: addUsage(total.inputTokens, tokenUsage.inputTokens),
4336
+ outputTokens: addUsage(total.outputTokens, tokenUsage.outputTokens),
4337
+ totalTokens: addUsage(total.totalTokens, tokenUsage.totalTokens),
4338
+ reasoningTokens: addUsage(total.reasoningTokens, tokenUsage.reasoningTokens),
4339
+ cacheReadInputTokens: addUsage(total.cacheReadInputTokens, tokenUsage.cacheReadInputTokens),
4340
+ cacheCreationInputTokens: addUsage(total.cacheCreationInputTokens, tokenUsage.cacheCreationInputTokens),
4341
+ cost: addUsage(total.cost, tokenUsage.cost)
4342
+ } : tokenUsage;
4343
+ return true;
4344
+ }
4345
+ return false;
4346
+ }
4347
+ function parseCliUsageLine(line) {
4348
+ if (!line.startsWith("usage:")) return void 0;
4349
+ const raw = line.slice(6).trim();
4350
+ if (!raw) return void 0;
4351
+ if (raw.startsWith("{")) return asRecord(JSON.parse(raw));
4352
+ const fields = {};
4353
+ for (const part of raw.split(/[,\s]+/)) {
4354
+ if (!part) continue;
4355
+ const separator = part.includes("=") ? "=" : part.includes(":") ? ":" : "";
4356
+ if (!separator) continue;
4357
+ const [key, value] = part.split(separator, 2);
4358
+ if (!key || value === void 0) continue;
4359
+ const numeric = Number(value);
4360
+ fields[key] = Number.isFinite(numeric) ? numeric : value;
4361
+ }
4362
+ return Object.keys(fields).length > 0 ? fields : void 0;
4363
+ }
4364
+ //#endregion
4365
+ //#region src/reasoning-effort.ts
4366
+ /** How each CLI takes agent-interface's native control token on its argv. */
4367
+ const EFFORT_FLAG = {
4368
+ "claude-code": (value) => ["--effort", value],
4369
+ codex: (value) => ["-c", `model_reasoning_effort=${value}`],
4370
+ openclaw: (value) => ["--thinking", value],
4371
+ pi: (value) => ["--thinking", value]
4372
+ };
4373
+ /** The argv carrying `nativeReasoningControl` for a profile effort, or none. */
4374
+ function nativeEffortArgs(harness, effort) {
4375
+ const value = nativeReasoningControl(harness, effort ?? null);
4376
+ const flag = EFFORT_FLAG[harness];
4377
+ return value === null || flag === void 0 ? [] : flag(value);
4378
+ }
4379
+ /**
4380
+ * Kimi stays local until #14839: the sandbox ships kimi-cli 1.49.0 as
4381
+ * `kimi-code` (binary `--thinking`/`--no-thinking`) while other paths run
4382
+ * kimi-code 2.x. A model whose name has "thinking" or "reason" always thinks
4383
+ * (kimi-cli `derive_model_capabilities`), so `off` throws there.
4384
+ */
4385
+ function kimiThinkingArgs(level, modelId) {
4386
+ if (level === void 0) return [];
4387
+ const off = level === "none" || level === "minimal";
4388
+ if (!REASONING_EFFORTS.includes(level) || off && /thinking|reason/i.test(modelId ?? "")) throw new Error(`kimi-code cannot honor reasoning effort "${level}"`);
4389
+ return off ? ["--no-thinking"] : ["--thinking"];
4390
+ }
4391
+ //#endregion
4392
+ export { ACP_MIN_PROTOCOL_VERSION, ACP_PROTOCOL_VERSION, AcpProviderAdapterBase, CliProviderAdapterBase, DEFAULT_INTERACTION_MCP_SERVER_NAME, EXECUTION_TRACE_HEADER, InteractionBroker, InteractionHttpBridge, InteractionMcpServer, InvalidAttachmentError, MANAGED_QUOTE_VERSION_HEADER, MAX_INLINE_IMAGE_BYTES, MAX_TOTAL_INLINE_IMAGE_BYTES, McpInteractionSupport, PERMISSION_DECISION_TIMEOUT_MS, PROMPT_SECTION_SEPARATOR, SessionRehydrationError, TerminalPlanSupport, accumulateCliTokenUsage, acpContentText, brokerInteractionTools, buildFileUrl, createExecutionState, createRepeatedToolFailureGuard, deriveFilePart, deterministicFilePartId, enabledInteractionKinds, executionCorrelationHeaders, finalTextEvent, homeXdgEnv, inferMediaType, interactionMcpToolName, interactionToggleEnabled, isLocalMcpConfig, jsonEventParser, kimiThinkingArgs, localMcpArgv, localMcpEnv, mapAcpStopReason, nativeEffortArgs, parseCliUsageLine, partitionImageAttachments, plaintextResponseParser, preparePromptInputParts, prependPromptSection, readCliTokenUsage, readEphemeralContext, reasoningEvents, recordCliTokenUsage, rejectNotLaunched, runtimeSecretsOverlay, sessionObservedEvents, spawnAcpBridge, streamPartBase, textDeltaEvents, textSnapshotEvents, toolResultEvents, toolStartEvents, trackFilePart, trackReasoningBlock, turnInteractionEnabled, wasNeverLaunched, withEphemeralContext, withTurnInteractions };
4393
+
4394
+ //# sourceMappingURL=index.mjs.map