1import type {
2 Event as OpenCodeEvent,
3 Message as OpenCodeMessage,
4 Part as OpenCodePart,
5 PermissionRequest,
6 PermissionRuleset,
7 QuestionRequest,
8 Session as OpenCodeSession,
9 Todo as OpenCodeTodo,
10 ToolPart,
11} from "@opencode-ai/sdk/v2";
12import * as HostProcess from "@t3tools/shared/HostProcess";
13import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
14import { causeErrorTag } from "@t3tools/shared/observability";
15import {
16 defaultInstanceIdForDriver,
17 type ModelSelection,
18 type OrchestrationV2AppThread,
19 type OrchestrationV2ConversationMessage,
20 type OrchestrationV2ExecutionNode,
21 type OrchestrationV2PlanStep,
22 type OrchestrationV2ProviderCapabilities,
23 type OrchestrationV2ProviderFailure,
24 type OrchestrationV2ProviderRef,
25 type OrchestrationV2ProviderSession,
26 type OrchestrationV2ProviderThread,
27 type OrchestrationV2ProviderTurn,
28 type OrchestrationV2RuntimeRequest,
29 type OrchestrationV2Subagent,
30 type OrchestrationV2TurnItem,
31 type PlanId,
32 ProviderDriverKind,
33 type ProviderInstanceId,
34 type ProviderRequestKind,
35 type ProviderSessionId,
36 type RuntimeRequestId,
37 type ThreadId,
38} from "@t3tools/contracts";
39import { OpenCodeSettings } from "../settings.ts";
40import * as Cause from "effect/Cause";
41import * as DateTime from "effect/DateTime";
42import * as Deferred from "effect/Deferred";
43import * as Duration from "effect/Duration";
44import * as Effect from "effect/Effect";
45import * as Exit from "effect/Exit";
46import * as Fiber from "effect/Fiber";
47import * as Layer from "effect/Layer";
48import * as Option from "effect/Option";
49import * as Queue from "effect/Queue";
50import * as Random from "effect/Random";
51import * as Ref from "effect/Ref";
52import * as Schema from "effect/Schema";
53import * as Semaphore from "effect/Semaphore";
54import * as Scope from "effect/Scope";
55import * as Stream from "effect/Stream";
56
57import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
58import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation";
59import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
60import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
61import {
62 structuralProtocolMethod,
63 summarizeNativeProtocolPayload,
64} from "@t3tools/provider-core/server/nativeProtocolLogging";
65import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
66import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions";
67import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions";
68import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
69import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
70import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
71import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition";
72import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt";
73import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
74import {
75 ProviderAdapterDriverCreateError,
76 type ProviderAdapterDriver,
77 type ProviderAdapterDriverCreateInput,
78} from "@t3tools/provider-core/server/adapterDriver";
79import {
80 makeSubagentChildThread,
81 subagentThreadTitle,
82} from "@t3tools/provider-core/server/subagentProjection";
83import { openCodeToolTurnItem } from "./toolItems.ts";
84
85export { openCodeToolProjectionKind } from "./toolItems.ts";
86
87export const OPENCODE_PROVIDER = ProviderDriverKind.make("opencode");
88export const OPENCODE_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(OPENCODE_PROVIDER);
89export const OPENCODE_SDK_PROTOCOL = "opencode-sdk.sse" as const;
90const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({});
91
92const makeOpenCodeMessageId = Effect.fnUntraced(function* (
93 clock: Ref.Ref<{ readonly epochMillis: number; readonly counter: number }>,
94) {
95 const epochMillis = DateTime.toEpochMillis(yield* DateTime.now);
96 const counter = yield* Ref.modify(clock, (previous) => {
97 const next = previous.epochMillis === epochMillis ? previous.counter + 1 : 1;
98 return [next, { epochMillis, counter: next }] as const;
99 });
100 const encodedTime = BigInt.asUintN(48, BigInt(epochMillis) * 0x1000n + BigInt(counter))
101 .toString(16)
102 .padStart(12, "0");
103 const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
104 const random = (yield* Effect.forEach(Array.from({ length: 28 }), () =>
105 Random.nextIntBetween(0, alphabet.length - 1),
106 ))
107 .map((index) => alphabet[index])
108 .join("");
109 return `msg_${encodedTime}${random}`;
110});
111
112/**
113 * OpenCode's session, message, part, and interaction-request identifiers are
114 * durable. It does not expose a first-class turn object: the initiating user
115 * message is the best native turn correlation point, and session idle is the
116 * authoritative terminal signal.
117 */
118const OpenCodeProviderCapabilitiesV2 = {
119 sessions: {
120 // The current adapter owns one directory-bound client/server per session.
121 // Keep it isolated until its runtime is made safe for cross-thread pooling.
122 supportsMultipleProviderThreadsPerSession: false,
123 supportsModelSwitchInSession: true,
124 supportsProviderSwitchingViaHandoff: true,
125 supportsRuntimeModeSwitchInSession: false,
126 pendingRequestsSurviveRestart: false,
127 },
128 threads: {
129 canCreateEmptyThread: true,
130 canReadThreadSnapshot: true,
131 canRollbackThread: true,
132 canForkThread: true,
133 canForkFromTurn: true,
134 canForkFromSubagentThread: true,
135 exposesNativeThreadId: true,
136 },
137 turns: {
138 exposesNativeTurnId: false,
139 emitsTurnStarted: true,
140 emitsTurnCompleted: true,
141 supportsInterrupt: true,
142 supportsActiveSteering: true,
143 supportsSteeringByInterruptRestart: true,
144 supportsQueuedMessages: true,
145 terminalStatusQuality: "strong",
146 },
147 streaming: {
148 streamsAssistantText: true,
149 streamsReasoning: true,
150 streamsToolOutput: true,
151 streamsPlanText: false,
152 emitsMessageCompleted: true,
153 },
154 tools: {
155 exposesToolItemIds: true,
156 emitsToolStarted: true,
157 emitsToolCompleted: true,
158 emitsToolOutput: true,
159 supportsMcpTools: true,
160 supportsDynamicToolCallbacks: false,
161 },
162 approvals: {
163 supportsCommandApproval: true,
164 supportsFileReadApproval: true,
165 supportsFileChangeApproval: true,
166 supportsApplyPatchApproval: true,
167 approvalsHaveNativeRequestIds: true,
168 approvalCallbacksAreLiveOnly: true,
169 approvalsCanOriginateFromSubagents: true,
170 },
171 planning: {
172 emitsPlanUpdated: true,
173 emitsTodoList: true,
174 emitsProposedPlan: false,
175 supportsStructuredQuestions: true,
176 planDeltasHaveItemIds: false,
177 },
178 subagents: {
179 supportsSubagents: true,
180 exposesSubagentThreadIds: true,
181 emitsSubagentLifecycle: true,
182 canWaitForSubagents: true,
183 canCloseSubagents: false,
184 canForkSubagentThread: true,
185 },
186 context: {
187 acceptsSystemContext: false,
188 acceptsDeveloperContext: false,
189 acceptsSyntheticUserContext: true,
190 canGenerateSummaries: true,
191 canConsumeHandoffSummaries: true,
192 supportsDeltaHandoff: true,
193 supportsFullThreadHandoff: true,
194 maxRecommendedHandoffChars: null,
195 },
196 checkpointing: {
197 appCanCheckpointFilesystem: true,
198 supportsNestedCheckpointScopes: true,
199 providerCanRollbackConversation: true,
200 providerRollbackReturnsSnapshot: true,
201 providerCanReadConversationSnapshot: true,
202 },
203 identity: {
204 nativeThreadIds: "strong",
205 nativeTurnIds: "weak",
206 nativeItemIds: "strong",
207 nativeRequestIds: "strong",
208 },
209 runtimePolicy: {
210 enforcement: "native",
211 },
212} satisfies OrchestrationV2ProviderCapabilities;
213
214type TerminalTurnStatus = Extract<
215 OrchestrationV2ProviderTurn["status"],
216 "completed" | "interrupted" | "failed" | "cancelled"
217>;
218
219type OpenCodeStepUsage = Pick<
220 Extract<OpenCodePart, { readonly type: "step-finish" }>,
221 "id" | "tokens"
222>;
223
224interface OpenCodeTurnTokenUsageAccumulator {
225 readonly partIds: Set<string>;
226 readonly promptMessageIds: Set<string>;
227 readonly assistantOwnershipByMessageId: Map<string, "owned" | "other" | "unknown">;
228 // Native removal does not undo usage. Keep unresolved counts until this turn settles.
229 readonly unresolvedStepsByMessageId: Map<string, Map<string, OpenCodeStepUsage>>;
230 inputTokens: number;
231 cachedInputTokens: number;
232 cacheCreationTokens: number;
233 outputTokens: number;
234 reasoningTokens: number;
235 hasSubagents: boolean;
236 complete: boolean;
237}
238
239function makeOpenCodeTurnTokenUsageAccumulator(): OpenCodeTurnTokenUsageAccumulator {
240 return {
241 partIds: new Set(),
242 promptMessageIds: new Set(),
243 assistantOwnershipByMessageId: new Map(),
244 unresolvedStepsByMessageId: new Map(),
245 inputTokens: 0,
246 cachedInputTokens: 0,
247 cacheCreationTokens: 0,
248 outputTokens: 0,
249 reasoningTokens: 0,
250 hasSubagents: false,
251 complete: true,
252 };
253}
254
255function accumulateOpenCodeStepUsage(
256 accumulator: OpenCodeTurnTokenUsageAccumulator,
257 part: OpenCodeStepUsage,
258): void {
259 if (accumulator.partIds.has(part.id)) return;
260 accumulator.partIds.add(part.id);
261 accumulator.inputTokens += part.tokens.input + part.tokens.cache.read + part.tokens.cache.write;
262 accumulator.cachedInputTokens += part.tokens.cache.read;
263 accumulator.cacheCreationTokens += part.tokens.cache.write;
264 accumulator.outputTokens += part.tokens.output + part.tokens.reasoning;
265 accumulator.reasoningTokens += part.tokens.reasoning;
266}
267
268interface ActiveOpenCodeTurn {
269 readonly usage: OpenCodeTurnTokenUsageAccumulator;
270 readonly isRoot: boolean;
271 readonly threadId: ThreadId;
272 readonly runId: ProviderAdapter.ProviderAdapterV2TurnInput["runId"] | null;
273 readonly rootNodeId: ProviderAdapter.ProviderAdapterV2TurnInput["rootNodeId"];
274 readonly appThread: OrchestrationV2AppThread;
275 readonly modelSelection: ModelSelection;
276 readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy;
277 readonly providerTurnId: OrchestrationV2ProviderTurn["id"];
278 /** The provider thread the turn started on, which its terminal names. */
279 readonly providerThreadId: OrchestrationV2ProviderTurn["providerThreadId"];
280 readonly providerTurnOrdinal: number;
281 readonly runOrdinal: number;
282 readonly runAttemptId: OrchestrationV2ProviderTurn["runAttemptId"];
283 readonly startedAt: DateTime.Utc;
284 readonly itemOrdinals: Map<string, number>;
285 readonly parts: Map<string, Exclude<OpenCodePart, ToolPart>>;
286 readonly partIdsByMessage: Map<string, Set<string>>;
287 readonly toolNamesByCallId: Map<string, string>;
288 mcpServerNames?: ReadonlyArray<string>;
289 readonly providerTurn: OrchestrationV2ProviderTurn;
290 nextItemOrdinal: number;
291 nativeUserMessageId: string | null;
292 admissionMessageId: string | null;
293 interrupted: boolean;
294 finalized: boolean;
295 planId: PlanId | null;
296 admissionGeneration: number;
297 admissionReconciliationGeneration: number | null;
298 admissionPending: boolean;
299 admissionAccepted: boolean;
300 admissionMessageObserved: boolean;
301 idleDuringAdmission: boolean;
302 admissionSettled: Deferred.Deferred<void>;
303 admissionAbortController: AbortController | null;
304}
305
306type OpenCodeAdmissionSignal =
307 | "accepted"
308 | "assistant-completed"
309 | "busy"
310 | "idle"
311 | "user-message";
312type OpenCodeAdmissionAction = "hold" | "reconcile-idle" | "release";
313
314export function advanceOpenCodePromptAdmission(
315 admission: Pick<
316 ActiveOpenCodeTurn,
317 "admissionAccepted" | "admissionMessageObserved" | "admissionPending" | "idleDuringAdmission"
318 >,
319 signal: OpenCodeAdmissionSignal,
320): OpenCodeAdmissionAction {
321 if (!admission.admissionPending) return "release";
322 if (signal === "assistant-completed") {
323 admission.admissionAccepted = true;
324 admission.admissionMessageObserved = true;
325 admission.admissionPending = false;
326 return "release";
327 }
328 if (signal === "idle") {
329 admission.idleDuringAdmission = true;
330 return "hold";
331 }
332 if (signal === "accepted") admission.admissionAccepted = true;
333 if (signal === "busy" || signal === "user-message") admission.admissionMessageObserved = true;
334 if (!admission.admissionAccepted || !admission.admissionMessageObserved) return "hold";
335 if (admission.idleDuringAdmission) return "reconcile-idle";
336 admission.admissionPending = false;
337 return "release";
338}
339
340export function cancelOpenCodePromptAdmission(
341 admission: Pick<ActiveOpenCodeTurn, "admissionGeneration" | "admissionPending">,
342 nextGeneration: number,
343): void {
344 admission.admissionGeneration = nextGeneration;
345 admission.admissionPending = false;
346}
347
348export const reconcileOpenCodePromptAdmissionStatus = Effect.fn(
349 "reconcileOpenCodePromptAdmissionStatus",
350)(function* (
351 admission: Pick<ActiveOpenCodeTurn, "admissionGeneration" | "admissionPending">,
352 generation: number,
353 readStatus: Effect.Effect<"busy" | "idle" | "unknown">,
354) {
355 if (admission.admissionGeneration !== generation || !admission.admissionPending) {
356 return "stale" as const;
357 }
358 const status = yield* readStatus;
359 if (admission.admissionGeneration !== generation || !admission.admissionPending) {
360 return "stale" as const;
361 }
362 if (status !== "unknown") admission.admissionPending = false;
363 return status;
364});
365
366interface OpenCodeSubagentContext {
367 readonly nativeItemId: string;
368 readonly nodeId: OrchestrationV2Subagent["id"];
369 readonly parentState: OpenCodeThreadState;
370 readonly parentTurn: ActiveOpenCodeTurn;
371 readonly prompt: string;
372 readonly title: string | null;
373 readonly startedAt: DateTime.Utc;
374 childSessionId: string | null;
375 childThreadId: ThreadId | null;
376 childProviderThreadId: OrchestrationV2ProviderThread["id"] | null;
377 model: string | null;
378 result: string | null;
379}
380
381interface OpenCodeThreadState {
382 readonly nativeSessionId: string;
383 providerThread: OrchestrationV2ProviderThread;
384 appThread: OrchestrationV2AppThread | null;
385 activeTurn: ActiveOpenCodeTurn | null;
386 readonly providerTurns: Map<string, OrchestrationV2ProviderTurn>;
387 readonly runtimeRequests: Map<string, OrchestrationV2RuntimeRequest>;
388 readonly messageRoles: Map<string, "user" | "assistant">;
389 readonly userMessageIds: Array<string>;
390 parentSubagent: OpenCodeSubagentContext | null;
391 nextChildTurnOrdinal: number;
392 nextAdmissionGeneration: number;
393}
394
395interface OpenCodeRequestOwner {
396 readonly state: OpenCodeThreadState;
397 readonly turn: ActiveOpenCodeTurn;
398 /** The subagent on the owner's thread that leads to the asking session. */
399 readonly subagent: OpenCodeSubagentContext | null;
400}
401
402/**
403 * The top-level thread and active turn that own a session's requests, walking
404 * up through (possibly nested) subagents.
405 */
406function topLevelRequestOwner(state: OpenCodeThreadState): OpenCodeRequestOwner | undefined {
407 let owner = state;
408 let subagent: OpenCodeSubagentContext | null = null;
409 while (owner.parentSubagent !== null) {
410 subagent = owner.parentSubagent;
411 owner = subagent.parentState;
412 }
413 return owner.activeTurn === null ? undefined : { state: owner, turn: owner.activeTurn, subagent };
414}
415
416interface PendingOpenCodeRequest {
417 readonly requestId: RuntimeRequestId;
418 readonly nativeRequestId: string;
419 /** The session that asked, which may be a subagent's. */
420 readonly nativeSessionId: string;
421 readonly turn: ActiveOpenCodeTurn;
422 readonly state: OpenCodeThreadState;
423 readonly nodeId: OrchestrationV2ExecutionNode["id"];
424 readonly parentNodeId: OrchestrationV2ExecutionNode["id"];
425 readonly turnItemId: OrchestrationV2TurnItem["id"];
426 readonly requestKind: OpenCodePermissionRequestKind | "user_input";
427 readonly createdAt: DateTime.Utc;
428 readonly permission?: PermissionRequest;
429 readonly question?: QuestionRequest;
430}
431
432export interface OpenCodeAdapterV2Options {
433 readonly instanceId: ProviderInstanceId;
434 readonly settings: OpenCodeSettings;
435 readonly environment: NodeJS.ProcessEnv;
436 readonly nativeEventLogger?: ProviderEventLoggers.EventNdjsonLogger;
437}
438
439export interface OpenCodeProtocolLogEvent {
440 readonly direction: "incoming" | "outgoing";
441 readonly messageKind: "request" | "response" | "notification" | "error";
442 readonly method: string;
443 readonly payload: unknown;
444}
445
446function formatOpenCodeProtocolLogPayload(event: OpenCodeProtocolLogEvent) {
447 return {
448 direction: event.direction,
449 messageKind: event.messageKind,
450 method: structuralProtocolMethod(event.method),
451 payload: summarizeNativeProtocolPayload(event.payload),
452 };
453}
454
455export function makeOpenCodeProtocolLogger(input: {
456 readonly nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger | undefined;
457 readonly idAllocator: IdAllocator.IdAllocatorV2["Service"];
458 readonly providerInstanceId: ProviderInstanceId;
459 readonly providerSessionId: ProviderSessionId;
460 readonly threadId: ThreadId;
461}): (event: OpenCodeProtocolLogEvent) => Effect.Effect<void, never> {
462 return (event) =>
463 Effect.gen(function* () {
464 if (!input.nativeEventLogger) return;
465 const observedAt = DateTime.formatIso(yield* DateTime.now);
466 const method = structuralProtocolMethod(event.method);
467 yield* input.nativeEventLogger.write(
468 {
469 observedAt,
470 event: {
471 id: yield* input.idAllocator.allocate.rawEvent({
472 providerSessionId: input.providerSessionId,
473 method,
474 }),
475 kind: "protocol",
476 protocol: OPENCODE_SDK_PROTOCOL,
477 provider: OPENCODE_PROVIDER,
478 providerInstanceId: input.providerInstanceId,
479 providerSessionId: input.providerSessionId,
480 createdAt: observedAt,
481 threadId: input.threadId,
482 payload: formatOpenCodeProtocolLogPayload(event),
483 },
484 },
485 input.threadId,
486 );
487 }).pipe(
488 Effect.catchCause((cause) =>
489 Cause.hasInterrupts(cause)
490 ? Effect.interrupt
491 : Effect.logWarning("Failed to write native OpenCode event log.", {
492 errorTag: causeErrorTag(cause),
493 reasonCount: cause.reasons.length,
494 provider: OPENCODE_PROVIDER,
495 threadId: input.threadId,
496 }),
497 ),
498 );
499}
500
501function protocolError(
502 detail: string,
503 payload?: unknown,
504): ProviderAdapter.ProviderAdapterProtocolError {
505 return new ProviderAdapter.ProviderAdapterProtocolError({
506 driver: OPENCODE_PROVIDER,
507 detail,
508 ...(payload === undefined ? {} : { payload }),
509 });
510}
511
512function nativeThreadId(providerThread: OrchestrationV2ProviderThread): string {
513 const nativeId = providerThread.nativeThreadRef?.nativeId;
514 if (nativeId === null || nativeId === undefined) {
515 throw protocolError(`Provider thread ${providerThread.id} has no OpenCode session id`);
516 }
517 return nativeId;
518}
519
520function dateTimeFromEpoch(value: number | undefined, fallback: DateTime.Utc): DateTime.Utc {
521 if (value === undefined) return fallback;
522 return Option.getOrElse(DateTime.make(value), () => fallback);
523}
524
525function nonEmptyString(value: unknown): string | undefined {
526 return typeof value === "string" && value.trim().length > 0 ? value : undefined;
527}
528
529function recordValue(input: unknown, key: string): unknown {
530 return typeof input === "object" && input !== null && key in input
531 ? (input as Record<string, unknown>)[key]
532 : undefined;
533}
534
535function recordString(input: unknown, ...keys: ReadonlyArray<string>): string | undefined {
536 for (const key of keys) {
537 const value = nonEmptyString(recordValue(input, key));
538 if (value !== undefined) return value;
539 }
540 return undefined;
541}
542
543function sdkResponseForRawLog(value: unknown): unknown {
544 if (typeof value !== "object" || value === null) return value;
545 if ("data" in value) return { data: (value as { readonly data?: unknown }).data ?? null };
546 if ("stream" in value) return { subscribed: true };
547 return value;
548}
549
550type OpenCodePermissionRequestKind = Extract<
551 ProviderRequestKind,
552 "command" | "file-read" | "file-change"
553>;
554
555export function openCodePermissionRequestKind(
556 permission: string,
557 toolName?: string,
558): OpenCodePermissionRequestKind {
559 const normalized = permission.toLowerCase();
560 const normalizedTool = toolName?.toLowerCase() ?? "";
561 if (
562 normalized === "edit" ||
563 normalized === "write" ||
564 normalized === "patch" ||
565 normalized === "apply_patch" ||
566 normalizedTool === "edit" ||
567 normalizedTool === "write" ||
568 normalizedTool === "patch" ||
569 normalizedTool === "apply_patch"
570 ) {
571 return "file-change";
572 }
573 if (
574 normalized === "read" ||
575 normalized === "glob" ||
576 normalized === "grep" ||
577 normalized === "lsp" ||
578 normalized === "external_directory" ||
579 normalizedTool === "read" ||
580 normalizedTool.includes("glob") ||
581 normalizedTool.includes("grep") ||
582 normalizedTool.includes("search")
583 ) {
584 return "file-read";
585 }
586 return "command";
587}
588
589const OPENCODE_ALWAYS_ALLOWED_PERMISSIONS = [
590 "question",
591 "read",
592 "glob",
593 "grep",
594 "lsp",
595 "todowrite",
596 "task",
597 "skill",
598] as const;
599
600const OPENCODE_RESTRICTED_PERMISSIONS = [
601 "bash",
602 "edit",
603 "webfetch",
604 "websearch",
605 "codesearch",
606 "external_directory",
607 "doom_loop",
608] as const;
609
610/**
611 * OpenCode does not provide an OS sandbox, so permission rules are also the
612 * enforcement boundary for non-interactive policies. Read/planning tools are
613 * safe by default; edits are auto-approved only for workspace-write modes,
614 * while shell/network/external access remains gated unless policy explicitly
615 * allows it.
616 */
617export function openCodePermissionRules(
618 runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy,
619): PermissionRuleset {
620 const sandboxPolicy = recordValue(runtimePolicy, "sandboxPolicy");
621 const sandboxType = recordString(sandboxPolicy, "type");
622 const rawApprovalPolicy = runtimePolicy.approvalPolicy;
623 const approvalPolicy = nonEmptyString(rawApprovalPolicy);
624 const requiresApproval =
625 approvalPolicy === undefined
626 ? (typeof rawApprovalPolicy === "object" && rawApprovalPolicy !== null) ||
627 runtimePolicy.runtimeMode !== "full-access"
628 : approvalPolicy !== "never";
629 const externallySandboxed = sandboxType === "externalSandbox";
630 const dangerFullAccess = sandboxType === "dangerFullAccess";
631 const implicitFullAccess =
632 sandboxType === undefined && runtimePolicy.runtimeMode === "full-access";
633
634 if (!requiresApproval && (externallySandboxed || dangerFullAccess || implicitFullAccess)) {
635 return [{ permission: "*", pattern: "*", action: "allow" }];
636 }
637
638 // Task sessions initially inherit only parent deny rules. Seed explicit
639 // denies before the effective ask/allow overrides so a child is safe during
640 // the short interval before emitSubagent installs its complete policy.
641 const rules: PermissionRuleset = [
642 { permission: "*", pattern: "*", action: "deny" },
643 ...OPENCODE_RESTRICTED_PERMISSIONS.map((permission) => ({
644 permission,
645 pattern: "*",
646 action: "deny" as const,
647 })),
648 ];
649
650 if (requiresApproval) {
651 rules.push({ permission: "*", pattern: "*", action: "ask" });
652 for (const permission of OPENCODE_RESTRICTED_PERMISSIONS) {
653 rules.push({ permission, pattern: "*", action: "ask" });
654 }
655 }
656
657 rules.push(
658 ...OPENCODE_ALWAYS_ALLOWED_PERMISSIONS.map((permission) => ({
659 permission,
660 pattern: "*",
661 action: "allow" as const,
662 })),
663 );
664
665 rules.push(
666 { permission: "read", pattern: "*.env", action: requiresApproval ? "ask" : "deny" },
667 { permission: "read", pattern: "*.env.*", action: requiresApproval ? "ask" : "deny" },
668 { permission: "read", pattern: "*.env.example", action: "allow" },
669 );
670
671 if (
672 runtimePolicy.runtimeMode === "auto-accept-edits" ||
673 (!requiresApproval && sandboxType === "workspaceWrite")
674 ) {
675 rules.push({ permission: "edit", pattern: "*", action: "allow" });
676 }
677
678 if (!requiresApproval && recordValue(sandboxPolicy, "networkAccess") === true) {
679 for (const permission of ["webfetch", "websearch", "codesearch"] as const) {
680 rules.push({ permission, pattern: "*", action: "allow" });
681 }
682 }
683
684 if (!requiresApproval && sandboxType === "readOnly") {
685 const access = recordValue(sandboxPolicy, "access");
686 if (recordString(access, "type") === "fullAccess") {
687 rules.push({ permission: "external_directory", pattern: "*", action: "allow" });
688 }
689 }
690
691 if (!requiresApproval && sandboxType === "workspaceWrite") {
692 const writableRoots = recordValue(sandboxPolicy, "writableRoots");
693 if (Array.isArray(writableRoots)) {
694 for (const root of writableRoots) {
695 if (typeof root === "string" && root.trim().length > 0) {
696 rules.push({
697 permission: "external_directory",
698 pattern: `${root.replace(/\/$/, "")}/*`,
699 action: "allow",
700 });
701 }
702 }
703 }
704 }
705
706 return rules;
707}
708
709function permissionRuleEquals(
710 left: PermissionRuleset[number],
711 right: PermissionRuleset[number],
712): boolean {
713 return (
714 left.permission === right.permission &&
715 left.pattern === right.pattern &&
716 left.action === right.action
717 );
718}
719
720/**
721 * OpenCode task sessions inherit only the parent's deny/external-directory
722 * rules and then add agent-specific restrictions such as disabling nested
723 * tasks. Install the complete parent policy while retaining only rules that
724 * were added specifically for the selected child agent.
725 */
726export function openCodeChildPermissionRules(
727 runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy,
728 nativeChildRules: PermissionRuleset,
729): PermissionRuleset {
730 const parentRules = openCodePermissionRules(runtimePolicy);
731 const inheritedRules = parentRules.filter(
732 (rule) => rule.permission === "external_directory" || rule.action === "deny",
733 );
734 const childSpecificRules = nativeChildRules.filter(
735 (childRule) =>
736 !inheritedRules.some((inheritedRule) => permissionRuleEquals(childRule, inheritedRule)),
737 );
738 return [...parentRules, ...childSpecificRules];
739}
740
741/**
742 * OpenCode's fork/revert boundary is exclusive. To retain the selected app
743 * turn, address the next native user message; omitting a boundary retains the
744 * current head when the selected turn is already last.
745 */
746export function openCodeBoundaryAfterProviderTurn(
747 providerTurns: ReadonlyArray<OrchestrationV2ProviderTurn>,
748 selectedProviderTurnId: OrchestrationV2ProviderTurn["id"],
749): string | undefined {
750 const selected = providerTurns.find((turn) => turn.id === selectedProviderTurnId);
751 if (selected === undefined) return undefined;
752 return providerTurns
753 .filter((turn) => turn.ordinal > selected.ordinal)
754 .toSorted((left, right) => left.ordinal - right.ordinal)
755 .map((turn) => turn.nativeTurnRef?.nativeId)
756 .find((nativeId): nativeId is string => nativeId !== null && nativeId !== undefined);
757}
758
759function toolStatus(part: ToolPart): {
760 readonly node: OrchestrationV2ExecutionNode["status"];
761 readonly item: OrchestrationV2TurnItem["status"];
762} {
763 switch (part.state.status) {
764 case "pending":
765 return { node: "pending", item: "pending" };
766 case "running":
767 return { node: "running", item: "running" };
768 case "completed":
769 return { node: "completed", item: "completed" };
770 case "error":
771 return { node: "failed", item: "failed" };
772 }
773}
774
775function toolInput(part: ToolPart): Record<string, unknown> {
776 return part.state.input;
777}
778
779function toolOutput(part: ToolPart): string | undefined {
780 if (part.state.status === "completed") return part.state.output;
781 if (part.state.status === "error") return part.state.error;
782 return undefined;
783}
784
785function toolStartedAt(part: ToolPart, now: DateTime.Utc): DateTime.Utc {
786 return dateTimeFromEpoch(
787 part.state.status === "pending" ? undefined : part.state.time.start,
788 now,
789 );
790}
791
792function toolCompletedAt(part: ToolPart, now: DateTime.Utc): DateTime.Utc | null {
793 return part.state.status === "completed" || part.state.status === "error"
794 ? dateTimeFromEpoch(part.state.time.end, now)
795 : null;
796}
797
798function toolTitle(part: ToolPart): string | null {
799 return part.state.status === "running" || part.state.status === "completed"
800 ? (part.state.title ?? null)
801 : null;
802}
803
804function toolModel(part: ToolPart): string | null {
805 const metadata =
806 part.state.status === "running" ||
807 part.state.status === "completed" ||
808 part.state.status === "error"
809 ? part.state.metadata
810 : undefined;
811 const model = recordValue(metadata, "model");
812 const providerId = recordString(model, "providerID", "providerId");
813 const modelId = recordString(model, "modelID", "modelId", "id");
814 return providerId !== undefined && modelId !== undefined ? `${providerId}/${modelId}` : null;
815}
816
817function taskSessionId(part: ToolPart): string | null {
818 const metadata =
819 part.state.status === "running" ||
820 part.state.status === "completed" ||
821 part.state.status === "error"
822 ? part.state.metadata
823 : undefined;
824 return recordString(metadata, "sessionId", "sessionID") ?? null;
825}
826
827function makeProviderThread(input: {
828 readonly idAllocator: IdAllocator.IdAllocatorV2["Service"];
829 readonly providerInstanceId: ProviderInstanceId;
830 readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"];
831 readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"];
832 readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"];
833 readonly nativeSession: OpenCodeSession;
834 readonly forkedFrom?: OrchestrationV2ProviderThread["forkedFrom"];
835 readonly now: DateTime.Utc;
836}): OrchestrationV2ProviderThread {
837 const createdAt = dateTimeFromEpoch(input.nativeSession.time.created, input.now);
838 return {
839 id: input.idAllocator.derive.providerThread({
840 driver: OPENCODE_PROVIDER,
841 nativeThreadId: input.nativeSession.id,
842 }),
843 driver: OPENCODE_PROVIDER,
844 providerInstanceId: input.providerInstanceId,
845 providerSessionId: input.providerSessionId,
846 appThreadId: input.appThreadId,
847 ownerNodeId: input.ownerNodeId ?? null,
848 nativeThreadRef: {
849 driver: OPENCODE_PROVIDER,
850 nativeId: input.nativeSession.id,
851 strength: "strong",
852 },
853 nativeConversationHeadRef: null,
854 status: "idle",
855 firstRunOrdinal: null,
856 lastRunOrdinal: null,
857 handoffIds: [],
858 forkedFrom: input.forkedFrom ?? null,
859 createdAt,
860 updatedAt: dateTimeFromEpoch(input.nativeSession.time.updated, input.now),
861 };
862}
863
864function providerRef(nativeId: string, strength: "strong" | "weak" = "strong") {
865 return {
866 driver: OPENCODE_PROVIDER,
867 nativeId,
868 strength,
869 } satisfies OrchestrationV2ProviderRef;
870}
871
872function openCodeErrorMessage(event: Extract<OpenCodeEvent, { type: "session.error" }>): string {
873 const error = event.properties.error;
874 if (error === undefined) return "OpenCode session failed without an error payload.";
875 return recordString(error.data, "message") ?? error.name;
876}
877
878function terminalStatusForError(
879 event: Extract<OpenCodeEvent, { type: "session.error" }>,
880 turn: ActiveOpenCodeTurn,
881): TerminalTurnStatus {
882 return turn.interrupted || isMessageAbortedError(event) ? "interrupted" : "failed";
883}
884
885function isMessageAbortedError(event: Extract<OpenCodeEvent, { type: "session.error" }>): boolean {
886 return event.properties.error?.name === "MessageAbortedError";
887}
888
889function isOpenCodeNotFound(cause: unknown): boolean {
890 const seen = new Set<unknown>();
891 const queue: Array<unknown> = [cause];
892 for (let steps = 0; queue.length > 0 && steps < 32; steps += 1) {
893 const node = queue.shift();
894 if (node === null || typeof node !== "object" || seen.has(node)) {
895 continue;
896 }
897 seen.add(node);
898 const record = node as Record<string, unknown>;
899
900 const response = record.response;
901 const statuses = [
902 record.status,
903 record.statusCode,
904 response !== null && typeof response === "object"
905 ? (response as { readonly status?: unknown }).status
906 : undefined,
907 ].filter((status): status is number => typeof status === "number");
908 if (statuses.includes(404)) {
909 return true;
910 }
911 if (statuses.length > 0) {
912 continue;
913 }
914
915 const name = record.name;
916 if (typeof name === "string" && name.toLowerCase() === "notfounderror") {
917 return true;
918 }
919
920 for (const key of ["cause", "body", "error", "data"] as const) {
921 if (record[key] !== undefined) {
922 queue.push(record[key]);
923 }
924 }
925 }
926 return false;
927}
928
929function unwrapData<A>(operation: string, result: { readonly data?: A }): NonNullable<A> {
930 if (result.data === undefined) {
931 throw new OpenCodeRuntime.OpenCodeRuntimeError({
932 operation,
933 detail: `OpenCode ${operation} returned no response payload.`,
934 });
935 }
936 return result.data as NonNullable<A>;
937}
938
939export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function* (
940 options: OpenCodeAdapterV2Options,
941) {
942 const runtime = yield* OpenCodeRuntime.OpenCodeRuntime;
943 const idAllocator = yield* IdAllocator.IdAllocatorV2;
944 const host = yield* ProviderHost.ProviderHost;
945 const mcpSessions = yield* McpProviderSessions.McpProviderSessions;
946 const messageIdClock = yield* Ref.make({ epochMillis: -1, counter: 0 });
947
948 return ProviderAdapter.ProviderAdapterV2.of({
949 instanceId: options.instanceId,
950 driver: OPENCODE_PROVIDER,
951 getCapabilities: () => Effect.succeed(OpenCodeProviderCapabilitiesV2),
952 planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()),
953 openSession: Effect.fn("OpenCodeAdapterV2.openSession")(
954 function* (input: ProviderAdapter.ProviderAdapterV2OpenSessionInput) {
955 const scope = yield* Effect.scope;
956 const cwd = input.runtimePolicy.cwd ?? host.paths.cwd;
957 const connection = yield* runtime.connectToOpenCodeServer({
958 binaryPath: options.settings.binaryPath,
959 directory: cwd,
960 serverUrl: options.settings.serverUrl,
961 environment: options.environment,
962 });
963 const client = runtime.createOpenCodeSdkClient({
964 baseUrl: connection.url,
965 directory: cwd,
966 ...(connection.external && options.settings.serverPassword
967 ? { serverPassword: options.settings.serverPassword }
968 : {}),
969 });
970
971 const mcpSession = yield* mcpSessions.read(input.threadId);
972 const hasT3Mcp = mcpSession !== undefined && !connection.external;
973 const orchestrationSystemPrompt = t3OrchestrationSystemPrompt(hasT3Mcp);
974 if (hasT3Mcp) {
975 yield* OpenCodeRuntime.runOpenCodeSdk("mcp.add", () =>
976 client.mcp.add({
977 name: "t3-code",
978 config: {
979 type: "remote",
980 url: mcpSession.endpoint,
981 headers: { Authorization: mcpSession.authorizationHeader },
982 oauth: false,
983 },
984 }),
985 );
986 }
987
988 const now = yield* DateTime.now;
989 let sessionEntity: OrchestrationV2ProviderSession = {
990 id: input.providerSessionId,
991 driver: OPENCODE_PROVIDER,
992 providerInstanceId: options.instanceId,
993 status: "ready",
994 cwd,
995 model: input.modelSelection.model,
996 capabilities: OpenCodeProviderCapabilitiesV2,
997 createdAt: now,
998 updatedAt: now,
999 lastError: null,
1000 };
1001 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event, Cause.Done>();
1002 let nativeStreamFailure: OrchestrationV2ProviderFailure | null = null;
1003 const threads = new Map<string, OpenCodeThreadState>();
1004 const commandReceipts = new Map<string, Deferred.Deferred<void>>();
1005 const commandControllers = new Map<string, Set<AbortController>>();
1006 const pendingRequests = new Map<string, PendingOpenCodeRequest>();
1007 const pendingRequestsByNativeId = new Map<string, PendingOpenCodeRequest>();
1008 const subagentsByNativeItemId = new Map<string, OpenCodeSubagentContext>();
1009 const subagentsByChildSessionId = new Map<string, OpenCodeSubagentContext>();
1010 // Permission and question requests can originate from child sessions
1011 // (task subagents and their descendants). Related sessions map back
1012 // to the root thread state whose active turn owns the request; asks
1013 // arriving before the relation is known resolve it via session.get.
1014 const relatedSessionOwners = new Map<string, OpenCodeThreadState>();
1015 // Sessions of this runtime that OpenCode reports busy. A background
1016 // task child keeps running after its parent turn settles, so idle
1017 // release must not close the server under it.
1018 const busySessionIds = new Set<string>();
1019 // Requests settled before their session relation resolved: a late
1020 // routing attempt must never resurrect them. Bounded because entries
1021 // only matter for the seconds a routing retry can still be running.
1022 const settledNativeRequestIds = new Set<string>();
1023 const pendingChildRequestRoutes = new Set<string>();
1024 const rememberSettledRequest = (nativeRequestId: string) => {
1025 if (settledNativeRequestIds.size >= 2_048) settledNativeRequestIds.clear();
1026 settledNativeRequestIds.add(nativeRequestId);
1027 };
1028 const abortController = new AbortController();
1029 let closing = false;
1030 let hasConnected = false;
1031
1032 const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) =>
1033 Queue.offer(events, event).pipe(Effect.asVoid);
1034
1035 const logProtocolEvent = makeOpenCodeProtocolLogger({
1036 nativeEventLogger: options.nativeEventLogger,
1037 idAllocator,
1038 providerInstanceId: options.instanceId,
1039 providerSessionId: input.providerSessionId,
1040 threadId: input.threadId,
1041 });
1042
1043 const sdkCall = <A>(
1044 method: string,
1045 payload: unknown,
1046 call: (signal: AbortSignal) => Promise<A>,
1047 ): Effect.Effect<A, OpenCodeRuntime.OpenCodeRuntimeError> =>
1048 logProtocolEvent({
1049 direction: "outgoing",
1050 messageKind: "request",
1051 method,
1052 payload,
1053 }).pipe(
1054 Effect.andThen(OpenCodeRuntime.runOpenCodeSdk(method, call)),
1055 Effect.tap((response) =>
1056 logProtocolEvent({
1057 direction: "incoming",
1058 messageKind: "response",
1059 method,
1060 payload: sdkResponseForRawLog(response),
1061 }),
1062 ),
1063 );
1064
1065 const abortDescendants = (rootId: string) =>
1066 Effect.gen(function* () {
1067 const visited = new Set([rootId]);
1068 const semaphore = Semaphore.makeUnsafe(8);
1069 const visit = (
1070 sessionId: string,
1071 abort: boolean,
1072 ): Effect.Effect<OpenCodeRuntime.OpenCodeRuntimeError | undefined> =>
1073 Effect.gen(function* () {
1074 const abortResult = abort
1075 ? yield* sdkCall("session.abort", { sessionID: sessionId }, (signal) =>
1076 client.session.abort({ sessionID: sessionId }, { signal }),
1077 ).pipe(
1078 semaphore.withPermit,
1079 Effect.catchIf(isOpenCodeNotFound, () => Effect.void),
1080 Effect.result,
1081 )
1082 : undefined;
1083 const childrenResult = yield* sdkCall(
1084 "session.children",
1085 { sessionID: sessionId },
1086 (signal) => client.session.children({ sessionID: sessionId }, { signal }),
1087 ).pipe(
1088 semaphore.withPermit,
1089 Effect.catchIf(isOpenCodeNotFound, () => Effect.void),
1090 Effect.result,
1091 );
1092 const firstFailure =
1093 abortResult?._tag === "Failure" ? abortResult.failure : undefined;
1094 if (childrenResult._tag === "Failure")
1095 return firstFailure ?? childrenResult.failure;
1096 const fresh = (childrenResult.success?.data ?? []).filter((child) => {
1097 if (visited.has(child.id)) return false;
1098 visited.add(child.id);
1099 return true;
1100 });
1101 const failures = yield* Effect.forEach(fresh, (child) => visit(child.id, true), {
1102 concurrency: 8,
1103 });
1104 return firstFailure ?? failures.find((failure) => failure !== undefined);
1105 });
1106 const failure = yield* visit(rootId, false);
1107 if (failure) return yield* Effect.fail(failure);
1108 }).pipe(Effect.timeout("15 seconds"));
1109
1110 const updateProviderSession = (
1111 status: OrchestrationV2ProviderSession["status"],
1112 lastError: string | null = sessionEntity.lastError,
1113 ) =>
1114 Effect.gen(function* () {
1115 const updatedAt = yield* DateTime.now;
1116 sessionEntity = { ...sessionEntity, status, lastError, updatedAt };
1117 yield* emitProviderEvent({
1118 type: "provider_session.updated",
1119 driver: OPENCODE_PROVIDER,
1120 providerSession: sessionEntity,
1121 });
1122 });
1123
1124 const updateProviderThread = (
1125 state: OpenCodeThreadState,
1126 patch: Partial<OrchestrationV2ProviderThread>,
1127 ) =>
1128 Effect.gen(function* () {
1129 const updatedAt = yield* DateTime.now;
1130 state.providerThread = { ...state.providerThread, ...patch, updatedAt };
1131 yield* emitProviderEvent({
1132 type: "provider_thread.updated",
1133 driver: OPENCODE_PROVIDER,
1134 providerThread: state.providerThread,
1135 });
1136 });
1137
1138 const itemOrdinal = (turn: ActiveOpenCodeTurn, nativeItemId: string): number => {
1139 const existing = turn.itemOrdinals.get(nativeItemId);
1140 if (existing !== undefined) return existing;
1141 const ordinal = turn.nextItemOrdinal++;
1142 turn.itemOrdinals.set(nativeItemId, ordinal);
1143 return ordinal;
1144 };
1145
1146 const emitProviderTurn = (
1147 state: OpenCodeThreadState,
1148 turn: ActiveOpenCodeTurn,
1149 status: OrchestrationV2ProviderTurn["status"],
1150 completedAt: DateTime.Utc | null,
1151 ) => {
1152 const providerTurn: OrchestrationV2ProviderTurn = {
1153 ...turn.providerTurn,
1154 nativeTurnRef:
1155 turn.nativeUserMessageId === null
1156 ? turn.providerTurn.nativeTurnRef
1157 : providerRef(turn.nativeUserMessageId, "weak"),
1158 status,
1159 completedAt,
1160 ...(completedAt === null
1161 ? {}
1162 : {
1163 turnTokenUsage:
1164 turn.usage.partIds.size === 0
1165 ? {
1166 usageScope: "main_agent" as const,
1167 usageStatus: "unavailable" as const,
1168 hasSubagents: turn.usage.hasSubagents,
1169 }
1170 : {
1171 usageScope: "main_agent" as const,
1172 usageStatus:
1173 status === "completed" &&
1174 turn.usage.complete &&
1175 turn.usage.unresolvedStepsByMessageId.size === 0
1176 ? ("complete" as const)
1177 : ("partial" as const),
1178 inputTokens: turn.usage.inputTokens,
1179 cachedInputTokens: turn.usage.cachedInputTokens,
1180 cacheCreationTokens: turn.usage.cacheCreationTokens,
1181 outputTokens: turn.usage.outputTokens,
1182 reasoningTokens: turn.usage.reasoningTokens,
1183 hasSubagents: turn.usage.hasSubagents,
1184 },
1185 }),
1186 };
1187 Object.assign(turn.providerTurn, providerTurn);
1188 state.providerTurns.set(String(providerTurn.id), providerTurn);
1189 return emitProviderEvent({
1190 type: "provider_turn.updated",
1191 driver: OPENCODE_PROVIDER,
1192 threadId: turn.threadId,
1193 providerTurn,
1194 });
1195 };
1196
1197 const emitTextPart = Effect.fnUntraced(function* (
1198 state: OpenCodeThreadState,
1199 turn: ActiveOpenCodeTurn,
1200 part: Extract<OpenCodePart, { type: "text" | "reasoning" }>,
1201 forceCompleted = false,
1202 ) {
1203 if (part.type === "text" && (part.ignored === true || part.synthetic === true)) return;
1204 if (part.text.length === 0) return;
1205 const emittedAt = yield* DateTime.now;
1206 const isCompleted = forceCompleted || part.time?.end !== undefined;
1207 const startedAt = dateTimeFromEpoch(part.time?.start, emittedAt);
1208 const completedAt = isCompleted ? dateTimeFromEpoch(part.time?.end, emittedAt) : null;
1209 const nativeItemRef = providerRef(part.id);
1210 const nodeId = idAllocator.derive.nodeFromProviderItem({
1211 driver: OPENCODE_PROVIDER,
1212 nativeItemId: part.id,
1213 });
1214 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1215 driver: OPENCODE_PROVIDER,
1216 nativeItemId: part.id,
1217 });
1218 const ordinal = itemOrdinal(turn, part.id);
1219 yield* emitProviderEvent({
1220 type: "node.updated",
1221 driver: OPENCODE_PROVIDER,
1222 node: {
1223 id: nodeId,
1224 threadId: turn.threadId,
1225 runId: turn.runId,
1226 parentNodeId: turn.rootNodeId,
1227 rootNodeId: turn.rootNodeId,
1228 kind: part.type === "text" ? "assistant_message" : "reasoning",
1229 status: isCompleted ? "completed" : "running",
1230 countsForRun: false,
1231 providerThreadId: state.providerThread.id,
1232 providerTurnId: turn.providerTurnId,
1233 nativeItemRef,
1234 runtimeRequestId: null,
1235 checkpointScopeId: null,
1236 startedAt,
1237 completedAt,
1238 },
1239 });
1240 if (part.type === "text") {
1241 const messageId = idAllocator.derive.messageFromProviderItem({
1242 driver: OPENCODE_PROVIDER,
1243 nativeItemId: part.id,
1244 });
1245 const message: OrchestrationV2ConversationMessage = {
1246 createdBy: "agent",
1247 creationSource: "provider",
1248 id: messageId,
1249 threadId: turn.threadId,
1250 runId: turn.runId,
1251 nodeId,
1252 role: "assistant",
1253 text: part.text,
1254 attachments: [],
1255 streaming: !isCompleted,
1256 createdAt: startedAt,
1257 updatedAt: emittedAt,
1258 };
1259 yield* emitProviderEvent({
1260 type: "message.updated",
1261 driver: OPENCODE_PROVIDER,
1262 message,
1263 });
1264 yield* emitProviderEvent({
1265 type: "turn_item.updated",
1266 driver: OPENCODE_PROVIDER,
1267 turnItem: {
1268 id: turnItemId,
1269 threadId: turn.threadId,
1270 runId: turn.runId,
1271 nodeId,
1272 providerThreadId: state.providerThread.id,
1273 providerTurnId: turn.providerTurnId,
1274 nativeItemRef,
1275 parentItemId: null,
1276 ordinal,
1277 status: isCompleted ? "completed" : "running",
1278 title: null,
1279 startedAt,
1280 completedAt,
1281 updatedAt: emittedAt,
1282 type: "assistant_message",
1283 messageId,
1284 text: part.text,
1285 streaming: !isCompleted,
1286 },
1287 });
1288 return;
1289 }
1290 yield* emitProviderEvent({
1291 type: "turn_item.updated",
1292 driver: OPENCODE_PROVIDER,
1293 turnItem: {
1294 id: turnItemId,
1295 threadId: turn.threadId,
1296 runId: turn.runId,
1297 nodeId,
1298 providerThreadId: state.providerThread.id,
1299 providerTurnId: turn.providerTurnId,
1300 nativeItemRef,
1301 parentItemId: null,
1302 ordinal,
1303 status: isCompleted ? "completed" : "running",
1304 title: null,
1305 startedAt,
1306 completedAt,
1307 updatedAt: emittedAt,
1308 type: "reasoning",
1309 text: part.text,
1310 streaming: !isCompleted,
1311 },
1312 });
1313 });
1314
1315 const emitSubagent = Effect.fnUntraced(function* (
1316 state: OpenCodeThreadState,
1317 turn: ActiveOpenCodeTurn,
1318 part: ToolPart,
1319 ) {
1320 const now = yield* DateTime.now;
1321 const nativeItemRef = providerRef(part.id);
1322 const nodeId = idAllocator.derive.nodeFromProviderItem({
1323 driver: OPENCODE_PROVIDER,
1324 nativeItemId: part.id,
1325 });
1326 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1327 driver: OPENCODE_PROVIDER,
1328 nativeItemId: part.id,
1329 });
1330 turn.usage.hasSubagents = true;
1331 const input = toolInput(part);
1332 const prompt = recordString(input, "prompt") ?? "";
1333 const title = toolTitle(part) ?? recordString(input, "description") ?? null;
1334 let context = subagentsByNativeItemId.get(part.id);
1335 if (context === undefined) {
1336 context = {
1337 nativeItemId: part.id,
1338 nodeId,
1339 parentState: state,
1340 parentTurn: turn,
1341 prompt,
1342 title,
1343 startedAt: toolStartedAt(part, now),
1344 childSessionId: null,
1345 childThreadId: null,
1346 childProviderThreadId: null,
1347 model: null,
1348 result: null,
1349 };
1350 subagentsByNativeItemId.set(part.id, context);
1351 }
1352 context.model = toolModel(part) ?? context.model;
1353 const childSessionId = taskSessionId(part);
1354 if (childSessionId !== null && context.childSessionId === null) {
1355 context.childSessionId = childSessionId;
1356 context.childThreadId = idAllocator.derive.threadFromProviderThread({
1357 driver: OPENCODE_PROVIDER,
1358 nativeThreadId: childSessionId,
1359 });
1360 context.childProviderThreadId = idAllocator.derive.providerThread({
1361 driver: OPENCODE_PROVIDER,
1362 nativeThreadId: childSessionId,
1363 });
1364 subagentsByChildSessionId.set(childSessionId, context);
1365 relatedSessionOwners.set(childSessionId, state);
1366 const childModelSelection: ModelSelection = {
1367 instanceId: options.instanceId,
1368 model: context.model ?? turn.modelSelection.model,
1369 };
1370 const childThread = makeSubagentChildThread({
1371 parentThread: turn.appThread,
1372 childThreadId: context.childThreadId,
1373 parentNodeId: nodeId,
1374 activeProviderThreadId: context.childProviderThreadId,
1375 providerInstanceId: options.instanceId,
1376 modelSelection: childModelSelection,
1377 title: subagentThreadTitle({
1378 parentTitle: turn.appThread.title,
1379 title,
1380 prompt,
1381 ordinal: itemOrdinal(turn, part.id),
1382 }),
1383 now,
1384 createdBy: "agent",
1385 creationSource: "provider",
1386 });
1387 const childProviderThread: OrchestrationV2ProviderThread = {
1388 id: context.childProviderThreadId,
1389 driver: OPENCODE_PROVIDER,
1390 providerInstanceId: options.instanceId,
1391 providerSessionId: inputProviderSessionId,
1392 appThreadId: context.childThreadId,
1393 ownerNodeId: nodeId,
1394 nativeThreadRef: providerRef(childSessionId),
1395 nativeConversationHeadRef: null,
1396 status: "active",
1397 firstRunOrdinal: null,
1398 lastRunOrdinal: null,
1399 handoffIds: [],
1400 forkedFrom: null,
1401 createdAt: now,
1402 updatedAt: now,
1403 };
1404 const childSessionResponse = yield* sdkCall(
1405 "session.get",
1406 { sessionID: childSessionId },
1407 () => client.session.get({ sessionID: childSessionId }),
1408 );
1409 const nativeChildSession = unwrapData("session.get", childSessionResponse);
1410 const childPermission = openCodeChildPermissionRules(
1411 turn.runtimePolicy,
1412 nativeChildSession.permission ?? [],
1413 );
1414 yield* sdkCall(
1415 "session.update",
1416 { sessionID: childSessionId, permission: childPermission },
1417 () =>
1418 client.session.update({
1419 sessionID: childSessionId,
1420 permission: childPermission,
1421 }),
1422 );
1423 threads.set(childSessionId, {
1424 nativeSessionId: childSessionId,
1425 providerThread: childProviderThread,
1426 appThread: childThread,
1427 activeTurn: null,
1428 providerTurns: new Map(),
1429 runtimeRequests: new Map(),
1430 messageRoles: new Map(),
1431 userMessageIds: [],
1432 parentSubagent: context,
1433 nextChildTurnOrdinal: 1,
1434 nextAdmissionGeneration: 1,
1435 });
1436 yield* emitProviderEvent({
1437 type: "app_thread.created",
1438 driver: OPENCODE_PROVIDER,
1439 appThread: childThread,
1440 });
1441 yield* emitProviderEvent({
1442 type: "provider_thread.updated",
1443 driver: OPENCODE_PROVIDER,
1444 providerThread: childProviderThread,
1445 });
1446 }
1447 const output = toolOutput(part);
1448 if (part.state.status === "completed" && output !== undefined) context.result = output;
1449 const status = toolStatus(part);
1450 const completedAt = toolCompletedAt(part, now);
1451 const subagentStatus: OrchestrationV2Subagent["status"] =
1452 status.item === "failed"
1453 ? "failed"
1454 : status.item === "completed"
1455 ? "completed"
1456 : status.item === "pending"
1457 ? "pending"
1458 : "running";
1459 const subagent: OrchestrationV2Subagent = {
1460 id: nodeId,
1461 threadId: turn.threadId,
1462 runId: turn.runId,
1463 parentNodeId: turn.rootNodeId,
1464 origin: "provider_native",
1465 createdBy: "agent",
1466 driver: OPENCODE_PROVIDER,
1467 providerInstanceId: options.instanceId,
1468 providerThreadId: context.childProviderThreadId,
1469 childThreadId: context.childThreadId,
1470 nativeTaskRef: nativeItemRef,
1471 prompt,
1472 title,
1473 model: context.model,
1474 status: subagentStatus,
1475 result: context.result,
1476 startedAt: context.startedAt,
1477 completedAt,
1478 updatedAt: now,
1479 };
1480 yield* emitProviderEvent({
1481 type: "node.updated",
1482 driver: OPENCODE_PROVIDER,
1483 node: {
1484 id: nodeId,
1485 threadId: turn.threadId,
1486 runId: turn.runId,
1487 parentNodeId: turn.rootNodeId,
1488 rootNodeId: turn.rootNodeId,
1489 kind: "subagent",
1490 status: status.node,
1491 countsForRun: false,
1492 providerThreadId: context.childProviderThreadId ?? state.providerThread.id,
1493 providerTurnId: turn.providerTurnId,
1494 nativeItemRef,
1495 runtimeRequestId: null,
1496 checkpointScopeId: null,
1497 startedAt: context.startedAt,
1498 completedAt,
1499 },
1500 });
1501 yield* emitProviderEvent({
1502 type: "subagent.updated",
1503 driver: OPENCODE_PROVIDER,
1504 subagent,
1505 });
1506 yield* emitProviderEvent({
1507 type: "turn_item.updated",
1508 driver: OPENCODE_PROVIDER,
1509 turnItem: {
1510 id: turnItemId,
1511 threadId: turn.threadId,
1512 runId: turn.runId,
1513 nodeId,
1514 providerThreadId: state.providerThread.id,
1515 providerTurnId: turn.providerTurnId,
1516 nativeItemRef,
1517 parentItemId: null,
1518 ordinal: itemOrdinal(turn, part.id),
1519 status: status.item,
1520 title,
1521 startedAt: context.startedAt,
1522 completedAt,
1523 updatedAt: now,
1524 type: "subagent",
1525 subagentId: nodeId,
1526 origin: "provider_native",
1527 driver: OPENCODE_PROVIDER,
1528 providerInstanceId: options.instanceId,
1529 childThreadId: context.childThreadId,
1530 prompt,
1531 result: context.result,
1532 },
1533 });
1534 });
1535
1536 const emitToolPart = Effect.fnUntraced(function* (
1537 state: OpenCodeThreadState,
1538 turn: ActiveOpenCodeTurn,
1539 part: ToolPart,
1540 ) {
1541 const normalizedTool = part.tool.toLowerCase();
1542 if (normalizedTool === "task") {
1543 yield* emitSubagent(state, turn, part);
1544 return;
1545 }
1546 // question.asked carries the respondable semantic item. Projecting
1547 // the implementation tool as well would duplicate it in the UI.
1548 if (normalizedTool === "question") return;
1549 const now = yield* DateTime.now;
1550 const status = toolStatus(part);
1551 const startedAt = toolStartedAt(part, now);
1552 const completedAt = toolCompletedAt(part, now);
1553 const nativeItemRef = providerRef(part.id);
1554 const nodeId = idAllocator.derive.nodeFromProviderItem({
1555 driver: OPENCODE_PROVIDER,
1556 nativeItemId: part.id,
1557 });
1558 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1559 driver: OPENCODE_PROVIDER,
1560 nativeItemId: part.id,
1561 });
1562 const base = {
1563 id: turnItemId,
1564 threadId: turn.threadId,
1565 runId: turn.runId,
1566 nodeId,
1567 providerThreadId: state.providerThread.id,
1568 providerTurnId: turn.providerTurnId,
1569 nativeItemRef,
1570 parentItemId: null,
1571 ordinal: itemOrdinal(turn, part.id),
1572 status: status.item,
1573 title: toolTitle(part),
1574 startedAt,
1575 completedAt,
1576 updatedAt: now,
1577 } satisfies Pick<
1578 OrchestrationV2TurnItem,
1579 | "id"
1580 | "threadId"
1581 | "runId"
1582 | "nodeId"
1583 | "providerThreadId"
1584 | "providerTurnId"
1585 | "nativeItemRef"
1586 | "parentItemId"
1587 | "ordinal"
1588 | "status"
1589 | "title"
1590 | "startedAt"
1591 | "completedAt"
1592 | "updatedAt"
1593 >;
1594 const input = toolInput(part);
1595 const output = toolOutput(part);
1596 const isNativeTool = part.tool === "code_search" || part.tool === "apply_patch";
1597 if (!isNativeTool && part.tool.includes("_") && turn.mcpServerNames === undefined) {
1598 const serverNames = yield* OpenCodeRuntime.runOpenCodeSdk("mcp.status", (signal) =>
1599 client.mcp.status(undefined, { signal, throwOnError: true }),
1600 ).pipe(
1601 Effect.timeout("1 second"),
1602 Effect.map((response) => Object.keys(response.data ?? {})),
1603 Effect.catch(() => Effect.succeed(undefined)),
1604 );
1605 if (serverNames !== undefined) turn.mcpServerNames = serverNames;
1606 }
1607 const matchingServers = turn.mcpServerNames?.filter(
1608 (name) =>
1609 !isNativeTool && part.tool.startsWith(`${name.replace(/[^a-zA-Z0-9_-]/g, "_")}_`),
1610 );
1611 const serverName = matchingServers?.length === 1 ? matchingServers[0] : undefined;
1612 const presentation =
1613 serverName === undefined
1614 ? {}
1615 : mcpToolPresentation({
1616 serverName,
1617 toolName: part.tool.slice(serverName.replace(/[^a-zA-Z0-9_-]/g, "_").length + 1),
1618 title: toolTitle(part) === part.tool ? undefined : toolTitle(part),
1619 });
1620 const turnItem: OrchestrationV2TurnItem = matchingServers?.length
1621 ? {
1622 ...base,
1623 type: "dynamic_tool",
1624 ...presentation,
1625 toolName: part.tool,
1626 input,
1627 ...(output === undefined ? {} : { output }),
1628 }
1629 : openCodeToolTurnItem(base, {
1630 name: part.tool,
1631 input,
1632 output,
1633 completedMetadata:
1634 part.state.status === "completed" ? part.state.metadata : undefined,
1635 });
1636 yield* emitProviderEvent({
1637 type: "node.updated",
1638 driver: OPENCODE_PROVIDER,
1639 node: {
1640 id: nodeId,
1641 threadId: turn.threadId,
1642 runId: turn.runId,
1643 parentNodeId: turn.rootNodeId,
1644 rootNodeId: turn.rootNodeId,
1645 kind: "tool_call",
1646 status: status.node,
1647 countsForRun: false,
1648 providerThreadId: state.providerThread.id,
1649 providerTurnId: turn.providerTurnId,
1650 nativeItemRef,
1651 runtimeRequestId: null,
1652 checkpointScopeId: null,
1653 startedAt,
1654 completedAt,
1655 },
1656 });
1657 yield* emitProviderEvent({
1658 type: "turn_item.updated",
1659 driver: OPENCODE_PROVIDER,
1660 turnItem,
1661 });
1662 });
1663
1664 const emitTodo = Effect.fnUntraced(function* (
1665 state: OpenCodeThreadState,
1666 turn: ActiveOpenCodeTurn,
1667 todos: ReadonlyArray<OpenCodeTodo>,
1668 ) {
1669 const now = yield* DateTime.now;
1670 if (turn.planId === null) {
1671 turn.planId = yield* idAllocator.allocate.plan({
1672 threadId: turn.threadId,
1673 ...(turn.runId === null ? {} : { runId: turn.runId }),
1674 driver: OPENCODE_PROVIDER,
1675 });
1676 }
1677 const planId = turn.planId;
1678 const nativeItemId = `${state.nativeSessionId}:todo:${turn.providerTurnId}`;
1679 const nodeId = idAllocator.derive.nodeFromProviderItem({
1680 driver: OPENCODE_PROVIDER,
1681 nativeItemId,
1682 });
1683 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1684 driver: OPENCODE_PROVIDER,
1685 nativeItemId,
1686 });
1687 const steps: Array<OrchestrationV2PlanStep> = todos.map((todo, index) => ({
1688 id: `${nativeItemId}:${index + 1}`,
1689 text: todo.content.trim() || `Todo ${index + 1}`,
1690 status:
1691 todo.status === "completed"
1692 ? "completed"
1693 : todo.status === "in_progress"
1694 ? "running"
1695 : "pending",
1696 }));
1697 const completed = steps.length > 0 && steps.every((step) => step.status === "completed");
1698 yield* emitProviderEvent({
1699 type: "node.updated",
1700 driver: OPENCODE_PROVIDER,
1701 node: {
1702 id: nodeId,
1703 threadId: turn.threadId,
1704 runId: turn.runId,
1705 parentNodeId: turn.rootNodeId,
1706 rootNodeId: turn.rootNodeId,
1707 kind: "todo_list",
1708 status: completed ? "completed" : "running",
1709 countsForRun: false,
1710 providerThreadId: state.providerThread.id,
1711 providerTurnId: turn.providerTurnId,
1712 nativeItemRef: providerRef(nativeItemId, "weak"),
1713 runtimeRequestId: null,
1714 checkpointScopeId: null,
1715 startedAt: turn.startedAt,
1716 completedAt: completed ? now : null,
1717 },
1718 });
1719 yield* emitProviderEvent({
1720 type: "plan.updated",
1721 driver: OPENCODE_PROVIDER,
1722 plan: {
1723 id: planId,
1724 threadId: turn.threadId,
1725 runId: turn.runId,
1726 nodeId,
1727 status: completed ? "completed" : "active",
1728 kind: "todo_list",
1729 steps,
1730 },
1731 });
1732 yield* emitProviderEvent({
1733 type: "turn_item.updated",
1734 driver: OPENCODE_PROVIDER,
1735 turnItem: {
1736 id: turnItemId,
1737 threadId: turn.threadId,
1738 runId: turn.runId,
1739 nodeId,
1740 providerThreadId: state.providerThread.id,
1741 providerTurnId: turn.providerTurnId,
1742 nativeItemRef: providerRef(nativeItemId, "weak"),
1743 parentItemId: null,
1744 ordinal: itemOrdinal(turn, nativeItemId),
1745 status: completed ? "completed" : "running",
1746 title: "Todo list",
1747 startedAt: turn.startedAt,
1748 completedAt: completed ? now : null,
1749 updatedAt: now,
1750 type: "todo_list",
1751 planId,
1752 steps,
1753 },
1754 });
1755 });
1756
1757 const requestQuestions = (request: QuestionRequest) =>
1758 request.questions.map((question, index) => ({
1759 id: OpenCodeRuntime.openCodeQuestionId(index, question),
1760 header: question.header.trim() || `Question ${index + 1}`,
1761 question: question.question.trim() || question.header.trim() || `Question ${index + 1}`,
1762 options: question.options.map((option) => ({
1763 label: option.label.trim() || "Option",
1764 description: option.description.trim() || option.label.trim() || "Option",
1765 })),
1766 }));
1767
1768 const runtimeRequestTurnItem = (
1769 pending: PendingOpenCodeRequest,
1770 status: OrchestrationV2TurnItem["status"],
1771 completedAt: DateTime.Utc | null,
1772 updatedAt: DateTime.Utc,
1773 ): OrchestrationV2TurnItem => {
1774 const base = {
1775 id: pending.turnItemId,
1776 threadId: pending.turn.threadId,
1777 runId: pending.turn.runId,
1778 nodeId: pending.nodeId,
1779 providerThreadId: pending.state.providerThread.id,
1780 providerTurnId: pending.turn.providerTurnId,
1781 nativeItemRef: providerRef(pending.nativeRequestId),
1782 parentItemId: null,
1783 ordinal: itemOrdinal(pending.turn, pending.nativeRequestId),
1784 status,
1785 startedAt: pending.createdAt,
1786 completedAt,
1787 updatedAt,
1788 };
1789 if (pending.question !== undefined) {
1790 return {
1791 ...base,
1792 title: "User input",
1793 type: "user_input_request",
1794 requestId: pending.requestId,
1795 questions: requestQuestions(pending.question),
1796 };
1797 }
1798 const permission = pending.permission;
1799 if (permission === undefined) {
1800 throw protocolError(`OpenCode request ${pending.requestId} has no native payload`);
1801 }
1802 return {
1803 ...base,
1804 title: permission.permission,
1805 type: "approval_request",
1806 requestId: pending.requestId,
1807 requestKind: pending.requestKind === "user_input" ? "command" : pending.requestKind,
1808 prompt:
1809 permission.patterns.length === 0
1810 ? permission.permission
1811 : permission.patterns.join("\n"),
1812 };
1813 };
1814
1815 const emitRuntimeRequest = Effect.fnUntraced(function* (
1816 owner: OpenCodeRequestOwner,
1817 nativeRequestId: string,
1818 request:
1819 | { readonly type: "permission"; readonly value: PermissionRequest }
1820 | { readonly type: "question"; readonly value: QuestionRequest },
1821 ) {
1822 if (pendingRequestsByNativeId.has(nativeRequestId)) return;
1823 const { state, turn, subagent } = owner;
1824 const now = yield* DateTime.now;
1825 const requestId = yield* idAllocator.allocate.runtimeRequest({
1826 driver: OPENCODE_PROVIDER,
1827 providerTurnId: turn.providerTurnId,
1828 nativeRequestId,
1829 });
1830 const nodeId = idAllocator.derive.approvalNode({ requestId });
1831 const turnItemId = idAllocator.derive.approvalTurnItem({ requestId });
1832 // The tool call belongs to the asking session's turn, not the owner's.
1833 const permissionToolName =
1834 request.type === "permission" && request.value.tool !== undefined
1835 ? threads
1836 .get(request.value.sessionID)
1837 ?.activeTurn?.toolNamesByCallId.get(request.value.tool.callID)
1838 : undefined;
1839 const permissionRequestKind =
1840 request.type === "permission"
1841 ? openCodePermissionRequestKind(request.value.permission, permissionToolName)
1842 : undefined;
1843 const requestKind: OrchestrationV2RuntimeRequest["kind"] =
1844 permissionRequestKind ?? "user_input";
1845 const pending: PendingOpenCodeRequest = {
1846 requestId,
1847 nativeRequestId,
1848 nativeSessionId: request.value.sessionID,
1849 turn,
1850 state,
1851 nodeId,
1852 parentNodeId: subagent?.nodeId ?? turn.rootNodeId,
1853 turnItemId,
1854 requestKind,
1855 createdAt: now,
1856 ...(request.type === "permission"
1857 ? { permission: request.value }
1858 : { question: request.value }),
1859 };
1860 pendingRequests.set(String(requestId), pending);
1861 pendingRequestsByNativeId.set(nativeRequestId, pending);
1862 const runtimeRequest: OrchestrationV2RuntimeRequest = {
1863 id: requestId,
1864 nodeId,
1865 providerTurnId: turn.providerTurnId,
1866 nativeRequestRef: providerRef(nativeRequestId),
1867 kind: requestKind,
1868 status: "pending",
1869 responseCapability: {
1870 type: "live",
1871 providerSessionId: inputProviderSessionId,
1872 },
1873 createdAt: now,
1874 resolvedAt: null,
1875 };
1876 state.runtimeRequests.set(String(requestId), runtimeRequest);
1877 yield* emitProviderEvent({
1878 type: "node.updated",
1879 driver: OPENCODE_PROVIDER,
1880 node: {
1881 id: nodeId,
1882 threadId: turn.threadId,
1883 runId: turn.runId,
1884 parentNodeId: pending.parentNodeId,
1885 rootNodeId: turn.rootNodeId,
1886 kind: request.type === "question" ? "user_input_request" : "approval_request",
1887 status: "waiting",
1888 countsForRun: false,
1889 providerThreadId: state.providerThread.id,
1890 providerTurnId: turn.providerTurnId,
1891 nativeItemRef: providerRef(nativeRequestId),
1892 runtimeRequestId: requestId,
1893 checkpointScopeId: null,
1894 startedAt: now,
1895 completedAt: null,
1896 },
1897 });
1898 yield* emitProviderEvent({
1899 type: "runtime_request.updated",
1900 driver: OPENCODE_PROVIDER,
1901 threadId: turn.threadId,
1902 runtimeRequest,
1903 });
1904 yield* emitProviderEvent({
1905 type: "turn_item.updated",
1906 driver: OPENCODE_PROVIDER,
1907 turnItem: runtimeRequestTurnItem(pending, "waiting", null, now),
1908 });
1909 yield* updateProviderSession("waiting", null);
1910 });
1911
1912 const resolveRuntimeRequest = Effect.fnUntraced(function* (
1913 nativeRequestId: string,
1914 status: "resolved" | "cancelled",
1915 ) {
1916 const pending = pendingRequestsByNativeId.get(nativeRequestId);
1917 if (pending === undefined) return;
1918 const now = yield* DateTime.now;
1919 const current = pending.state.runtimeRequests.get(String(pending.requestId));
1920 if (current !== undefined) {
1921 const resolved: OrchestrationV2RuntimeRequest = {
1922 ...current,
1923 status,
1924 resolvedAt: now,
1925 };
1926 pending.state.runtimeRequests.set(String(pending.requestId), resolved);
1927 yield* emitProviderEvent({
1928 type: "runtime_request.updated",
1929 driver: OPENCODE_PROVIDER,
1930 threadId: pending.turn.threadId,
1931 runtimeRequest: resolved,
1932 });
1933 }
1934 yield* emitProviderEvent({
1935 type: "node.updated",
1936 driver: OPENCODE_PROVIDER,
1937 node: {
1938 id: pending.nodeId,
1939 threadId: pending.turn.threadId,
1940 runId: pending.turn.runId,
1941 parentNodeId: pending.parentNodeId,
1942 rootNodeId: pending.turn.rootNodeId,
1943 kind: pending.question === undefined ? "approval_request" : "user_input_request",
1944 status: status === "resolved" ? "completed" : "cancelled",
1945 countsForRun: false,
1946 providerThreadId: pending.state.providerThread.id,
1947 providerTurnId: pending.turn.providerTurnId,
1948 nativeItemRef: providerRef(nativeRequestId),
1949 runtimeRequestId: pending.requestId,
1950 checkpointScopeId: null,
1951 startedAt: pending.createdAt,
1952 completedAt: now,
1953 },
1954 });
1955 yield* emitProviderEvent({
1956 type: "turn_item.updated",
1957 driver: OPENCODE_PROVIDER,
1958 turnItem: runtimeRequestTurnItem(
1959 pending,
1960 status === "resolved" ? "completed" : "cancelled",
1961 now,
1962 now,
1963 ),
1964 });
1965 pendingRequests.delete(String(pending.requestId));
1966 pendingRequestsByNativeId.delete(nativeRequestId);
1967 const hasOtherPending = Array.from(pendingRequests.values()).some(
1968 (candidate) => candidate.turn.isRoot,
1969 );
1970 if (!hasOtherPending) yield* updateProviderSession("running", null);
1971 });
1972
1973 /** Resolve the thread state a session belongs to: its own, or for a
1974 * child not registered yet, the nearest known ancestor found by walking
1975 * the native parent chain. Registers every hop so later requests from
1976 * the same child resolve without another lookup. */
1977 const resolveSessionOwner = Effect.fnUntraced(function* (sessionId: string) {
1978 const known = threads.get(sessionId) ?? relatedSessionOwners.get(sessionId);
1979 if (known !== undefined) return known;
1980 let cursor = sessionId;
1981 const hops: string[] = [];
1982 for (let depth = 0; depth < 5; depth += 1) {
1983 const response = yield* sdkCall("session.get", { sessionID: cursor }, () =>
1984 client.session.get({ sessionID: cursor }),
1985 ).pipe(Effect.option);
1986 const info = Option.getOrUndefined(response)?.data;
1987 const parentId = info?.parentID;
1988 if (parentId === undefined) return undefined;
1989 hops.push(cursor);
1990 const owner = threads.get(parentId) ?? relatedSessionOwners.get(parentId);
1991 if (owner !== undefined) {
1992 for (const hop of hops) relatedSessionOwners.set(hop, owner);
1993 return owner;
1994 }
1995 cursor = parentId;
1996 }
1997 return undefined;
1998 });
1999
2000 /** Every request is asked on the top-level thread and its active turn,
2001 * under the subagent that leads to the asking session, because native
2002 * subagent threads are hidden from the sidebar. A child's request can
2003 * arrive before the task part or session.created event that reveals
2004 * its relation to a thread. The first resolution attempt runs inline
2005 * (the replayable path); if the relation or the owning turn is not
2006 * established yet, a short forked backoff keeps trying instead of
2007 * dropping the request. */
2008 const routeRuntimeRequest = Effect.fnUntraced(function* (
2009 nativeRequestId: string,
2010 sessionId: string,
2011 request:
2012 | { readonly type: "permission"; readonly value: PermissionRequest }
2013 | { readonly type: "question"; readonly value: QuestionRequest },
2014 ) {
2015 if (pendingChildRequestRoutes.has(nativeRequestId)) return;
2016 const attempt = Effect.gen(function* () {
2017 if (
2018 settledNativeRequestIds.has(nativeRequestId) ||
2019 pendingRequestsByNativeId.has(nativeRequestId)
2020 ) {
2021 return true;
2022 }
2023 const state = yield* resolveSessionOwner(sessionId);
2024 const owner = state === undefined ? undefined : topLevelRequestOwner(state);
2025 if (owner === undefined) return false;
2026 yield* emitRuntimeRequest(owner, nativeRequestId, request);
2027 return true;
2028 });
2029 if (yield* attempt) return;
2030 pendingChildRequestRoutes.add(nativeRequestId);
2031 yield* Effect.gen(function* () {
2032 for (let retry = 0; retry < 5; retry += 1) {
2033 yield* Effect.sleep(Duration.millis(Math.min(200 * 2 ** retry, 2_000)));
2034 if (yield* attempt) return;
2035 }
2036 }).pipe(
2037 Effect.ensuring(Effect.sync(() => pendingChildRequestRoutes.delete(nativeRequestId))),
2038 Effect.forkIn(scope),
2039 );
2040 });
2041
2042 const finalizeTurn = Effect.fnUntraced(function* (
2043 state: OpenCodeThreadState,
2044 turn: ActiveOpenCodeTurn,
2045 status: TerminalTurnStatus,
2046 terminal?: {
2047 readonly failure?: OrchestrationV2ProviderFailure;
2048 readonly threadDisposition?: "reusable" | "broken";
2049 },
2050 ) {
2051 if (turn.finalized) return;
2052 if (nativeStreamFailure !== null) {
2053 status = "failed";
2054 terminal = { failure: nativeStreamFailure, threadDisposition: "broken" };
2055 }
2056 turn.finalized = true;
2057 const completedAt = yield* DateTime.now;
2058 for (const part of turn.parts.values()) {
2059 if (part.type === "text" || part.type === "reasoning") {
2060 yield* emitTextPart(state, turn, part, true);
2061 }
2062 }
2063 for (const pending of Array.from(pendingRequests.values())) {
2064 if (
2065 pending.turn.providerTurnId === turn.providerTurnId ||
2066 pending.nativeSessionId === state.nativeSessionId
2067 ) {
2068 yield* resolveRuntimeRequest(pending.nativeRequestId, "cancelled");
2069 }
2070 }
2071 yield* emitProviderTurn(state, turn, status, completedAt);
2072 const threadDisposition = terminal?.threadDisposition ?? "reusable";
2073 yield* updateProviderThread(state, {
2074 status: turn.isRoot ? "active" : threadDisposition === "broken" ? "error" : "idle",
2075 nativeConversationHeadRef:
2076 turn.nativeUserMessageId === null
2077 ? state.providerThread.nativeConversationHeadRef
2078 : providerRef(turn.nativeUserMessageId, "weak"),
2079 });
2080 state.activeTurn = null;
2081 if (!turn.isRoot) {
2082 yield* emitProviderEvent({
2083 type: "node.updated",
2084 driver: OPENCODE_PROVIDER,
2085 node: {
2086 id: turn.rootNodeId,
2087 threadId: turn.threadId,
2088 runId: null,
2089 parentNodeId: null,
2090 rootNodeId: turn.rootNodeId,
2091 kind: "root_turn",
2092 status,
2093 countsForRun: false,
2094 providerThreadId: state.providerThread.id,
2095 providerTurnId: turn.providerTurnId,
2096 nativeItemRef: providerRef(state.nativeSessionId),
2097 runtimeRequestId: null,
2098 checkpointScopeId: null,
2099 startedAt: turn.startedAt,
2100 completedAt,
2101 },
2102 });
2103 return;
2104 }
2105 const anotherTurnIsActive = Array.from(threads.values()).some(
2106 (candidate) => candidate.activeTurn?.isRoot === true,
2107 );
2108 yield* updateProviderSession(
2109 anotherTurnIsActive ? "running" : status === "failed" ? "error" : "ready",
2110 status === "failed" ? sessionEntity.lastError : null,
2111 );
2112 yield* emitProviderEvent(
2113 status === "failed"
2114 ? {
2115 type: "turn.terminal",
2116 driver: OPENCODE_PROVIDER,
2117 providerThreadId: turn.providerThreadId,
2118 providerTurnId: turn.providerTurnId,
2119 runOrdinal: turn.runOrdinal,
2120 failureItemOrdinal: itemOrdinal(turn, `terminal-failure:${turn.providerTurnId}`),
2121 status,
2122 failure:
2123 terminal?.failure ??
2124 makeProviderFailure({
2125 message: sessionEntity.lastError ?? undefined,
2126 class: "provider_error",
2127 }),
2128 threadDisposition,
2129 }
2130 : {
2131 type: "turn.terminal",
2132 driver: OPENCODE_PROVIDER,
2133 providerThreadId: turn.providerThreadId,
2134 providerTurnId: turn.providerTurnId,
2135 runOrdinal: turn.runOrdinal,
2136 status,
2137 failure: null,
2138 threadDisposition,
2139 },
2140 );
2141 });
2142
2143 const promptAdmissionIsCurrent = (
2144 state: OpenCodeThreadState,
2145 turn: ActiveOpenCodeTurn,
2146 generation: number,
2147 ) =>
2148 state.activeTurn === turn &&
2149 !turn.finalized &&
2150 !turn.interrupted &&
2151 turn.admissionPending &&
2152 turn.admissionGeneration === generation;
2153
2154 const runPromptAdmissionReconciliation = Effect.fnUntraced(function* (
2155 state: OpenCodeThreadState,
2156 turn: ActiveOpenCodeTurn,
2157 generation: number,
2158 ) {
2159 while (promptAdmissionIsCurrent(state, turn, generation)) {
2160 const status = yield* reconcileOpenCodePromptAdmissionStatus(
2161 turn,
2162 generation,
2163 sdkCall("session.status", { sessionID: state.nativeSessionId, generation }, () =>
2164 client.session.status(),
2165 ).pipe(
2166 Effect.match({
2167 onFailure: () => "unknown" as const,
2168 onSuccess: (response) => {
2169 const statuses = unwrapData("session.status", response);
2170 const sessionStatus = statuses[state.nativeSessionId];
2171 return sessionStatus === undefined || sessionStatus.type === "idle"
2172 ? ("idle" as const)
2173 : ("busy" as const);
2174 },
2175 }),
2176 ),
2177 );
2178 if (
2179 state.activeTurn !== turn ||
2180 status === "stale" ||
2181 turn.finalized ||
2182 turn.interrupted ||
2183 turn.admissionGeneration !== generation
2184 ) {
2185 return;
2186 }
2187 if (status === "idle") {
2188 yield* finalizeTurn(state, turn, "completed");
2189 return;
2190 }
2191 if (status === "busy") return;
2192 yield* Effect.sleep("250 millis");
2193 }
2194 });
2195
2196 const reconcilePromptAdmission = Effect.fnUntraced(function* (
2197 state: OpenCodeThreadState,
2198 turn: ActiveOpenCodeTurn,
2199 ) {
2200 const generation = turn.admissionGeneration;
2201 if (
2202 !promptAdmissionIsCurrent(state, turn, generation) ||
2203 turn.admissionReconciliationGeneration === generation
2204 ) {
2205 return;
2206 }
2207 turn.admissionReconciliationGeneration = generation;
2208 yield* runPromptAdmissionReconciliation(state, turn, generation).pipe(
2209 Effect.ensuring(
2210 Effect.sync(() => {
2211 if (turn.admissionReconciliationGeneration === generation) {
2212 turn.admissionReconciliationGeneration = null;
2213 }
2214 }),
2215 ),
2216 Effect.forkIn(scope),
2217 );
2218 });
2219
2220 const createChildTurn = Effect.fnUntraced(function* (
2221 state: OpenCodeThreadState,
2222 message: Extract<OpenCodeMessage, { role: "user" }>,
2223 ) {
2224 if (state.appThread === null || state.parentSubagent === null) return null;
2225 const now = yield* DateTime.now;
2226 const startedAt = dateTimeFromEpoch(message.time.created, now);
2227 const rootNodeId = idAllocator.derive.nodeFromProviderItem({
2228 driver: OPENCODE_PROVIDER,
2229 nativeItemId: `${state.nativeSessionId}:root:${message.id}`,
2230 });
2231 const providerTurnId = idAllocator.derive.providerTurn({
2232 driver: OPENCODE_PROVIDER,
2233 nativeTurnId: message.id,
2234 });
2235 const providerTurn: OrchestrationV2ProviderTurn = {
2236 id: providerTurnId,
2237 providerThreadId: state.providerThread.id,
2238 nodeId: rootNodeId,
2239 runAttemptId: null,
2240 nativeTurnRef: providerRef(message.id, "weak"),
2241 ordinal: state.nextChildTurnOrdinal++,
2242 status: "running",
2243 startedAt,
2244 completedAt: null,
2245 };
2246 const turn: ActiveOpenCodeTurn = {
2247 isRoot: false,
2248 threadId: state.appThread.id,
2249 runId: null,
2250 rootNodeId,
2251 appThread: state.appThread,
2252 modelSelection: state.appThread.modelSelection,
2253 runtimePolicy: state.parentSubagent.parentTurn.runtimePolicy,
2254 providerTurnId,
2255 providerThreadId: providerTurn.providerThreadId,
2256 providerTurnOrdinal: providerTurn.ordinal,
2257 runOrdinal: state.parentSubagent.parentTurn.runOrdinal,
2258 runAttemptId: null,
2259 startedAt,
2260 itemOrdinals: new Map(),
2261 usage: makeOpenCodeTurnTokenUsageAccumulator(),
2262 parts: new Map(),
2263 partIdsByMessage: new Map(),
2264 toolNamesByCallId: new Map(),
2265 providerTurn,
2266 nextItemOrdinal: 1,
2267 nativeUserMessageId: message.id,
2268 admissionMessageId: null,
2269 interrupted: false,
2270 finalized: false,
2271 planId: null,
2272 admissionGeneration: 0,
2273 admissionReconciliationGeneration: null,
2274 admissionPending: false,
2275 admissionAccepted: true,
2276 admissionMessageObserved: true,
2277 idleDuringAdmission: false,
2278 admissionSettled: Deferred.makeUnsafe<void>(),
2279 admissionAbortController: null,
2280 };
2281 state.activeTurn = turn;
2282 state.providerTurns.set(String(providerTurnId), providerTurn);
2283 yield* emitProviderEvent({
2284 type: "node.updated",
2285 driver: OPENCODE_PROVIDER,
2286 node: {
2287 id: rootNodeId,
2288 threadId: turn.threadId,
2289 runId: null,
2290 parentNodeId: null,
2291 rootNodeId,
2292 kind: "root_turn",
2293 status: "running",
2294 countsForRun: false,
2295 providerThreadId: state.providerThread.id,
2296 providerTurnId,
2297 nativeItemRef: providerRef(message.id, "weak"),
2298 runtimeRequestId: null,
2299 checkpointScopeId: null,
2300 startedAt,
2301 completedAt: null,
2302 },
2303 });
2304 yield* emitProviderTurn(state, turn, "running", null);
2305 return turn;
2306 });
2307
2308 const projectChildUserPart = Effect.fnUntraced(function* (
2309 state: OpenCodeThreadState,
2310 turn: ActiveOpenCodeTurn,
2311 part: Extract<OpenCodePart, { type: "text" }>,
2312 ) {
2313 const now = yield* DateTime.now;
2314 const messageId = idAllocator.derive.messageFromProviderItem({
2315 driver: OPENCODE_PROVIDER,
2316 nativeItemId: part.messageID,
2317 });
2318 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
2319 driver: OPENCODE_PROVIDER,
2320 nativeItemId: part.messageID,
2321 });
2322 const projected: OrchestrationV2ConversationMessage = {
2323 createdBy: "agent",
2324 creationSource: "provider",
2325 id: messageId,
2326 threadId: turn.threadId,
2327 runId: null,
2328 nodeId: turn.rootNodeId,
2329 role: "user",
2330 text: part.text,
2331 attachments: [],
2332 streaming: false,
2333 createdAt: turn.startedAt,
2334 updatedAt: now,
2335 };
2336 yield* emitProviderEvent({
2337 type: "message.updated",
2338 driver: OPENCODE_PROVIDER,
2339 message: projected,
2340 });
2341 yield* emitProviderEvent({
2342 type: "turn_item.updated",
2343 driver: OPENCODE_PROVIDER,
2344 turnItem: {
2345 createdBy: "agent",
2346 creationSource: "provider",
2347 id: turnItemId,
2348 threadId: turn.threadId,
2349 runId: null,
2350 nodeId: turn.rootNodeId,
2351 providerThreadId: state.providerThread.id,
2352 providerTurnId: turn.providerTurnId,
2353 nativeItemRef: providerRef(part.messageID),
2354 parentItemId: null,
2355 ordinal: itemOrdinal(turn, part.messageID),
2356 status: "completed",
2357 title: null,
2358 startedAt: projected.createdAt,
2359 completedAt: now,
2360 updatedAt: now,
2361 type: "user_message",
2362 messageId,
2363 inputIntent: "turn_start",
2364 text: part.text,
2365 attachments: [],
2366 },
2367 });
2368 });
2369
2370 const emitCompactionItem = Effect.fn("OpenCodeAdapterV2.emitCompactionItem")(function* (
2371 state: OpenCodeThreadState,
2372 turn: ActiveOpenCodeTurn,
2373 ) {
2374 const nativeItemId = `${turn.providerTurnId}:compaction`;
2375 if (turn.itemOrdinals.has(nativeItemId)) return;
2376 const now = yield* DateTime.now;
2377 yield* emitProviderEvent({
2378 type: "turn_item.updated",
2379 driver: OPENCODE_PROVIDER,
2380 turnItem: {
2381 id: idAllocator.derive.turnItemFromProviderItem({
2382 driver: OPENCODE_PROVIDER,
2383 nativeItemId,
2384 }),
2385 threadId: turn.threadId,
2386 runId: turn.runId,
2387 nodeId: turn.rootNodeId,
2388 providerThreadId: state.providerThread.id,
2389 providerTurnId: turn.providerTurnId,
2390 nativeItemRef: providerRef(nativeItemId, "weak"),
2391 parentItemId: null,
2392 ordinal: itemOrdinal(turn, nativeItemId),
2393 type: "compaction",
2394 driver: OPENCODE_PROVIDER,
2395 status: "completed",
2396 title: "Context compacted",
2397 startedAt: turn.startedAt,
2398 completedAt: now,
2399 updatedAt: now,
2400 },
2401 });
2402 });
2403
2404 const handleMessageUpdated = Effect.fnUntraced(function* (
2405 event: Extract<OpenCodeEvent, { type: "message.updated" }>,
2406 ) {
2407 const state = threads.get(event.properties.sessionID);
2408 if (state === undefined) return;
2409 const message = event.properties.info;
2410 state.messageRoles.set(message.id, message.role);
2411 if (message.role === "assistant") {
2412 const usage = state.activeTurn?.usage;
2413 if (usage === undefined) return;
2414 const prior = usage.assistantOwnershipByMessageId.get(message.id);
2415 const ownership =
2416 prior !== undefined && prior !== "unknown"
2417 ? prior
2418 : !message.parentID
2419 ? "unknown"
2420 : usage.promptMessageIds.has(message.parentID)
2421 ? "owned"
2422 : "other";
2423 usage.assistantOwnershipByMessageId.set(message.id, ownership);
2424 if (ownership !== "unknown") {
2425 if (ownership === "owned") {
2426 for (const step of usage.unresolvedStepsByMessageId.get(message.id)?.values() ??
2427 []) {
2428 accumulateOpenCodeStepUsage(usage, step);
2429 }
2430 }
2431 usage.unresolvedStepsByMessageId.delete(message.id);
2432 }
2433 return;
2434 }
2435 const commandReceipt = commandReceipts.get(message.id);
2436 if (commandReceipt) yield* Deferred.succeed(commandReceipt, undefined);
2437 const isNewUserMessage = !state.userMessageIds.includes(message.id);
2438 if (isNewUserMessage) state.userMessageIds.push(message.id);
2439 let turn = state.activeTurn;
2440 if (turn === null && state.parentSubagent !== null && isNewUserMessage) {
2441 turn = yield* createChildTurn(state, message);
2442 }
2443 const matchesAdmission =
2444 turn !== null &&
2445 (turn.admissionMessageId === null || turn.admissionMessageId === message.id);
2446 if (turn !== null && matchesAdmission) turn.usage.promptMessageIds.add(message.id);
2447 if (turn !== null && matchesAdmission && turn.nativeUserMessageId === null) {
2448 turn.nativeUserMessageId = message.id;
2449 yield* emitProviderTurn(state, turn, "running", null);
2450 }
2451 if (turn !== null && matchesAdmission && turn.admissionPending) {
2452 if (advanceOpenCodePromptAdmission(turn, "user-message") === "reconcile-idle") {
2453 yield* reconcilePromptAdmission(state, turn);
2454 }
2455 }
2456 });
2457
2458 const handlePartUpdated = Effect.fnUntraced(function* (
2459 event: Extract<OpenCodeEvent, { type: "message.part.updated" }>,
2460 ) {
2461 const part = event.properties.part;
2462 const state = threads.get(part.sessionID);
2463 const turn = state?.activeTurn;
2464 if (state === undefined || turn === null || turn === undefined || turn.finalized) return;
2465 if (part.type === "text" && state.messageRoles.get(part.messageID) === "user") {
2466 if (!turn.isRoot) yield* projectChildUserPart(state, turn, part);
2467 return;
2468 }
2469 if (part.type === "step-finish") {
2470 const usage = turn.usage;
2471 const ownership = usage.assistantOwnershipByMessageId.get(part.messageID);
2472 if (ownership === "owned") accumulateOpenCodeStepUsage(usage, part);
2473 else if (
2474 ownership === "unknown" ||
2475 (ownership === undefined && !state.messageRoles.has(part.messageID))
2476 ) {
2477 const steps =
2478 usage.unresolvedStepsByMessageId.get(part.messageID) ??
2479 new Map<string, OpenCodeStepUsage>();
2480 steps.set(part.id, { id: part.id, tokens: part.tokens });
2481 usage.unresolvedStepsByMessageId.set(part.messageID, steps);
2482 }
2483 return;
2484 }
2485 if (part.type === "tool") {
2486 // Approval routing needs the tool name, without retaining its input and output.
2487 turn.toolNamesByCallId.set(part.callID, part.tool);
2488 } else {
2489 turn.parts.set(part.id, part);
2490 const ids = turn.partIdsByMessage.get(part.messageID) ?? new Set<string>();
2491 ids.add(part.id);
2492 turn.partIdsByMessage.set(part.messageID, ids);
2493 }
2494 switch (part.type) {
2495 case "text":
2496 case "reasoning":
2497 yield* emitTextPart(state, turn, part);
2498 return;
2499 case "tool":
2500 yield* emitToolPart(state, turn, part);
2501 return;
2502 default:
2503 return;
2504 }
2505 });
2506
2507 const handlePartDelta = Effect.fnUntraced(function* (
2508 event: Extract<OpenCodeEvent, { type: "message.part.delta" }>,
2509 ) {
2510 if (event.properties.field !== "text") return;
2511 const state = threads.get(event.properties.sessionID);
2512 const turn = state?.activeTurn;
2513 const current = turn?.parts.get(event.properties.partID);
2514 if (
2515 state === undefined ||
2516 turn === null ||
2517 turn === undefined ||
2518 current === undefined ||
2519 (current.type !== "text" && current.type !== "reasoning")
2520 ) {
2521 return;
2522 }
2523 const updated = { ...current, text: current.text + event.properties.delta };
2524 turn.parts.set(updated.id, updated);
2525 yield* emitTextPart(state, turn, updated);
2526 });
2527
2528 const handleAssistantCompleted = Effect.fnUntraced(function* (
2529 event: Extract<OpenCodeEvent, { type: "message.updated" }>,
2530 ) {
2531 const message = event.properties.info;
2532 if (message.role !== "assistant" || message.time.completed === undefined) return;
2533 const state = threads.get(message.sessionID);
2534 const turn = state?.activeTurn;
2535 if (state === undefined || turn === null || turn === undefined) return;
2536 // Some OpenCode versions ignore the client-provided message ID. A
2537 // completed assistant message is definitive admission evidence, so
2538 // let the following idle event settle the turn without weakening
2539 // the stale-user-message guard.
2540 advanceOpenCodePromptAdmission(turn, "assistant-completed");
2541 for (const partId of turn.partIdsByMessage.get(message.id) ?? []) {
2542 const part = turn.parts.get(partId);
2543 if (part?.type === "text" || part?.type === "reasoning") {
2544 yield* emitTextPart(state, turn, part, true);
2545 }
2546 }
2547 });
2548
2549 const handleEvent = Effect.fnUntraced(function* (event: OpenCodeEvent) {
2550 yield* logProtocolEvent({
2551 direction: "incoming",
2552 messageKind: "notification",
2553 method: event.type,
2554 payload: event,
2555 });
2556 switch (event.type) {
2557 case "server.connected":
2558 if (hasConnected) {
2559 for (const state of threads.values()) {
2560 if (state.activeTurn !== null) state.activeTurn.usage.complete = false;
2561 }
2562 }
2563 hasConnected = true;
2564 return;
2565 case "message.updated":
2566 yield* handleMessageUpdated(event);
2567 yield* handleAssistantCompleted(event);
2568 return;
2569 case "session.compacted": {
2570 const state = threads.get(event.properties.sessionID);
2571 if (state?.activeTurn !== undefined && state.activeTurn !== null) {
2572 yield* emitCompactionItem(state, state.activeTurn);
2573 }
2574 return;
2575 }
2576 case "message.part.updated":
2577 yield* handlePartUpdated(event);
2578 return;
2579 case "message.part.delta":
2580 yield* handlePartDelta(event);
2581 return;
2582 case "todo.updated": {
2583 const state = threads.get(event.properties.sessionID);
2584 if (state?.activeTurn !== null && state?.activeTurn !== undefined) {
2585 yield* emitTodo(state, state.activeTurn, event.properties.todos);
2586 }
2587 return;
2588 }
2589 case "permission.asked":
2590 yield* routeRuntimeRequest(event.properties.id, event.properties.sessionID, {
2591 type: "permission",
2592 value: event.properties,
2593 });
2594 return;
2595 case "question.asked":
2596 yield* routeRuntimeRequest(event.properties.id, event.properties.sessionID, {
2597 type: "question",
2598 value: event.properties,
2599 });
2600 return;
2601 case "permission.replied":
2602 case "question.replied":
2603 rememberSettledRequest(event.properties.requestID);
2604 yield* resolveRuntimeRequest(event.properties.requestID, "resolved");
2605 return;
2606 case "question.rejected":
2607 rememberSettledRequest(event.properties.requestID);
2608 yield* resolveRuntimeRequest(event.properties.requestID, "cancelled");
2609 return;
2610 case "session.created":
2611 case "session.updated": {
2612 const info = event.properties.info;
2613 if (info.parentID !== undefined && !threads.has(info.id)) {
2614 const owner = threads.get(info.parentID) ?? relatedSessionOwners.get(info.parentID);
2615 if (owner !== undefined) {
2616 relatedSessionOwners.set(info.id, owner);
2617 }
2618 }
2619 return;
2620 }
2621 case "session.deleted":
2622 relatedSessionOwners.delete(event.properties.info.id);
2623 busySessionIds.delete(event.properties.info.id);
2624 return;
2625 case "session.status": {
2626 const sessionId = event.properties.sessionID;
2627 switch (event.properties.status.type) {
2628 case "busy":
2629 case "retry":
2630 if (threads.has(sessionId) || relatedSessionOwners.has(sessionId)) {
2631 busySessionIds.add(sessionId);
2632 }
2633 break;
2634 case "idle":
2635 busySessionIds.delete(sessionId);
2636 break;
2637 }
2638 const state = threads.get(sessionId);
2639 if (state === undefined) return;
2640 if (event.properties.status.type === "busy") {
2641 yield* updateProviderThread(state, { status: "active" });
2642 return;
2643 }
2644 if (event.properties.status.type === "idle" && state.activeTurn !== null) {
2645 if (state.activeTurn.admissionPending) {
2646 advanceOpenCodePromptAdmission(state.activeTurn, "idle");
2647 return;
2648 }
2649 yield* finalizeTurn(
2650 state,
2651 state.activeTurn,
2652 state.activeTurn.interrupted ? "interrupted" : "completed",
2653 );
2654 }
2655 return;
2656 }
2657 case "session.idle": {
2658 busySessionIds.delete(event.properties.sessionID);
2659 const state = threads.get(event.properties.sessionID);
2660 if (state?.activeTurn !== null && state?.activeTurn !== undefined) {
2661 if (state.activeTurn.admissionPending) {
2662 advanceOpenCodePromptAdmission(state.activeTurn, "idle");
2663 return;
2664 }
2665 yield* finalizeTurn(
2666 state,
2667 state.activeTurn,
2668 state.activeTurn.interrupted ? "interrupted" : "completed",
2669 );
2670 }
2671 return;
2672 }
2673 case "session.error": {
2674 const states =
2675 event.properties.sessionID === undefined
2676 ? Array.from(threads.values()).filter((state) => state.activeTurn !== null)
2677 : [threads.get(event.properties.sessionID)].filter(
2678 (state): state is OpenCodeThreadState => state !== undefined,
2679 );
2680 const message = openCodeErrorMessage(event);
2681 if (
2682 !isMessageAbortedError(event) &&
2683 (event.properties.sessionID === undefined ||
2684 states.some((state) => state.parentSubagent === null))
2685 ) {
2686 yield* updateProviderSession("error", message);
2687 }
2688 for (const state of states) {
2689 if (state.activeTurn !== null) {
2690 yield* finalizeTurn(
2691 state,
2692 state.activeTurn,
2693 terminalStatusForError(event, state.activeTurn),
2694 {
2695 failure: makeProviderFailure({
2696 message,
2697 code: event.properties.error?.name ?? null,
2698 class: "provider_error",
2699 }),
2700 threadDisposition:
2701 event.properties.sessionID === undefined ? "broken" : "reusable",
2702 },
2703 );
2704 }
2705 }
2706 return;
2707 }
2708 default:
2709 return;
2710 }
2711 });
2712
2713 const inputProviderSessionId = input.providerSessionId;
2714
2715 const subscription = yield* sdkCall("event.subscribe", {}, () =>
2716 client.event.subscribe(undefined, { signal: abortController.signal }),
2717 );
2718 yield* Scope.addFinalizer(
2719 scope,
2720 Effect.sync(() => abortController.abort()),
2721 );
2722 yield* Stream.fromAsyncIterable(
2723 subscription.stream,
2724 (cause) =>
2725 new OpenCodeRuntime.OpenCodeRuntimeError({
2726 operation: "event.subscribe",
2727 detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause),
2728 cause,
2729 }),
2730 ).pipe(
2731 Stream.runForEach(handleEvent),
2732 Effect.exit,
2733 Effect.flatMap((exit) =>
2734 Effect.gen(function* () {
2735 // No further status can clear a busy session once the stream ends.
2736 busySessionIds.clear();
2737 if (closing || abortController.signal.aborted) return;
2738 const detail = Exit.isSuccess(exit)
2739 ? "OpenCode event stream ended unexpectedly."
2740 : OpenCodeRuntime.openCodeRuntimeErrorDetail(Cause.squash(exit.cause));
2741 nativeStreamFailure = makeProviderFailure({
2742 message: detail,
2743 class: "transport_error",
2744 });
2745 yield* updateProviderSession("error", detail);
2746 for (const state of threads.values()) {
2747 if (state.activeTurn !== null)
2748 yield* finalizeTurn(state, state.activeTurn, "failed", {
2749 failure: makeProviderFailure({ message: detail, class: "transport_error" }),
2750 threadDisposition: "broken",
2751 });
2752 }
2753 yield* Queue.end(events);
2754 }),
2755 ),
2756 Effect.forkIn(scope),
2757 );
2758
2759 if (!connection.external && connection.exitCode !== null) {
2760 yield* connection.exitCode.pipe(
2761 Effect.flatMap((code) =>
2762 abortController.signal.aborted
2763 ? Effect.void
2764 : Effect.gen(function* () {
2765 const detail = `OpenCode server exited unexpectedly (${code}).`;
2766 yield* updateProviderSession("error", detail);
2767 for (const state of threads.values()) {
2768 if (state.activeTurn !== null) {
2769 yield* finalizeTurn(state, state.activeTurn, "failed", {
2770 failure: makeProviderFailure({
2771 message: detail,
2772 class: "transport_error",
2773 }),
2774 threadDisposition: "broken",
2775 });
2776 }
2777 }
2778 }),
2779 ),
2780 Effect.forkIn(scope),
2781 );
2782 }
2783
2784 if (connection.external) {
2785 yield* Scope.addFinalizer(
2786 scope,
2787 Effect.suspend(() =>
2788 Effect.forEach(
2789 Array.from(threads.values()).filter((state) => state.parentSubagent === null),
2790 (state) =>
2791 sdkCall("session.abort", { sessionID: state.nativeSessionId }, (signal) =>
2792 client.session.abort({ sessionID: state.nativeSessionId }, { signal }),
2793 ).pipe(
2794 Effect.timeout("1 second"),
2795 Effect.ignore({ log: true }),
2796 Effect.andThen(
2797 abortDescendants(state.nativeSessionId).pipe(
2798 Effect.timeout("1 second"),
2799 Effect.ignore({ log: true }),
2800 ),
2801 ),
2802 ),
2803 { concurrency: 8, discard: true },
2804 ),
2805 ),
2806 );
2807 }
2808
2809 yield* Scope.addFinalizer(
2810 scope,
2811 Effect.sync(() => {
2812 closing = true;
2813 }),
2814 );
2815
2816 const registerThread = (
2817 nativeSession: OpenCodeSession,
2818 providerThread: OrchestrationV2ProviderThread,
2819 appThread: OrchestrationV2AppThread | null,
2820 ): OpenCodeThreadState => {
2821 const existing = threads.get(nativeSession.id);
2822 if (existing !== undefined) {
2823 existing.providerThread = providerThread;
2824 if (appThread !== null) existing.appThread = appThread;
2825 return existing;
2826 }
2827 const state: OpenCodeThreadState = {
2828 nativeSessionId: nativeSession.id,
2829 providerThread,
2830 appThread,
2831 activeTurn: null,
2832 providerTurns: new Map(),
2833 runtimeRequests: new Map(),
2834 messageRoles: new Map(),
2835 userMessageIds: [],
2836 parentSubagent: subagentsByChildSessionId.get(nativeSession.id) ?? null,
2837 nextChildTurnOrdinal: 1,
2838 nextAdmissionGeneration: 1,
2839 };
2840 threads.set(nativeSession.id, state);
2841 return state;
2842 };
2843
2844 const resolvePromptParts = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => {
2845 const text = providerMessageTextWithAttachmentPaths({
2846 text: turnInput.message.text,
2847 attachments: turnInput.message.attachments,
2848 resolveAttachmentPath: host.resolveAttachmentPath,
2849 }).trim();
2850 const files = OpenCodeRuntime.toOpenCodeFileParts({
2851 attachments: turnInput.message.attachments,
2852 resolveAttachmentPath: host.resolveAttachmentPath,
2853 });
2854 if (text.length === 0 && files.length === 0) {
2855 throw protocolError("OpenCode turns require text or at least one valid attachment");
2856 }
2857 return [...(text.length === 0 ? [] : [{ type: "text" as const, text }]), ...files];
2858 };
2859
2860 const readSnapshot = Effect.fnUntraced(function* (
2861 providerThread: OrchestrationV2ProviderThread,
2862 ) {
2863 const sessionId = nativeThreadId(providerThread);
2864 const sessionResponse = yield* sdkCall("session.get", { sessionID: sessionId }, () =>
2865 client.session.get({ sessionID: sessionId }),
2866 );
2867 const nativeSession = unwrapData("session.get", sessionResponse);
2868 const response = yield* sdkCall("session.messages", { sessionID: sessionId }, () =>
2869 client.session.messages({ sessionID: sessionId }),
2870 );
2871 const nativeMessages = [];
2872 for (const entry of unwrapData("session.messages", response)) {
2873 // OpenCode retains reverted records in session.messages. Its
2874 // revert marker identifies the first removed message, so stop
2875 // before it or a restart resurrects rolled-back assistant work.
2876 if (entry.info.id === nativeSession.revert?.messageID) break;
2877 nativeMessages.push(entry);
2878 }
2879 const state = threads.get(sessionId);
2880 const snapshotNow = yield* DateTime.now;
2881 const messages: Array<OrchestrationV2ConversationMessage> = nativeMessages.flatMap(
2882 ({ info, parts }) => {
2883 const text = parts
2884 .filter(
2885 (part): part is Extract<OpenCodePart, { type: "text" }> => part.type === "text",
2886 )
2887 .filter((part) => part.ignored !== true && part.synthetic !== true)
2888 .map((part) => part.text)
2889 .join("\n");
2890 if (text.length === 0) return [];
2891 const createdAt = dateTimeFromEpoch(info.time.created, snapshotNow);
2892 return [
2893 {
2894 createdBy: info.role === "user" ? "user" : "agent",
2895 creationSource: "provider",
2896 id: idAllocator.derive.messageFromProviderItem({
2897 driver: OPENCODE_PROVIDER,
2898 nativeItemId: info.id,
2899 }),
2900 threadId: providerThread.appThreadId ?? input.threadId,
2901 runId: null,
2902 nodeId: null,
2903 role: info.role,
2904 text,
2905 attachments: [],
2906 streaming: false,
2907 createdAt,
2908 updatedAt:
2909 info.role === "assistant"
2910 ? dateTimeFromEpoch(info.time.completed, createdAt)
2911 : createdAt,
2912 },
2913 ];
2914 },
2915 );
2916 const lastUser = nativeMessages.findLast(({ info }) => info.role === "user")?.info.id;
2917 return {
2918 providerThread: {
2919 ...providerThread,
2920 providerSessionId: input.providerSessionId,
2921 nativeConversationHeadRef:
2922 lastUser === undefined ? null : providerRef(lastUser, "weak"),
2923 status: "idle" as const,
2924 updatedAt: snapshotNow,
2925 },
2926 providerTurns: state === undefined ? [] : [...state.providerTurns.values()],
2927 messages,
2928 runtimeRequests: state === undefined ? [] : [...state.runtimeRequests.values()],
2929 providerPayload: nativeMessages,
2930 };
2931 });
2932
2933 // Native commands wait for generation, unlike promptAsync. A user-message
2934 // receipt admits the run while the scoped request continues in the background.
2935 const submitPrompt = Effect.fn("OpenCode.submitPrompt")(function* (
2936 state: OpenCodeThreadState,
2937 turn: ActiveOpenCodeTurn,
2938 payload: Parameters<typeof client.session.promptAsync>[0] & {
2939 messageID: string;
2940 sessionID: string;
2941 },
2942 abortController: AbortController,
2943 ) {
2944 const text = payload.parts
2945 ?.filter((part) => part.type === "text")
2946 .map((part) => part.text)
2947 .join("\n")
2948 .trim();
2949 const match = text?.match(/^\/([^\s/]+)(?:\s+([\s\S]*))?$/);
2950 const command = match
2951 ? (yield* OpenCodeRuntime.loadOpenCodeCommands(client).pipe(
2952 Effect.timeout("10 seconds"),
2953 Effect.orElseSucceed(() => []),
2954 )).find((entry) => entry.name === match[1])
2955 : undefined;
2956 if (!command) {
2957 return yield* sdkCall("session.promptAsync", payload, (signal) =>
2958 client.session.promptAsync(payload, {
2959 signal: AbortSignal.any([signal, abortController.signal]),
2960 }),
2961 ).pipe(Effect.asVoid);
2962 }
2963 const receipt = Deferred.makeUnsafe<void>();
2964 commandReceipts.set(payload.messageID, receipt);
2965 const controllers =
2966 commandControllers.get(payload.sessionID) ?? new Set<AbortController>();
2967 controllers.add(abortController);
2968 commandControllers.set(payload.sessionID, controllers);
2969 const generation = turn.admissionGeneration;
2970 const commandPayload = {
2971 sessionID: payload.sessionID,
2972 messageID: payload.messageID,
2973 command: command.name,
2974 arguments: match?.[2] ?? "",
2975 ...(payload.model
2976 ? { model: `${payload.model.providerID}/${payload.model.modelID}` }
2977 : {}),
2978 ...(payload.agent ? { agent: payload.agent } : {}),
2979 ...(payload.variant ? { variant: payload.variant } : {}),
2980 parts: payload.parts?.filter((part) => part.type === "file") ?? [],
2981 };
2982 const request = yield* sdkCall("session.command", commandPayload, (signal) =>
2983 client.session.command(commandPayload, {
2984 signal: AbortSignal.any([signal, abortController.signal]),
2985 }),
2986 ).pipe(
2987 Effect.asVoid,
2988 Effect.tapError((cause) =>
2989 abortController.signal.aborted ||
2990 turn.finalized ||
2991 turn.admissionGeneration !== generation
2992 ? Effect.void
2993 : finalizeTurn(state, turn, "failed", {
2994 failure: makeProviderFailure({ cause, class: "provider_error" }),
2995 }),
2996 ),
2997 Effect.ensuring(
2998 Effect.sync(() => {
2999 commandReceipts.delete(payload.messageID);
3000 controllers.delete(abortController);
3001 if (controllers.size === 0) commandControllers.delete(payload.sessionID);
3002 }),
3003 ),
3004 Effect.forkIn(scope),
3005 );
3006 yield* Effect.raceFirst(Fiber.join(request), Deferred.await(receipt)).pipe(
3007 Effect.timeout("10 seconds"),
3008 Effect.catchTags({
3009 TimeoutError: (cause) => {
3010 const error = new OpenCodeRuntime.OpenCodeRuntimeError({
3011 operation: "session.command",
3012 detail: "OpenCode command admission did not complete within 10 seconds.",
3013 cause,
3014 });
3015 abortController.abort();
3016 return finalizeTurn(state, turn, "failed", {
3017 failure: makeProviderFailure({ cause: error, class: "provider_error" }),
3018 }).pipe(Effect.andThen(Effect.fail(error)));
3019 },
3020 }),
3021 );
3022 });
3023
3024 const runtimeSession: ProviderAdapter.ProviderAdapterV2SessionRuntime = {
3025 instanceId: options.instanceId,
3026 driver: OPENCODE_PROVIDER,
3027 providerSessionId: input.providerSessionId,
3028 providerSession: sessionEntity,
3029 events: Stream.fromEffectRepeat(Queue.take(events)),
3030 hasPendingBackgroundWork: Effect.sync(() => busySessionIds.size > 0),
3031 ensureThread: (threadInput) =>
3032 Effect.gen(function* () {
3033 // Only a row that already carries a native session can be
3034 // resumed; a placeholder without one still needs session.create.
3035 if (threadInput.existingProviderThread?.nativeThreadRef != null) {
3036 return yield* runtimeSession.resumeThread({
3037 providerThread: threadInput.existingProviderThread,
3038 });
3039 }
3040 // No title: OpenCode generates one from the first prompt only when
3041 // session.create leaves it unset (SessionPrompt.ensureTitle).
3042 const response = yield* sdkCall(
3043 "session.create",
3044 { permission: openCodePermissionRules(threadInput.runtimePolicy) },
3045 () =>
3046 client.session.create({
3047 permission: openCodePermissionRules(threadInput.runtimePolicy),
3048 }),
3049 );
3050 const nativeSession = unwrapData("session.create", response);
3051 const createdAt = yield* DateTime.now;
3052 const created = makeProviderThread({
3053 idAllocator,
3054 providerInstanceId: options.instanceId,
3055 providerSessionId: input.providerSessionId,
3056 appThreadId: threadInput.threadId,
3057 nativeSession,
3058 now: createdAt,
3059 });
3060 const existing = threadInput.existingProviderThread;
3061 // Bind the new native session to the caller's row when one was
3062 // handed over: a second live row per app thread would make
3063 // `activeProviderThreadId` flap between the two on every update.
3064 const providerThread =
3065 existing === undefined
3066 ? created
3067 : {
3068 ...existing,
3069 providerSessionId: input.providerSessionId,
3070 nativeThreadRef: created.nativeThreadRef,
3071 nativeConversationHeadRef: created.nativeConversationHeadRef,
3072 status: created.status,
3073 updatedAt: created.updatedAt,
3074 };
3075 registerThread(nativeSession, providerThread, null);
3076 return providerThread;
3077 }).pipe(
3078 Effect.mapError(
3079 (cause) =>
3080 new ProviderAdapter.ProviderAdapterEnsureThreadError({
3081 driver: OPENCODE_PROVIDER,
3082 threadId: threadInput.threadId,
3083 cause,
3084 }),
3085 ),
3086 ),
3087 resumeThread: (threadInput) =>
3088 Effect.gen(function* () {
3089 const sessionId = nativeThreadId(threadInput.providerThread);
3090 const response = yield* sdkCall("session.get", { sessionID: sessionId }, () =>
3091 client.session.get({ sessionID: sessionId }),
3092 );
3093 const nativeSession = unwrapData("session.get", response);
3094 const resumedAt = yield* DateTime.now;
3095 const providerThread = {
3096 ...threadInput.providerThread,
3097 providerSessionId: input.providerSessionId,
3098 status: "idle" as const,
3099 updatedAt: dateTimeFromEpoch(nativeSession.time.updated, resumedAt),
3100 };
3101 registerThread(nativeSession, providerThread, null);
3102 return providerThread;
3103 }).pipe(
3104 Effect.mapError(
3105 (cause) =>
3106 new ProviderAdapter.ProviderAdapterResumeThreadError({
3107 driver: OPENCODE_PROVIDER,
3108 providerSessionId: input.providerSessionId,
3109 providerThreadId: threadInput.providerThread.id,
3110 cause,
3111 }),
3112 ),
3113 ),
3114 compactThread: (turnInput) =>
3115 runtimeSession.startTurn({
3116 ...turnInput,
3117 message: { ...turnInput.message, text: "/compact" },
3118 }),
3119 startTurn: (turnInput) =>
3120 Effect.gen(function* () {
3121 if (nativeStreamFailure !== null) {
3122 return yield* protocolError(
3123 "OpenCode event stream has ended; reconnect the provider session before starting another turn.",
3124 );
3125 }
3126 const sessionId = nativeThreadId(turnInput.providerThread);
3127 const state = threads.get(sessionId);
3128 if (state === undefined) {
3129 return yield* protocolError(`OpenCode session ${sessionId} is not registered`);
3130 }
3131 if (state.activeTurn !== null) {
3132 return yield* protocolError(
3133 `OpenCode provider thread ${turnInput.providerThread.id} already has an active turn`,
3134 );
3135 }
3136 const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(
3137 turnInput.modelSelection.model,
3138 );
3139 if (parsedModel === null) {
3140 return yield* protocolError(
3141 `OpenCode model '${turnInput.modelSelection.model}' must use provider/model format`,
3142 );
3143 }
3144 const isCompaction =
3145 turnInput.message.text.trim() === "/compact" &&
3146 turnInput.message.attachments.length === 0;
3147 const parts = isCompaction ? [] : resolvePromptParts(turnInput);
3148 const startedAt = yield* DateTime.now;
3149 const syntheticNativeTurnId = `${sessionId}:attempt:${turnInput.attemptId}`;
3150 const providerTurnId = idAllocator.derive.providerTurn({
3151 driver: OPENCODE_PROVIDER,
3152 nativeTurnId: syntheticNativeTurnId,
3153 });
3154 const providerTurn: OrchestrationV2ProviderTurn = {
3155 id: providerTurnId,
3156 providerThreadId: turnInput.providerThread.id,
3157 nodeId: turnInput.rootNodeId,
3158 runAttemptId: turnInput.attemptId,
3159 nativeTurnRef: providerRef(syntheticNativeTurnId, "weak"),
3160 ordinal: turnInput.providerTurnOrdinal,
3161 status: "running",
3162 startedAt,
3163 completedAt: null,
3164 };
3165 const admissionMessageId = yield* makeOpenCodeMessageId(messageIdClock);
3166 // No Effect may be yielded between this check and installing the
3167 // turn. If the event stream ended while IDs were being prepared,
3168 // registering afterward would leave a running turn that the EOF
3169 // handler had already finished scanning.
3170 if (nativeStreamFailure !== null) {
3171 return yield* protocolError(
3172 "OpenCode event stream has ended; reconnect the provider session before starting another turn.",
3173 );
3174 }
3175 if (state.activeTurn !== null) {
3176 return yield* protocolError(
3177 `OpenCode provider thread ${turnInput.providerThread.id} already has an active turn`,
3178 );
3179 }
3180 const turn: ActiveOpenCodeTurn = {
3181 isRoot: true,
3182 threadId: turnInput.threadId,
3183 runId: turnInput.runId,
3184 rootNodeId: turnInput.rootNodeId,
3185 appThread: turnInput.appThread,
3186 modelSelection: turnInput.modelSelection,
3187 runtimePolicy: turnInput.runtimePolicy,
3188 providerTurnId,
3189 providerThreadId: turnInput.providerThread.id,
3190 providerTurnOrdinal: turnInput.providerTurnOrdinal,
3191 runOrdinal: turnInput.runOrdinal,
3192 runAttemptId: turnInput.attemptId,
3193 startedAt,
3194 itemOrdinals: new Map(),
3195 usage: makeOpenCodeTurnTokenUsageAccumulator(),
3196 parts: new Map(),
3197 partIdsByMessage: new Map(),
3198 toolNamesByCallId: new Map(),
3199 providerTurn,
3200 nextItemOrdinal: turnInput.providerTurnOrdinal * 100 + 1,
3201 nativeUserMessageId: null,
3202 admissionMessageId,
3203 interrupted: false,
3204 finalized: false,
3205 planId: null,
3206 admissionGeneration: state.nextAdmissionGeneration++,
3207 admissionReconciliationGeneration: null,
3208 admissionPending: !isCompaction,
3209 admissionAccepted: isCompaction,
3210 admissionMessageObserved: false,
3211 idleDuringAdmission: false,
3212 admissionSettled: Deferred.makeUnsafe<void>(),
3213 admissionAbortController: new AbortController(),
3214 };
3215 if (turn.admissionMessageId !== null)
3216 turn.usage.promptMessageIds.add(turn.admissionMessageId);
3217 const admissionSettled = turn.admissionSettled;
3218 const admissionAbortController = turn.admissionAbortController;
3219 state.appThread = turnInput.appThread;
3220 state.activeTurn = turn;
3221 state.providerTurns.set(String(providerTurnId), providerTurn);
3222 yield* emitProviderTurn(state, turn, "running", null);
3223 yield* updateProviderThread(state, {
3224 status: "active",
3225 firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal,
3226 lastRunOrdinal: turnInput.runOrdinal,
3227 });
3228 yield* updateProviderSession("running", null);
3229 if (isCompaction) {
3230 yield* sdkCall(
3231 "session.summarize",
3232 { sessionID: sessionId, ...parsedModel, auto: false },
3233 (signal) =>
3234 client.session.summarize(
3235 { sessionID: sessionId, ...parsedModel, auto: false },
3236 { signal: AbortSignal.any([signal, admissionAbortController!.signal]) },
3237 ),
3238 ).pipe(
3239 Effect.tap(() =>
3240 turn.interrupted || turn.finalized || nativeStreamFailure !== null
3241 ? Effect.void
3242 : emitCompactionItem(state, turn),
3243 ),
3244 Effect.tap(() =>
3245 finalizeTurn(state, turn, turn.interrupted ? "interrupted" : "completed"),
3246 ),
3247 Effect.tapError((cause) =>
3248 finalizeTurn(state, turn, turn.interrupted ? "interrupted" : "failed", {
3249 failure: makeProviderFailure({ cause, class: "provider_error" }),
3250 }),
3251 ),
3252 Effect.ensuring(Deferred.succeed(admissionSettled, undefined)),
3253 );
3254 return;
3255 }
3256 const systemPrompt = [
3257 orchestrationSystemPrompt,
3258 buildRuntimeInstructions({
3259 harness: "OpenCode",
3260 model: turnInput.modelSelection.model,
3261 }),
3262 ]
3263 .filter(Boolean)
3264 .join("\n\n");
3265 const agent =
3266 getModelSelectionStringOptionValue(turnInput.modelSelection, "agent") ??
3267 (turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : undefined);
3268 const variant = getModelSelectionStringOptionValue(
3269 turnInput.modelSelection,
3270 "variant",
3271 );
3272 yield* submitPrompt(
3273 state,
3274 turn,
3275 {
3276 sessionID: sessionId,
3277 messageID: turn.admissionMessageId!,
3278 model: parsedModel,
3279 ...(agent === undefined ? {} : { agent }),
3280 ...(variant === undefined ? {} : { variant }),
3281 system: systemPrompt,
3282 parts,
3283 },
3284 admissionAbortController!,
3285 ).pipe(
3286 Effect.tapError((cause) =>
3287 admissionAbortController!.signal.aborted
3288 ? Effect.void
3289 : finalizeTurn(state, turn, "failed", {
3290 failure: makeProviderFailure({ cause, class: "provider_error" }),
3291 }),
3292 ),
3293 Effect.catch((cause) =>
3294 admissionAbortController!.signal.aborted ? Effect.void : Effect.fail(cause),
3295 ),
3296 Effect.ensuring(
3297 Effect.all([
3298 Deferred.succeed(admissionSettled, undefined).pipe(Effect.ignore),
3299 Effect.sync(() => {
3300 if (turn.admissionAbortController === admissionAbortController) {
3301 turn.admissionAbortController = null;
3302 }
3303 }),
3304 ]).pipe(Effect.asVoid),
3305 ),
3306 );
3307 if (state.activeTurn === turn && !turn.finalized && !turn.interrupted) {
3308 const admissionAction = advanceOpenCodePromptAdmission(turn, "accepted");
3309 if (admissionAction === "reconcile-idle") {
3310 yield* reconcilePromptAdmission(state, turn);
3311 }
3312 }
3313 }).pipe(
3314 Effect.mapError(
3315 (cause) =>
3316 new ProviderAdapter.ProviderAdapterTurnStartError({
3317 driver: OPENCODE_PROVIDER,
3318 threadId: turnInput.threadId,
3319 providerThreadId: turnInput.providerThread.id,
3320 runId: turnInput.runId,
3321 cause,
3322 }),
3323 ),
3324 ),
3325 steerTurn: (steerInput) =>
3326 Effect.gen(function* () {
3327 const sessionId = nativeThreadId(steerInput.providerThread);
3328 const state = threads.get(sessionId);
3329 const turn = state?.activeTurn;
3330 if (
3331 state === undefined ||
3332 turn === undefined ||
3333 turn === null ||
3334 turn.providerTurnId !== steerInput.providerTurnId ||
3335 turn.interrupted
3336 ) {
3337 return yield* protocolError(
3338 `OpenCode turn ${steerInput.providerTurnId} is not active`,
3339 );
3340 }
3341 const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(turn.modelSelection.model);
3342 if (parsedModel === null) {
3343 return yield* protocolError(
3344 `OpenCode model '${turn.modelSelection.model}' must use provider/model format`,
3345 );
3346 }
3347 const text = providerMessageTextWithAttachmentPaths({
3348 text: steerInput.message.text,
3349 attachments: steerInput.message.attachments,
3350 resolveAttachmentPath: host.resolveAttachmentPath,
3351 }).trim();
3352 const files = OpenCodeRuntime.toOpenCodeFileParts({
3353 attachments: steerInput.message.attachments,
3354 resolveAttachmentPath: host.resolveAttachmentPath,
3355 });
3356 if (text.length === 0 && files.length === 0) {
3357 return yield* protocolError("OpenCode steering requires text or an attachment");
3358 }
3359 const parts = [
3360 ...(text.length === 0 ? [] : [{ type: "text" as const, text }]),
3361 ...files,
3362 ];
3363 turn.admissionGeneration = state.nextAdmissionGeneration++;
3364 turn.admissionMessageId = yield* makeOpenCodeMessageId(messageIdClock);
3365 turn.admissionPending = true;
3366 turn.admissionAccepted = false;
3367 turn.admissionMessageObserved = false;
3368 turn.idleDuringAdmission = false;
3369 turn.admissionSettled = Deferred.makeUnsafe<void>();
3370 turn.admissionAbortController = new AbortController();
3371 if (turn.admissionMessageId !== null)
3372 turn.usage.promptMessageIds.add(turn.admissionMessageId);
3373 const admissionSettled = turn.admissionSettled;
3374 const admissionAbortController = turn.admissionAbortController;
3375 yield* submitPrompt(
3376 state,
3377 turn,
3378 {
3379 sessionID: sessionId,
3380 messageID: turn.admissionMessageId,
3381 model: parsedModel,
3382 parts,
3383 },
3384 admissionAbortController!,
3385 ).pipe(
3386 Effect.ensuring(
3387 Effect.all([
3388 Deferred.succeed(admissionSettled, undefined).pipe(Effect.ignore),
3389 Effect.sync(() => {
3390 if (turn.admissionAbortController === admissionAbortController) {
3391 turn.admissionAbortController = null;
3392 }
3393 }),
3394 ]).pipe(Effect.asVoid),
3395 ),
3396 );
3397 if (state.activeTurn === turn && !turn.finalized) {
3398 const admissionAction = advanceOpenCodePromptAdmission(turn, "accepted");
3399 if (admissionAction === "reconcile-idle") {
3400 yield* reconcilePromptAdmission(state, turn);
3401 }
3402 }
3403 }).pipe(
3404 Effect.mapError(
3405 (cause) =>
3406 new ProviderAdapter.ProviderAdapterSteerRunError({
3407 driver: OPENCODE_PROVIDER,
3408 providerThreadId: steerInput.providerThread.id,
3409 providerTurnId: steerInput.providerTurnId,
3410 cause,
3411 }),
3412 ),
3413 ),
3414 interruptTurn: (interruptInput) =>
3415 Effect.gen(function* () {
3416 const sessionId = nativeThreadId(interruptInput.providerThread);
3417 const state = threads.get(sessionId);
3418 const turn = state?.activeTurn;
3419 // Stop on a settled turn: a background task child can outlive
3420 // it (busySessionIds), and aborting the descendants ends it.
3421 if (
3422 (turn === undefined || turn === null) &&
3423 interruptInput.requestRuntimeRestart === true
3424 ) {
3425 for (const controller of commandControllers.get(sessionId) ?? [])
3426 controller.abort();
3427 yield* abortDescendants(sessionId);
3428 return;
3429 }
3430 if (
3431 turn === undefined ||
3432 turn === null ||
3433 turn.providerTurnId !== interruptInput.providerTurnId
3434 ) {
3435 return yield* protocolError(
3436 `OpenCode turn ${interruptInput.providerTurnId} is not active`,
3437 );
3438 }
3439 turn.interrupted = true;
3440 for (const controller of commandControllers.get(sessionId) ?? []) controller.abort();
3441 const admissionWasPending = turn.admissionPending;
3442 cancelOpenCodePromptAdmission(turn, state!.nextAdmissionGeneration++);
3443 turn.admissionAbortController?.abort();
3444 if (admissionWasPending) {
3445 // Settle the cancelled request before sending the session abort.
3446 // A timed-out local request must not prevent the definitive
3447 // server-side abort below from running.
3448 yield* Deferred.await(turn.admissionSettled).pipe(
3449 Effect.timeout("10 seconds"),
3450 Effect.ignore,
3451 );
3452 }
3453 yield* sdkCall("session.abort", { sessionID: sessionId }, (signal) =>
3454 client.session.abort({ sessionID: sessionId }, { signal }),
3455 ).pipe(
3456 Effect.timeout("10 seconds"),
3457 // The turn can settle while the abort is in flight, and
3458 // aborting an already-idle session fails. That stop still
3459 // succeeded; only surface failures for a turn that is
3460 // genuinely still running.
3461 Effect.catch((cause) =>
3462 turn.finalized
3463 ? Effect.void
3464 : Effect.suspend(() => {
3465 turn.interrupted = false;
3466 return Effect.fail(cause);
3467 }),
3468 ),
3469 );
3470 // Root abort does not stop child sessions. Report incomplete cleanup
3471 // even if the root has already emitted its terminal event.
3472 yield* abortDescendants(sessionId);
3473 }).pipe(
3474 Effect.mapError(
3475 (cause) =>
3476 new ProviderAdapter.ProviderAdapterInterruptError({
3477 driver: OPENCODE_PROVIDER,
3478 providerThreadId: interruptInput.providerThread.id,
3479 providerTurnId: interruptInput.providerTurnId,
3480 cause,
3481 }),
3482 ),
3483 ),
3484 respondToRuntimeRequest: (requestInput) =>
3485 Effect.gen(function* () {
3486 const pending = pendingRequests.get(String(requestInput.requestId));
3487 if (pending === undefined) {
3488 return yield* protocolError(
3489 `No pending OpenCode request ${requestInput.requestId}`,
3490 );
3491 }
3492 if (pending.question !== undefined) {
3493 if (requestInput.answers === undefined) {
3494 return yield* protocolError(
3495 `OpenCode question request ${requestInput.requestId} requires answers`,
3496 );
3497 }
3498 const answers = OpenCodeRuntime.toOpenCodeQuestionAnswers(
3499 pending.question,
3500 requestInput.answers,
3501 );
3502 yield* sdkCall(
3503 "question.reply",
3504 { requestID: pending.nativeRequestId, answers },
3505 (signal) =>
3506 client.question.reply(
3507 {
3508 requestID: pending.nativeRequestId,
3509 answers,
3510 },
3511 { signal },
3512 ),
3513 ).pipe(Effect.timeout("10 seconds"));
3514 return;
3515 }
3516 if (requestInput.decision === undefined) {
3517 return yield* protocolError(
3518 `OpenCode approval request ${requestInput.requestId} requires a decision`,
3519 );
3520 }
3521 const reply = OpenCodeRuntime.toOpenCodePermissionReply(requestInput.decision);
3522 yield* sdkCall(
3523 "permission.reply",
3524 { requestID: pending.nativeRequestId, reply },
3525 (signal) =>
3526 client.permission.reply(
3527 {
3528 requestID: pending.nativeRequestId,
3529 reply,
3530 },
3531 { signal },
3532 ),
3533 ).pipe(Effect.timeout("10 seconds"));
3534 }).pipe(
3535 Effect.mapError(
3536 (cause) =>
3537 new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({
3538 driver: OPENCODE_PROVIDER,
3539 requestId: requestInput.requestId,
3540 cause,
3541 }),
3542 ),
3543 ),
3544 readThreadSnapshot: (snapshotInput) =>
3545 readSnapshot(snapshotInput.providerThread).pipe(
3546 Effect.mapError(
3547 (cause) =>
3548 new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({
3549 driver: OPENCODE_PROVIDER,
3550 providerThreadId: snapshotInput.providerThread.id,
3551 cause,
3552 }),
3553 ),
3554 ),
3555 rollbackThread: (rollbackInput) =>
3556 Effect.gen(function* () {
3557 const sessionId = nativeThreadId(rollbackInput.providerThread);
3558 const state = threads.get(sessionId);
3559 if (state?.activeTurn !== null && state?.activeTurn !== undefined) {
3560 return yield* protocolError(
3561 `Cannot roll back OpenCode thread ${rollbackInput.providerThread.id} while a turn is active`,
3562 );
3563 }
3564 const response = yield* sdkCall("session.messages", { sessionID: sessionId }, () =>
3565 client.session.messages({ sessionID: sessionId }),
3566 );
3567 const messages = unwrapData("session.messages", response);
3568 let boundaryMessageId: string | undefined;
3569 if (rollbackInput.target.type === "thread_start") {
3570 boundaryMessageId = messages.find(({ info }) => info.role === "user")?.info.id;
3571 } else {
3572 boundaryMessageId = openCodeBoundaryAfterProviderTurn(
3573 rollbackInput.providerThreadTurns,
3574 rollbackInput.target.providerTurn.id,
3575 );
3576 }
3577 let retainedThread = rollbackInput.providerThread;
3578 if (boundaryMessageId !== undefined) {
3579 const boundaryIndex = messages.findIndex(
3580 ({ info }) => info.id === boundaryMessageId,
3581 );
3582 if (boundaryIndex < 0)
3583 return yield* protocolError(
3584 "The OpenCode rewind boundary is no longer available.",
3585 );
3586 const fork = unwrapData(
3587 "session.fork",
3588 yield* sdkCall(
3589 "session.fork",
3590 { sessionID: sessionId, messageID: boundaryMessageId },
3591 () =>
3592 client.session.fork({ sessionID: sessionId, messageID: boundaryMessageId }),
3593 ),
3594 );
3595 const retained = unwrapData(
3596 "session.messages",
3597 yield* sdkCall("session.messages", { sessionID: fork.id }, () =>
3598 client.session.messages({ sessionID: fork.id }),
3599 ),
3600 );
3601 if (retained.length !== boundaryIndex)
3602 return yield* protocolError(
3603 "OpenCode did not preserve the requested rewind boundary.",
3604 );
3605 yield* sdkCall("session.update", { sessionID: fork.id }, () =>
3606 client.session.update({
3607 sessionID: fork.id,
3608 permission: openCodePermissionRules(input.runtimePolicy),
3609 }),
3610 );
3611 retainedThread = {
3612 ...rollbackInput.providerThread,
3613 nativeThreadRef: providerRef(fork.id),
3614 };
3615 registerThread(fork, retainedThread, state?.appThread ?? null);
3616 }
3617 const snapshot = yield* readSnapshot(retainedThread);
3618 return {
3619 ...snapshot,
3620 providerThread: {
3621 ...snapshot.providerThread,
3622 nativeConversationHeadRef:
3623 rollbackInput.target.type === "provider_turn"
3624 ? rollbackInput.target.providerTurn.nativeTurnRef
3625 : null,
3626 },
3627 };
3628 }).pipe(
3629 Effect.mapError(
3630 (cause) =>
3631 new ProviderAdapter.ProviderAdapterRollbackThreadError({
3632 driver: OPENCODE_PROVIDER,
3633 providerThreadId: rollbackInput.providerThread.id,
3634 checkpointId: rollbackInput.target.checkpointId,
3635 cause,
3636 }),
3637 ),
3638 ),
3639 forkThread: (forkInput) =>
3640 Effect.gen(function* () {
3641 const sourceSessionId = nativeThreadId(forkInput.sourceProviderThread);
3642 const sourceState = threads.get(sourceSessionId);
3643 if (sourceState?.activeTurn !== null && sourceState?.activeTurn !== undefined) {
3644 return yield* protocolError(
3645 `Cannot fork OpenCode thread ${forkInput.sourceProviderThread.id} while a turn is active`,
3646 );
3647 }
3648 let boundaryMessageId: string | undefined;
3649 if (forkInput.providerTurnId !== undefined) {
3650 const sourceTurns = forkInput.sourceProviderTurns ?? [];
3651 const selected = sourceTurns.find((turn) => turn.id === forkInput.providerTurnId);
3652 if (selected === undefined) {
3653 return yield* protocolError(
3654 `OpenCode fork boundary turn ${forkInput.providerTurnId} was not found`,
3655 );
3656 }
3657 boundaryMessageId = openCodeBoundaryAfterProviderTurn(sourceTurns, selected.id);
3658 }
3659 const response = yield* sdkCall(
3660 "session.fork",
3661 {
3662 sessionID: sourceSessionId,
3663 ...(boundaryMessageId === undefined ? {} : { messageID: boundaryMessageId }),
3664 },
3665 () =>
3666 client.session.fork({
3667 sessionID: sourceSessionId,
3668 ...(boundaryMessageId === undefined ? {} : { messageID: boundaryMessageId }),
3669 }),
3670 );
3671 const nativeSession = unwrapData("session.fork", response);
3672 const forkedAt = yield* DateTime.now;
3673 const providerThread = makeProviderThread({
3674 idAllocator,
3675 providerInstanceId: options.instanceId,
3676 providerSessionId: input.providerSessionId,
3677 appThreadId: forkInput.targetThreadId,
3678 ...(forkInput.ownerNodeId === undefined
3679 ? {}
3680 : { ownerNodeId: forkInput.ownerNodeId }),
3681 nativeSession,
3682 forkedFrom: {
3683 providerThreadId: forkInput.sourceProviderThread.id,
3684 ...(forkInput.providerTurnId === undefined
3685 ? {}
3686 : { providerTurnId: forkInput.providerTurnId }),
3687 },
3688 now: forkedAt,
3689 });
3690 registerThread(nativeSession, providerThread, null);
3691 return providerThread;
3692 }).pipe(
3693 Effect.mapError(
3694 (cause) =>
3695 new ProviderAdapter.ProviderAdapterForkThreadError({
3696 driver: OPENCODE_PROVIDER,
3697 providerThreadId: forkInput.sourceProviderThread.id,
3698 cause,
3699 }),
3700 ),
3701 ),
3702 };
3703
3704 return runtimeSession;
3705 },
3706 (effect, input) =>
3707 effect.pipe(
3708 Effect.mapError(
3709 (cause) =>
3710 new ProviderAdapter.ProviderAdapterOpenSessionError({
3711 driver: OPENCODE_PROVIDER,
3712 providerSessionId: input.providerSessionId,
3713 cause,
3714 }),
3715 ),
3716 ),
3717 ),
3718 });
3719});
3720
3721export type OpenCodeAdapterV2DriverEnv =
3722 | OpenCodeRuntime.OpenCodeRuntime
3723 | IdAllocator.IdAllocatorV2
3724 | McpProviderSessions.McpProviderSessions
3725 | ProviderEventLoggers.ProviderEventLoggers
3726 | ProviderHost.ProviderHost;
3727
3728export const OpenCodeAdapterV2Driver: ProviderAdapterDriver<
3729 OpenCodeSettings,
3730 OpenCodeAdapterV2DriverEnv
3731> = {
3732 driverKind: OPENCODE_PROVIDER,
3733 configSchema: OpenCodeSettings,
3734 defaultConfig: (): OpenCodeSettings => DEFAULT_OPENCODE_SETTINGS,
3735 create: Effect.fn("OpenCodeAdapterV2Driver.create")(
3736 function* (input: ProviderAdapterDriverCreateInput<OpenCodeSettings>) {
3737 const hostEnvironment = yield* HostProcess.Environment;
3738 const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers;
3739 return yield* makeOpenCodeAdapterV2({
3740 instanceId: input.instanceId,
3741 settings: { ...input.config, enabled: input.enabled },
3742 environment: yield* mergeProviderInstanceEnvironment(input.environment, hostEnvironment),
3743 ...(providerEventLoggers.native === undefined
3744 ? {}
3745 : { nativeEventLogger: providerEventLoggers.native }),
3746 });
3747 },
3748 (effect, input) =>
3749 effect.pipe(
3750 Effect.mapError(
3751 (cause) =>
3752 new ProviderAdapterDriverCreateError({
3753 driver: OPENCODE_PROVIDER,
3754 instanceId: input.instanceId,
3755 detail: "Failed to create OpenCode v2 adapter.",
3756 cause,
3757 }),
3758 ),
3759 ),
3760 ),
3761};
3762
3763const layer: Layer.Layer<ProviderAdapter.ProviderAdapterV2, never, OpenCodeAdapterV2DriverEnv> =
3764 Layer.effect(
3765 ProviderAdapter.ProviderAdapterV2,
3766 Effect.gen(function* () {
3767 const hostEnvironment = yield* HostProcess.Environment;
3768 const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers;
3769 return yield* makeOpenCodeAdapterV2({
3770 instanceId: OPENCODE_DEFAULT_INSTANCE_ID,
3771 settings: DEFAULT_OPENCODE_SETTINGS,
3772 environment: hostEnvironment,
3773 ...(providerEventLoggers.native === undefined
3774 ? {}
3775 : { nativeEventLogger: providerEventLoggers.native }),
3776 });
3777 }),
3778 );