1/**
2 * The OpenCode 2 runtime behind the `opencode` driver. It talks to the
3 * instance's `opencode serve` process through the HTTP client and reads that
4 * server's `/api/event` stream, routed here by session id.
5 *
6 * A turn is one `session.prompt` (or `session.command`, or `session.compact`
7 * for `/compact`); the session's next `session.execution.*` terminal ends it.
8 * A steer is another prompt with `delivery: "steer"`, which OpenCode reads at
9 * the running execution's next step boundary, so it ends with the turn it
10 * joined. Fork and rollback cut the native history before the user message a
11 * turn prompted with (its `nativeTurnRef`). Each runtime mode is a set of
12 * session permission rules, plan mode is also OpenCode's `plan` agent
13 * (switched before the prompt like the model), and OpenCode's permission asks
14 * and question forms become runtime requests on the asking session's thread, a
15 * subagent's on its parent's.
16 *
17 * A `subagent` call runs in a child session shown as a subagent thread. A
18 * background one outlives its turn; when it ends, OpenCode starts a parent
19 * execution T3 did not ask for, which waits here for the continuation turn T3
20 * opens for it.
21 *
22 * @module orchestration-v2/Adapters/OpenCode2AdapterV2
23 */
24
25import {
26 AbsolutePath,
27 Agent,
28 Form,
29 Location,
30 Model,
31 Permission,
32 Provider,
33 Session,
34 SessionMessage,
35 Skill,
36 type OpenCodeEvent,
37} from "@opencode/client/effect";
38import { Mcp } from "@opencode/schema/mcp";
39import {
40 isOrchestrationV2WorkActive,
41 type OrchestrationV2AppThread,
42 type OrchestrationV2ConversationMessage,
43 type OrchestrationV2ExecutionNode,
44 type OrchestrationV2ProviderCapabilities,
45 type OrchestrationV2ProviderSession,
46 type OrchestrationV2ProviderThread,
47 type OrchestrationV2ProviderTurn,
48 type OrchestrationV2RuntimeRequest,
49 type OrchestrationV2Subagent,
50 type OrchestrationV2TurnItem,
51 type OrchestrationV2UserInputQuestion,
52 type MessageId,
53 type ModelSelection,
54 type ProviderApprovalDecision,
55 type ProviderInstanceId,
56 type RunId,
57 type RuntimeRequestId,
58} from "@t3tools/contracts";
59import type * as Cause from "effect/Cause";
60import * as DateTime from "effect/DateTime";
61import * as Deferred from "effect/Deferred";
62import * as Crypto from "effect/Crypto";
63import * as Effect from "effect/Effect";
64import * as Hex from "effect/encoding/Hex";
65import * as Exit from "effect/Exit";
66import * as Option from "effect/Option";
67import * as Queue from "effect/Queue";
68import * as Schedule from "effect/Schedule";
69import * as Scope from "effect/Scope";
70import * as Schema from "effect/Schema";
71import * as Semaphore from "effect/Semaphore";
72import * as Stream from "effect/Stream";
73
74import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
75import * as OpenCode2Client from "./OpenCode2Client.ts";
76import * as OpenCode2Server from "./OpenCode2Server.ts";
77import * as OpenCodeRuntime from "../OpenCodeRuntime.ts";
78import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
79import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions";
80import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions";
81import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens";
82import * as KeyedLock from "@t3tools/shared/KeyedLock";
83import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model";
84import { causeErrorTag } from "@t3tools/shared/observability";
85
86import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt";
87import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
88import {
89 backgroundWorkNotification,
90 type BackgroundWorkReport,
91} from "@t3tools/provider-core/server/notification";
92import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";
93import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
94import {
95 makeSubagentChildThread,
96 makeSubagentConversationArtifacts,
97 subagentThreadTitle,
98} from "@t3tools/provider-core/server/subagentProjection";
99import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
100import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition";
101import { OPENCODE_PROVIDER, openCodePermissionRequestKind } from "../adapter.ts";
102import { openCodeToolTurnItem } from "../toolItems.ts";
103
104const OpenCode2ProviderCapabilities = {
105 sessions: {
106 // One server serves every location, so one session runtime owns them all.
107 supportsMultipleProviderThreadsPerSession: true,
108 supportsModelSwitchInSession: true,
109 supportsProviderSwitchingViaHandoff: true,
110 // A mode change rewrites the session's rules when its next turn resumes it.
111 supportsRuntimeModeSwitchInSession: true,
112 pendingRequestsSurviveRestart: false,
113 },
114 threads: {
115 canCreateEmptyThread: true,
116 canReadThreadSnapshot: true,
117 canRollbackThread: true,
118 canForkThread: true,
119 canForkFromTurn: true,
120 canForkFromSubagentThread: false,
121 exposesNativeThreadId: true,
122 },
123 turns: {
124 exposesNativeTurnId: false,
125 emitsTurnStarted: true,
126 emitsTurnCompleted: true,
127 supportsInterrupt: true,
128 supportsActiveSteering: true,
129 supportsSteeringByInterruptRestart: true,
130 // T3 holds queued messages and starts each as its own turn once the one
131 // before it ends, so OpenCode's own `queue` delivery is never used.
132 supportsQueuedMessages: true,
133 terminalStatusQuality: "strong",
134 },
135 streaming: {
136 streamsAssistantText: true,
137 streamsReasoning: true,
138 streamsToolOutput: false,
139 streamsPlanText: false,
140 emitsMessageCompleted: true,
141 },
142 tools: {
143 exposesToolItemIds: true,
144 emitsToolStarted: true,
145 emitsToolCompleted: true,
146 emitsToolOutput: true,
147 supportsMcpTools: false,
148 supportsDynamicToolCallbacks: false,
149 },
150 approvals: {
151 supportsCommandApproval: true,
152 supportsFileReadApproval: true,
153 supportsFileChangeApproval: true,
154 supportsApplyPatchApproval: true,
155 approvalsHaveNativeRequestIds: true,
156 approvalCallbacksAreLiveOnly: true,
157 approvalsCanOriginateFromSubagents: true,
158 },
159 planning: {
160 emitsPlanUpdated: false,
161 emitsTodoList: false,
162 emitsProposedPlan: false,
163 supportsStructuredQuestions: true,
164 planDeltasHaveItemIds: false,
165 },
166 subagents: {
167 supportsSubagents: true,
168 exposesSubagentThreadIds: true,
169 emitsSubagentLifecycle: true,
170 canWaitForSubagents: true,
171 canCloseSubagents: false,
172 canForkSubagentThread: false,
173 },
174 context: {
175 acceptsSystemContext: false,
176 acceptsDeveloperContext: false,
177 acceptsSyntheticUserContext: true,
178 canGenerateSummaries: false,
179 canConsumeHandoffSummaries: true,
180 supportsDeltaHandoff: true,
181 supportsFullThreadHandoff: true,
182 maxRecommendedHandoffChars: null,
183 },
184 checkpointing: {
185 appCanCheckpointFilesystem: true,
186 supportsNestedCheckpointScopes: true,
187 providerCanRollbackConversation: true,
188 providerRollbackReturnsSnapshot: true,
189 providerCanReadConversationSnapshot: true,
190 },
191 identity: {
192 nativeThreadIds: "strong",
193 nativeTurnIds: "weak",
194 nativeItemIds: "strong",
195 nativeRequestIds: "strong",
196 },
197 // OpenCode enforces each runtime mode through the session's permission rules.
198 runtimePolicy: { enforcement: "native" },
199} satisfies OrchestrationV2ProviderCapabilities;
200
201type EventOf<T extends OpenCodeEvent["type"]> = Extract<OpenCodeEvent, { readonly type: T }>;
202type Tokens = EventOf<"session.step.ended">["data"]["tokens"];
203
204/** What a turn reports against: a run's own turn, or a subagent session's runless one. */
205interface TurnOwner {
206 readonly appThread: OrchestrationV2AppThread;
207 readonly threadId: OrchestrationV2AppThread["id"];
208 readonly runId: RunId | null;
209 readonly runOrdinal: number;
210 readonly rootNodeId: ProviderAdapter.ProviderAdapterV2TurnInput["rootNodeId"];
211 readonly modelSelection: ModelSelection;
212 /** Where the turn runs: a subagent runs in its parent turn's directory. */
213 readonly runtimePolicy: Pick<ProviderAdapter.ProviderAdapterV2RuntimePolicy, "cwd">;
214}
215
216interface ActiveTurn {
217 readonly input: TurnOwner;
218 providerTurn: OrchestrationV2ProviderTurn;
219 /** Open text and reasoning blocks, keyed `<assistantMessageID>:<kind>:<ordinal>`. */
220 readonly texts: Map<string, OpenBlock>;
221 readonly tools: Map<string, { readonly name: string; input: Record<string, unknown> }>;
222 readonly startedAt: Map<string, DateTime.Utc>;
223 readonly ordinals: Map<string, number>;
224 nextOrdinal: number;
225 /** Input includes cache reads and writes, as 1.x reports it; the parts are also kept apart. */
226 readonly usage: {
227 input: number;
228 cached: number;
229 cacheWrite: number;
230 output: number;
231 reasoning: number;
232 };
233 steps: number;
234 lastStep: Tokens | undefined;
235 /**
236 * The history item this turn's execution follows, for a turn with no prompt
237 * id of T3's: the session's newest item before a `/name` command (null when
238 * the history was empty), since `session.command` takes no id and answers
239 * without one; the report a continuation turn's execution answers; or, on a
240 * subagent's session, the first item queued for its turn. A reconnect
241 * backfills that turn from everything after this. Other turns backfill from
242 * their own prompt's id (`nativeTurnRef`).
243 */
244 before: string | null | undefined;
245 /** The compaction running in this turn, `/compact` or OpenCode's own when the context fills. */
246 compaction: { readonly nativeId: string; readonly startedAt: DateTime.Utc } | undefined;
247 compactions: number;
248 interrupted: boolean;
249 /**
250 * Set until the turn's prompt, command or compaction is sent. A Stop before
251 * then has nothing on the server to stop, so it ends the turn here and the
252 * request is never sent.
253 */
254 unsent: boolean;
255 /**
256 * Steers sent into this turn that OpenCode has not delivered yet, by inbox
257 * id. Each one wakes the session, so an execution that ends before reading
258 * one is followed by another that does, and the turn spans both.
259 */
260 readonly steers: Set<string>;
261 /** This turn's steers OpenCode delivered or cancelled: a retried one is not sent again. */
262 readonly settledInbox: Set<string>;
263 /** How the last execution ended, while the turn waits for the one that delivers its steers. */
264 heldEnd: TurnTerminal | undefined;
265 /**
266 * Set on a turn started after a timed-out Stop's run left the server: that
267 * run's tail can still be on the stream, and everything before this turn's
268 * own `session.execution.started` belongs to it.
269 */
270 awaitingStart: boolean;
271 /**
272 * Prefixes a subagent session's tool ids in native item ids: the model
273 * names tool calls, so a child's could repeat its parent's.
274 */
275 readonly scope: string;
276 /** Set once the turn calls `subagent`: its usage then leaves out the subagents'. */
277 usedSubagents: boolean;
278}
279
280/** A `subagent` tool call. A background one outlives the turn that made it. */
281interface SubagentCall {
282 readonly toolId: string;
283 readonly nativeId: string;
284 /** The calling session and the turn whose item the call is. */
285 readonly state: ThreadState;
286 readonly turn: ActiveTurn;
287 readonly ordinal: number;
288 readonly startedAt: DateTime.Utc;
289 prompt: string;
290 title: string | null;
291 agent: string | undefined;
292 model: string | null;
293 /** The tool returned while the subagent runs on; the report OpenCode gives its parent settles it. */
294 background: boolean;
295 /** The running call whose subagent run this call joined, so that run's one report settles both. */
296 joined: SubagentCall | undefined;
297 child: ThreadState | undefined;
298 status: OrchestrationV2Subagent["status"];
299 result: string | null;
300 completedAt: DateTime.Utc | null;
301}
302
303/**
304 * An execution OpenCode ran on a thread's session without T3 asking: the
305 * parent's answer once a background subagent ended. Its events are held until
306 * the continuation turn T3 opens for it takes them.
307 */
308interface Wake {
309 /** The message its continuation run starts from, which names the wake in that turn's input. */
310 readonly messageId: MessageId | undefined;
311 readonly events: Array<OpenCode2Client.OpenCode2StreamEvent>;
312 running: boolean;
313 /** The background subagents whose end it answers, as its turn's notification names them. */
314 readonly reports: Array<BackgroundWorkReport>;
315 /** What OpenCode told the model, the continuation's prompt text. */
316 readonly detail: string | null;
317 /** Stopped, or taken by a user turn: the continuation it asked for is not needed. */
318 dropped: boolean;
319 /** The last report it delivered (inbox ids are history ids): its execution follows it. */
320 readonly after: string | undefined;
321 /** The first report it delivered, where fork and rollback cut before its turn. */
322 readonly first: string | undefined;
323 /**
324 * Subagent sessions OpenCode announced while it was held: their calls are
325 * in its events, so only its replay names them.
326 */
327 readonly children: Set<string>;
328}
329
330interface OpenBlock {
331 readonly block: { readonly assistantMessageID: string; readonly ordinal: number };
332 readonly kind: "text" | "reasoning";
333 readonly startedAt: DateTime.Utc;
334 text: string;
335}
336
337interface ThreadState {
338 readonly sessionId: string;
339 providerThread: OrchestrationV2ProviderThread;
340 readonly providerTurns: Map<string, OrchestrationV2ProviderTurn>;
341 active: ActiveTurn | undefined;
342 /** What the native session runs now, so a changed selection is switched before prompting. */
343 model: ModelRef | undefined;
344 /**
345 * Set when a turn ended here while OpenCode may still be running it: a Stop
346 * that timed out, a prompt whose request failed without a clear answer, or a
347 * request T3 could not answer. Execution events carry only the session id,
348 * so the next execution end belongs to that run; it clears this and ends no turn.
349 */
350 unsettled: boolean;
351 /**
352 * Whether the run `unsettled` waits on had started when it was marked. A
353 * prompt whose request failed may start its run late; that start is the
354 * run's own, and only a start after it means its end was lost.
355 */
356 unsettledStarted: boolean;
357 /** The session's location, where its agents' path rules are read. */
358 directory: string;
359 /**
360 * The agent the session runs (`build`, or `plan` in plan mode); its own path
361 * rules stay in force, and a changed mode switches it before prompting.
362 */
363 agent: string;
364 /** The native session's title as T3 last read or wrote it. */
365 title: string | undefined;
366 /** The native session's rules as T3 last read or wrote them, and the policy they are for. */
367 rules: ReadonlyArray<Rule> | undefined;
368 policy: RulesPolicy;
369 /** "Allow … this session" answers, kept in the session's rules while T3 has it open. */
370 readonly grants: Array<Rule>;
371 /** The session's `subagent` calls still running, by tool call id. */
372 readonly calls: Map<string, SubagentCall>;
373 /**
374 * Set on a subagent's session: the call that runs it and the thread it
375 * shows in. Each of its executions is a runless turn there.
376 */
377 subagent:
378 | {
379 call: SubagentCall;
380 readonly appThread: OrchestrationV2AppThread;
381 turns: number;
382 /** The prompt its next execution answers, shown as that turn's user message. */
383 prompt: string | undefined;
384 /**
385 * The first item queued for the session since its last turn began (its
386 * prompt, or a report it answers): where the next turn's history
387 * begins, which a reconnect reads back.
388 */
389 queued: string | undefined;
390 }
391 | undefined;
392 /** Executions OpenCode started on its own, oldest first, each waiting for its turn. */
393 readonly wakes: Array<Wake>;
394 /**
395 * Background subagents whose end OpenCode queued for this session and has
396 * not delivered yet, by inbox id. The wake that delivers them names them.
397 */
398 readonly reports: Map<
399 string,
400 {
401 readonly inboxId: string;
402 readonly childId: string;
403 readonly report: BackgroundWorkReport;
404 readonly text: string;
405 }
406 >;
407 /**
408 * Background subagent sessions T3 stopped. OpenCode wakes the parent to
409 * report them; a wake that reports only these is stopped as well.
410 */
411 readonly stoppedChildren: Set<string>;
412 /**
413 * Steers a turn ended without (a Stop, a failure). They stay in OpenCode's
414 * inbox, where the next prompt would deliver them first, so it cancels them.
415 */
416 readonly strandedSteers: Set<string>;
417 /** T3's MCP server as registered for this thread, and the instructions entry sent with it. */
418 mcp:
419 | { readonly name: string; readonly directory: string; readonly credential: string }
420 | undefined;
421 instructions: string | undefined;
422}
423
424type TurnTerminal =
425 | { readonly status: "completed" | "interrupted" }
426 | { readonly status: "failed"; readonly failure: ReturnType<typeof makeProviderFailure> };
427
428type Rule = Permission.Rule;
429type RulesPolicy = Pick<
430 ProviderAdapter.ProviderAdapterV2RuntimePolicy,
431 "runtimeMode" | "interactionMode"
432>;
433type NativeForm = EventOf<"form.created">["data"]["form"];
434
435/** A permission ask or question form shown to the user and not answered yet. */
436interface PendingRequest {
437 readonly request: OrchestrationV2RuntimeRequest;
438 readonly item: OrchestrationV2TurnItem;
439 readonly node: OrchestrationV2ExecutionNode;
440 /** The thread the request is shown on: the asking session's, or its parent's. */
441 readonly state: ThreadState;
442 readonly turn: ActiveTurn;
443 /** The session that asked: the thread's own, or one of its subagents'. */
444 readonly sessionId: string;
445 /** Set once T3 sends its answer; the orchestrator has already recorded it. */
446 answering: boolean;
447 readonly native:
448 | {
449 readonly type: "permission";
450 readonly id: string;
451 readonly action: string;
452 readonly resources: ReadonlyArray<string>;
453 readonly save: ReadonlyArray<string>;
454 }
455 | { readonly type: "form"; readonly id: string; readonly form: NativeForm };
456}
457
458const rule = (action: string, effect: Rule["effect"]): Rule => ({ action, resource: "*", effect });
459
460/**
461 * A session's permission rules. OpenCode checks the agent's rules and then
462 * these, and the last rule that matches decides, so these override the
463 * agent's. `paths` are the agent's own allows for its directories (saved tool
464 * output, the plan agent's plan directory), which the blanket rules here would
465 * otherwise override; `grants` are "Always allow this session" answers.
466 */
467/**
468 * T3's MCP server is registered per directory, not per session, so each thread
469 * gets its own `t3-code-<thread>` entry with its own credential. OpenCode names
470 * an MCP tool's permission `<server>_<tool>` (non-alphanumerics become `_`).
471 * OpenCode skips every tool of a server whose name is over 64 characters (its
472 * tool namespace limit), and its router rejects adding one over 100, so a
473 * thread id that does not fit is replaced by a digest of it.
474 */
475export const t3McpServerName = Effect.fn("t3McpServerName")(function* (threadId: string) {
476 const name = `t3-code-${threadId.replaceAll(/[^a-zA-Z0-9_-]/g, "_")}`;
477 if (name.length <= 64) return name;
478 const crypto = yield* Crypto.Crypto;
479 const digest = yield* crypto
480 .digest("SHA-256", new TextEncoder().encode(threadId))
481 .pipe(Effect.orDie);
482 return `t3-code-${Hex.encode(digest).slice(0, 16)}`;
483});
484
485/**
486 * The rules that keep T3's MCP servers to their own thread, after the mode's:
487 * the last matching rule wins, so every thread's T3 server is denied and then
488 * this thread's own is allowed again, in every mode. A subagent's session
489 * inherits the thread's.
490 */
491const mcpRules = (mcpServerName: string | null): ReadonlyArray<Rule> =>
492 mcpServerName === null
493 ? []
494 : [
495 { action: "t3-code-*", resource: "*", effect: "deny" },
496 { action: `${mcpServerName}_*`, resource: "*", effect: "allow" },
497 ];
498
499const sessionRules = (
500 policy: RulesPolicy,
501 paths: ReadonlyArray<Rule>,
502 grants: ReadonlyArray<Rule>,
503 mcpServerName: string | null,
504): ReadonlyArray<Rule> => [
505 ...(policy.runtimeMode === "full-access"
506 ? [rule("*", "allow")]
507 : [
508 rule("shell", "ask"),
509 rule("edit", policy.runtimeMode === "auto-accept-edits" ? "allow" : "ask"),
510 rule("external_directory", "ask"),
511 ]),
512 ...grants,
513 // Plan mode writes only its plan, which `paths` allows again. Shell and read
514 // are never denied: the free tier refuses sessions whose rules deny them.
515 ...(policy.interactionMode === "plan" ? [rule("edit", "deny")] : []),
516 ...paths,
517 ...mcpRules(mcpServerName),
518];
519
520const sameRules = (left: ReadonlyArray<Rule> | undefined, right: ReadonlyArray<Rule>) =>
521 left?.length === right.length &&
522 left.every(
523 (entry, index) =>
524 entry.action === right[index]?.action &&
525 entry.resource === right[index]?.resource &&
526 entry.effect === right[index]?.effect,
527 );
528
529/**
530 * Sent with a declined permission. A reject without a message is OpenCode's
531 * "stop": it ends the whole execution, which is Cancel.
532 */
533const DECLINED = "The user declined this request.";
534
535/**
536 * Steered into the session before a decline: a declined shell call reaches the
537 * model only as "Unable to execute command", which it retries.
538 */
539const declinedNote = (action: string, resources: ReadonlyArray<string>) =>
540 `The user declined the ${action} request${resources.length === 0 ? "" : ` (${resources.join(", ")})`}. Do not retry it; continue without it or ask the user how to proceed.`;
541
542/** The session-wide choice for a request whose `save` patterns OpenCode would remember. */
543const sessionGrantLabel = (action: string, save: ReadonlyArray<string>) =>
544 save.every((pattern) => pattern === "*")
545 ? `Allow every ${action} request this session`
546 : `Allow ${save.join(", ")} this session`;
547
548const text = (value: string | undefined, fallback: string) => value?.trim() || fallback;
549
550/**
551 * A form's fields as T3 questions, or why T3 cannot ask them: a link to open,
552 * a field shown only for another answer, a hidden field, or a number or yes/no
553 * value. OpenCode's question tool only asks text and multi-select fields.
554 */
555const formQuestions = (
556 form: NativeForm,
557):
558 | { readonly questions: ReadonlyArray<OrchestrationV2UserInputQuestion> }
559 | { readonly unsupported: string } => {
560 const questions: Array<OrchestrationV2UserInputQuestion> = [];
561 for (const [index, field] of form.fields.entries()) {
562 if (field.type === "external") return { unsupported: "a link to open" };
563 if (field.type !== "string" && field.type !== "multiselect") {
564 return { unsupported: `a ${field.type} value` };
565 }
566 if (field.hidden === true) return { unsupported: "a hidden field" };
567 if ((field.when?.length ?? 0) > 0) return { unsupported: "a field that depends on another" };
568 const header = text(field.title, `Question ${index + 1}`);
569 const options = (field.options ?? []).map((option) => {
570 const label = text(option.label, text(option.value, "Option"));
571 return { label, description: text(option.description, label), value: option.value };
572 });
573 questions.push({
574 id: field.key,
575 header,
576 question: text(field.description, header),
577 options,
578 multiSelect: field.type === "multiselect",
579 allowCustomAnswer: field.custom === true || options.length === 0,
580 });
581 }
582 return { questions };
583};
584
585/** T3's answers in OpenCode's shape: a list for a multi-select, text otherwise. */
586const formAnswer = (form: NativeForm, answers: Readonly<Record<string, unknown>>) => {
587 const answer: Record<string, string | ReadonlyArray<string>> = {};
588 for (const field of form.fields) {
589 const raw = answers[field.key];
590 const values = (Array.isArray(raw) ? raw : [raw]).filter(
591 (value): value is string => typeof value === "string" && value.trim().length > 0,
592 );
593 if (values.length > 0) {
594 answer[field.key] = field.type === "multiselect" ? values : values.join(", ");
595 }
596 }
597 return answer;
598};
599
600const approves = (decision: ProviderApprovalDecision) =>
601 decision === "accept" || decision === "acceptForSession" || decision === "acceptAlways";
602
603/**
604 * An answer to a request OpenCode already dropped (its execution ended, or
605 * its session is gone): nothing waits on it, so it counts as delivered.
606 */
607const permissionGone = {
608 PermissionNotFoundError: () => Effect.void,
609 SessionNotFoundError: () => Effect.void,
610};
611const formGone = {
612 FormNotFoundError: () => Effect.void,
613 FormAlreadySettledError: () => Effect.void,
614 SessionNotFoundError: () => Effect.void,
615};
616
617/** The session rules an agent keeps for its own directories, which T3's blanket rules would override. */
618const agentPaths = (rules: ReadonlyArray<Rule>) =>
619 rules.filter(
620 (entry) =>
621 entry.effect === "allow" &&
622 entry.resource !== "*" &&
623 (entry.action === "edit" || entry.action === "external_directory"),
624 );
625
626const ref = (nativeId: string, strength: "strong" | "weak" = "strong") => ({
627 driver: OPENCODE_PROVIDER,
628 nativeId,
629 strength,
630});
631
632const sessionIdOf = (providerThread: OrchestrationV2ProviderThread) => {
633 const nativeId = providerThread.nativeThreadRef?.nativeId;
634 return nativeId === undefined || nativeId === null
635 ? Effect.fail(
636 new ProviderAdapter.ProviderAdapterProtocolError({
637 driver: OPENCODE_PROVIDER,
638 detail: `Provider thread ${providerThread.id} has no OpenCode session`,
639 }),
640 )
641 : Effect.succeed(nativeId);
642};
643
644const textOf = (content: ReadonlyArray<{ readonly type: string; readonly text?: string }>) =>
645 content.flatMap((part) => (part.type === "text" && part.text ? [part.text] : [])).join("\n");
646
647const stringField = (record: Readonly<Record<string, unknown>> | undefined, key: string) => {
648 const value = record?.[key];
649 return typeof value === "string" && value.trim().length > 0 ? value : undefined;
650};
651
652/** How a background subagent ended, from the `state` of OpenCode's report to its parent. */
653const reportOutcome = (state: string | undefined) =>
654 state === "completed"
655 ? ("completed" as const)
656 : state === "error"
657 ? ("failed" as const)
658 : state === "cancelled"
659 ? ("cancelled" as const)
660 : ("unknown" as const);
661
662/** A subagent's answer, without the `<subagent …>` wrapper OpenCode gives the model. */
663const subagentOutput = (text: string) =>
664 /^<subagent\b[^>]*>\n?([\s\S]*?)\n?<\/subagent>$/.exec(text.trim())?.[1] ?? text;
665
666const isContinuation = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) =>
667 turnInput.message.createdBy === "agent" && turnInput.message.creationSource === "provider";
668
669/** The native turn id of a continuation turn, which answers a wake and has no prompt. */
670const wakeTurnId = (sessionId: string, attemptId: string) => `${sessionId}:wake:${attemptId}`;
671const isWakeTurn = (turn: OrchestrationV2ProviderTurn) =>
672 turn.nativeTurnRef?.nativeId?.includes(":wake:") === true;
673
674const INTERRUPT_TIMEOUT = "10 seconds";
675/** The session instructions entry T3 writes its per-turn system prompt to. */
676const INSTRUCTIONS_KEY = "t3-code";
677/** A lost event stream is resubscribed this many times, this far apart, before the session breaks. */
678const RECONNECT_ATTEMPTS = 5;
679const RECONNECT_DELAY = "2 seconds";
680const RECONCILE_TIMEOUT = "15 seconds";
681/** How long a new turn waits for a reconnect in progress. */
682const RECONNECT_WAIT = "30 seconds";
683/** A background subagent's result when its end was lost with the event stream. */
684const LOST_BACKGROUND =
685 "T3 Code lost its connection to OpenCode while this subagent ran, so its result is not shown.";
686/** How long a turn waits on the directory's commands or skills before sending the text as is. */
687const INVENTORY_TIMEOUT = "5 seconds";
688const ACTIVE_CHECK_TIMEOUT = "5 seconds";
689/** Answers that mean the server refused a prompt; any other failure may have been accepted. */
690const CLEAR_PROMPT_REJECTIONS: ReadonlySet<string> = new Set([
691 "InvalidRequestError",
692 "ConflictError",
693 "UnauthorizedError",
694 "CommandNotFoundError",
695 "SkillNotFoundError",
696]);
697
698export const OPENCODE_2_STILL_STOPPING =
699 "OpenCode is still stopping the previous turn. Send the message again in a moment.";
700const REQUEST_REPLY_TIMEOUT = "10 seconds";
701
702/** Whether an answer to a paused request reached the server, trying twice. */
703const deliver = <E>(answer: Effect.Effect<void, E>) =>
704 answer.pipe(
705 Effect.retry({ times: 1 }),
706 Effect.timeout(REQUEST_REPLY_TIMEOUT),
707 Effect.exit,
708 Effect.map(Exit.isSuccess),
709 );
710
711type ModelRef = ReturnType<typeof Model.Ref.make>;
712
713/**
714 * The user message ids T3 prompts under. OpenCode takes a client id (it must
715 * start with `msg_`) and answers a repeat of one in the same session with the
716 * item it already has, so a retried request never queues a second message,
717 * and a turn knows the message fork and rollback cut at before OpenCode
718 * answers. The id is unique across the whole server, which refuses it in any
719 * other session (409), so it names the session: another T3 database or
720 * environment on the same server repeats thread ids and run ordinals, never
721 * session ids. A turn keeps its id in `nativeTurnRef`.
722 */
723const turnPromptId = (sessionId: string, attemptId: string) =>
724 SessionMessage.ID.make(`msg_t3_turn_${sessionId}:${attemptId}`);
725const steerPromptId = (sessionId: string, messageId: string) =>
726 SessionMessage.ID.make(`msg_t3_steer_${sessionId}:${messageId}`);
727
728/**
729 * The user message a turn prompted with. Turns from before T3 chose prompt ids
730 * recorded `<session>:attempt:<id>`, which is no message.
731 */
732const promptOf = (turn: OrchestrationV2ProviderTurn) => {
733 const nativeId = turn.nativeTurnRef?.nativeId;
734 return nativeId?.startsWith("msg_") === true ? nativeId : undefined;
735};
736
737/**
738 * Where fork and rollback cut to keep the session's turns up to `kept` (all of
739 * them before the first turn when undefined): before the message that starts
740 * the next turn that reached OpenCode, which is in `prompts`. That is a turn's
741 * prompt, or for a continuation the report it answers. A turn refused before
742 * it prompted is not in the session and is passed over, and so is a
743 * continuation that took no report. `null` means nothing follows, so there is
744 * no cut; a later turn from before T3 chose prompt ids has no known message,
745 * so no cut is safe.
746 */
747const boundaryAfter = (
748 turns: ReadonlyArray<OrchestrationV2ProviderTurn>,
749 providerThreadId: OrchestrationV2ProviderThread["id"],
750 kept: OrchestrationV2ProviderTurn | undefined,
751 prompts: ReadonlySet<string>,
752) => {
753 const later = turns
754 .filter(
755 (turn) =>
756 turn.providerThreadId === providerThreadId &&
757 (kept === undefined || turn.ordinal > kept.ordinal),
758 )
759 .toSorted((left, right) => left.ordinal - right.ordinal);
760 if (later.some((turn) => promptOf(turn) === undefined && !isWakeTurn(turn))) {
761 return Effect.fail(
762 new ProviderAdapter.ProviderAdapterProtocolError({
763 driver: OPENCODE_PROVIDER,
764 detail:
765 "This OpenCode conversation has turns from an earlier T3 Code version, so it can't be cut there.",
766 }),
767 );
768 }
769 return Effect.succeed(
770 later.map(promptOf).find((messageId) => messageId !== undefined && prompts.has(messageId)) ??
771 null,
772 );
773};
774
775// Errors already in the adapter channel keep their tag; only lower-level ones are wrapped.
776const isProviderAdapterError = Schema.is(ProviderAdapter.ProviderAdapterV2Error);
777
778/**
779 * The model OpenCode should run for a `provider/model` slug and its reasoning
780 * variant, or undefined for any other slug: sending none would run OpenCode's
781 * default while T3 records the requested model.
782 */
783const modelRef = (selection: ProviderAdapter.ProviderAdapterV2TurnInput["modelSelection"]) => {
784 const parsed = OpenCodeRuntime.parseOpenCodeModelSlug(selection.model);
785 if (parsed === null) return undefined;
786 const variant = getModelSelectionStringOptionValue(selection, "variant");
787 return Model.Ref.make({
788 providerID: Provider.ID.make(parsed.providerID),
789 id: Model.ID.make(parsed.modelID),
790 ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }),
791 });
792};
793const malformedModel = (model: string) =>
794 `OpenCode model '${model}' must use provider/model format`;
795const sameModel = (left: ModelRef, right: ModelRef | undefined) =>
796 left.providerID === right?.providerID &&
797 left.id === right?.id &&
798 (left.variant ?? "default") === (right?.variant ?? "default");
799
800/** OpenCode's own agents for T3's interaction modes; plan mode is its read-only `plan` agent. */
801const agentFor = (input: ProviderAdapter.ProviderAdapterV2TurnInput) =>
802 input.runtimePolicy.interactionMode === "plan" ? "plan" : "build";
803
804/** `/name args` naming one of the workspace's commands, which OpenCode expands itself. */
805const commandOf = (text: string) => {
806 const match = /^\/([^\s/]+)(?:\s+([\s\S]*))?$/.exec(text.trim());
807 return match === null ? undefined : { name: match[1]!, text: match[2] ?? "" };
808};
809
810/** Whether a prompt names any skill at all, before the directory's skills are read. */
811const SKILL_MENTION = new RegExp(SKILL_MENTION_PATTERN.source, "u");
812
813/** The workspace skills a prompt names with the composer's `$skill` tokens. */
814const skillsNamed = (text: string, known: ReadonlySet<string>) => [
815 ...new Set(
816 [...text.matchAll(SKILL_MENTION_PATTERN)].flatMap((match) =>
817 known.has(match[2] ?? "") ? [match[2]!] : [],
818 ),
819 ),
820];
821
822/**
823 * The turn's own tokens: steps add up, and the last step's input is the live
824 * context size. A subagent's tokens are its own session's, never these.
825 */
826const turnTokenUsage = (turn: ActiveTurn, status: OrchestrationV2ProviderTurn["status"]) =>
827 turn.steps === 0
828 ? {
829 usageScope: "main_agent" as const,
830 usageStatus: "unavailable" as const,
831 hasSubagents: turn.usedSubagents,
832 }
833 : {
834 usageScope: "main_agent" as const,
835 usageStatus: status === "completed" ? ("complete" as const) : ("partial" as const),
836 inputTokens: turn.usage.input,
837 cachedInputTokens: turn.usage.cached,
838 cacheCreationTokens: turn.usage.cacheWrite,
839 outputTokens: turn.usage.output,
840 reasoningTokens: turn.usage.reasoning,
841 hasSubagents: turn.usedSubagents,
842 };
843
844/**
845 * The adapter for one provider instance. It talks to the instance's
846 * {@link OpenCode2Server.OpenCode2Server}, which the driver builds from the instance's settings.
847 */
848export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: ProviderInstanceId) {
849 const server = yield* OpenCode2Server.OpenCode2Server;
850 const idAllocator = yield* IdAllocator.IdAllocatorV2;
851 const host = yield* ProviderHost.ProviderHost;
852 const mcpSessions = yield* McpProviderSessions.McpProviderSessions;
853 const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
854 const crypto = yield* Crypto.Crypto;
855 const driver = OPENCODE_PROVIDER;
856 const mcpServerNameFor = (threadId: string) =>
857 t3McpServerName(threadId).pipe(Effect.provideService(Crypto.Crypto, crypto));
858
859 /**
860 * Lends the instance's server to a session until its scope closes. A spawned
861 * server that died is started again on the next borrow.
862 */
863 const borrow = Effect.gen(function* () {
864 const lent = yield* Deferred.make<
865 OpenCode2Server.OpenCode2Connection,
866 OpenCodeRuntime.OpenCodeRuntimeError
867 >();
868 yield* server
869 .withConnection((connection) =>
870 Deferred.succeed(lent, connection).pipe(Effect.andThen(Effect.never)),
871 )
872 .pipe(
873 Effect.catch((error) => Deferred.fail(lent, error)),
874 Effect.forkScoped,
875 );
876 return yield* Deferred.await(lent);
877 });
878
879 const openSession = Effect.fn("OpenCode2Adapter.openSession")(function* (
880 input: Parameters<ProviderAdapter.ProviderAdapterV2["Service"]["openSession"]>[0],
881 initial: {
882 readonly connection: OpenCode2Server.OpenCode2Connection;
883 readonly scope: Scope.Closeable;
884 },
885 ) {
886 let connection = initial.connection;
887 // Replaced when the session reconnects to a restarted server.
888 let client = connection.client;
889 const sessionScope = yield* Effect.scope;
890 // Context windows by directory, then `provider/model`: a project's own
891 // OpenCode config can change a model's limits, and this one runtime serves
892 // the instance's threads in every directory.
893 const contextWindows = new Map<string, Map<string, number>>();
894 /** A thread without a worktree runs where T3 does, as its session is created. */
895 const directoryOf = (cwd: string | null | undefined) => cwd ?? host.paths.cwd;
896 const windowOf = (cwd: string | null | undefined, model: string) =>
897 contextWindows.get(directoryOf(cwd))?.get(model);
898 const now = yield* DateTime.now;
899 let session: OrchestrationV2ProviderSession = {
900 id: input.providerSessionId,
901 driver,
902 providerInstanceId: instanceId,
903 status: "ready",
904 cwd: input.runtimePolicy.cwd ?? host.paths.cwd,
905 model: input.modelSelection.model,
906 capabilities: OpenCode2ProviderCapabilities,
907 createdAt: now,
908 updatedAt: now,
909 lastError: null,
910 };
911 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event, Cause.Done>();
912 // Thread sessions, and the sessions of their subagents.
913 const threads = new Map<string, ThreadState>();
914 // A subagent's session, by its id, to the thread whose session started it.
915 const childOwners = new Map<string, ThreadState>();
916 // What OpenCode announced about a subagent's session, until its call names it.
917 const announced = new Map<string, EventOf<"session.created">["data"]>();
918 // Subagent sessions a dropped held execution started, and the subagents
919 // they start, by the session the first of them was called from. Nothing
920 // names them, so they no longer route to its thread; they stay here until
921 // a Stop of that thread stops them or they are gone.
922 const strays = new Map<string, ThreadState>();
923 // Sessions of these threads with an execution running, seen on the stream.
924 const busy = new Set<string>();
925 const pending = new Map<RuntimeRequestId, PendingRequest>();
926 // Events and a wake's replay into its turn are handled one at a time, in order.
927 const lock = yield* Semaphore.make(1);
928 // Sessions whose `revert.clear` has not had its empty execution yet.
929 const clearing = new Map<string, Deferred.Deferred<void>>();
930 // Sessions a failed rollback may have left with a staged revert.
931 const stagedReverts = new Set<string>();
932 // Starting a turn and cutting the history take turns on a session: each
933 // checks that the other is not running before its own requests yield.
934 const sessionGates = yield* KeyedLock.make<string>();
935 const exclusive =
936 (providerThread: OrchestrationV2ProviderThread) =>
937 <A, E, R>(effect: Effect.Effect<A, E, R>) => {
938 const sessionId = providerThread.nativeThreadRef?.nativeId;
939 if (sessionId == null) return effect;
940 return sessionGates.withLock(sessionId, effect);
941 };
942 const emit = (event: ProviderAdapter.ProviderAdapterV2Event) =>
943 Queue.offer(events, event).pipe(Effect.asVoid);
944 const ownerOf = (sessionId: string) => childOwners.get(sessionId) ?? threads.get(sessionId);
945 /** Marks a run T3 ended while OpenCode may still run it (see `unsettled`). */
946 const markUnsettled = (state: ThreadState) => {
947 state.unsettled = true;
948 state.unsettledStarted = busy.has(state.sessionId);
949 };
950
951 const newThreadState = (
952 sessionId: string,
953 providerThread: OrchestrationV2ProviderThread,
954 directory: string,
955 subagent: ThreadState["subagent"],
956 ): ThreadState => ({
957 sessionId,
958 providerThread,
959 providerTurns: new Map(),
960 active: undefined,
961 model: undefined,
962 unsettled: false,
963 unsettledStarted: false,
964 directory,
965 agent: "build",
966 title: undefined,
967 rules: undefined,
968 policy: input.runtimePolicy,
969 grants: [],
970 calls: new Map(),
971 subagent,
972 wakes: [],
973 reports: new Map(),
974 stoppedChildren: new Set(),
975 strandedSteers: new Set(),
976 mcp: undefined,
977 instructions: undefined,
978 });
979
980 /** The thread whose session started this one, through any nesting. */
981 const rootOf = (state: ThreadState): ThreadState => {
982 let current = state;
983 while (current.subagent !== undefined) current = current.subagent.call.state;
984 return current;
985 };
986
987 /** A thread's session and its subagents' sessions, through any nesting. */
988 const sessionsOf = (thread: ThreadState): ReadonlyArray<ThreadState> => [
989 thread,
990 ...[...threads.values()].filter((state) => {
991 for (let above = state.subagent?.call.state; above; above = above.subagent?.call.state) {
992 if (above === thread) return true;
993 }
994 return false;
995 }),
996 ];
997
998 /**
999 * A thread's `subagent` calls still running, its subagents' included: a
1000 * subagent's background call runs on after the call that made it ended.
1001 */
1002 const runningCalls = (thread: ThreadState): ReadonlyArray<SubagentCall> =>
1003 sessionsOf(thread).flatMap((state) => [...state.calls.values()]);
1004
1005 /** The `subagent` calls that lead to a session, from its own up to the thread's. */
1006 const callsAbove = (sessionId: string) => {
1007 const calls: Array<SubagentCall> = [];
1008 let current = threads.get(sessionId);
1009 while (current?.subagent !== undefined) {
1010 calls.push(current.subagent.call);
1011 current = current.subagent.call.state;
1012 }
1013 return calls;
1014 };
1015
1016 /**
1017 * The turn a request from `sessionId` is shown under. A background
1018 * subagent's goes to the turn that started it, whose run waits on the
1019 * subagent; anything else goes to the thread's running turn.
1020 */
1021 const requestTurn = (sessionId: string) => {
1022 const state = ownerOf(sessionId);
1023 if (state === undefined) return undefined;
1024 const background = callsAbove(sessionId).findLast((call) => call.background);
1025 if (background !== undefined) {
1026 return isOrchestrationV2WorkActive(background.status)
1027 ? { state, turn: background.turn }
1028 : undefined;
1029 }
1030 return state.active === undefined ? undefined : { state, turn: state.active };
1031 };
1032
1033 /**
1034 * What a session still owes its thread once the thread's turn ended:
1035 * background subagents, held executions, reports OpenCode queued for the
1036 * follow-up it will start, and anything a subagent's session still runs,
1037 * such as its own follow-up to such a report.
1038 */
1039 const owesWork = (state: ThreadState) =>
1040 state.wakes.length > 0 ||
1041 state.reports.size > 0 ||
1042 (state.subagent !== undefined && busy.has(state.sessionId)) ||
1043 [...state.calls.values()].some((call) => call.background);
1044
1045 /** Work that outlives the thread's turn, on its own session or its subagents'. */
1046 const hasBackground = (thread: ThreadState) => sessionsOf(thread).some(owesWork);
1047
1048 const setSessionStatus = (
1049 status: OrchestrationV2ProviderSession["status"],
1050 lastError: string | null,
1051 ) =>
1052 Effect.gen(function* () {
1053 session = { ...session, status, lastError, updatedAt: yield* DateTime.now };
1054 yield* emit({ type: "provider_session.updated", driver, providerSession: session });
1055 });
1056
1057 const ordinalOf = (turn: ActiveTurn, nativeId: string) => {
1058 const known = turn.ordinals.get(nativeId);
1059 if (known !== undefined) return known;
1060 const next = turn.nextOrdinal++;
1061 turn.ordinals.set(nativeId, next);
1062 return next;
1063 };
1064
1065 const itemBase = (
1066 state: ThreadState,
1067 turn: ActiveTurn,
1068 nativeId: string,
1069 status: OrchestrationV2TurnItem["status"],
1070 startedAt: DateTime.Utc,
1071 completedAt: DateTime.Utc | null,
1072 updatedAt: DateTime.Utc,
1073 ) => ({
1074 id: idAllocator.derive.turnItemFromProviderItem({ driver, nativeItemId: nativeId }),
1075 threadId: turn.input.threadId,
1076 runId: turn.input.runId,
1077 nodeId: idAllocator.derive.nodeFromProviderItem({ driver, nativeItemId: nativeId }),
1078 providerThreadId: state.providerThread.id,
1079 providerTurnId: turn.providerTurn.id,
1080 nativeItemRef: ref(nativeId),
1081 parentItemId: null,
1082 ordinal: ordinalOf(turn, nativeId),
1083 status,
1084 title: null,
1085 startedAt,
1086 completedAt,
1087 updatedAt,
1088 });
1089
1090 const emitNode = (
1091 state: ThreadState,
1092 turn: ActiveTurn,
1093 nativeId: string,
1094 kind: OrchestrationV2ExecutionNode["kind"],
1095 status: OrchestrationV2ExecutionNode["status"],
1096 startedAt: DateTime.Utc,
1097 completedAt: DateTime.Utc | null,
1098 ) =>
1099 emit({
1100 type: "node.updated",
1101 driver,
1102 node: {
1103 id: idAllocator.derive.nodeFromProviderItem({ driver, nativeItemId: nativeId }),
1104 threadId: turn.input.threadId,
1105 runId: turn.input.runId,
1106 parentNodeId: turn.input.rootNodeId,
1107 rootNodeId: turn.input.rootNodeId,
1108 kind,
1109 status,
1110 countsForRun: false,
1111 providerThreadId: state.providerThread.id,
1112 providerTurnId: turn.providerTurn.id,
1113 nativeItemRef: ref(nativeId),
1114 runtimeRequestId: null,
1115 checkpointScopeId: null,
1116 startedAt,
1117 completedAt,
1118 },
1119 });
1120
1121 /** One text or reasoning block, re-emitted with its accumulated text on every change. */
1122 const emitText = Effect.fnUntraced(function* (
1123 state: ThreadState,
1124 turn: ActiveTurn,
1125 data: {
1126 readonly assistantMessageID: string;
1127 readonly ordinal: number;
1128 readonly text?: string;
1129 },
1130 kind: "text" | "reasoning",
1131 update: (current: string) => string,
1132 completed = "text" in data,
1133 ) {
1134 const nativeId = `${data.assistantMessageID}:${kind}:${data.ordinal}`;
1135 const updatedAt = yield* DateTime.now;
1136 const entry = turn.texts.get(nativeId) ?? {
1137 block: data,
1138 kind,
1139 startedAt: updatedAt,
1140 text: "",
1141 };
1142 entry.text = update(entry.text);
1143 if (completed) turn.texts.delete(nativeId);
1144 else turn.texts.set(nativeId, entry);
1145 if (entry.text.length === 0) return;
1146 const status = completed ? "completed" : "running";
1147 const completedAt = completed ? updatedAt : null;
1148 const nodeKind = kind === "text" ? "assistant_message" : "reasoning";
1149 yield* emitNode(state, turn, nativeId, nodeKind, status, entry.startedAt, completedAt);
1150 const base = itemBase(state, turn, nativeId, status, entry.startedAt, completedAt, updatedAt);
1151 if (kind === "reasoning") {
1152 yield* emit({
1153 type: "turn_item.updated",
1154 driver,
1155 turnItem: { ...base, type: "reasoning", text: entry.text, streaming: !completed },
1156 });
1157 return;
1158 }
1159 const messageId = idAllocator.derive.messageFromProviderItem({
1160 driver,
1161 nativeItemId: nativeId,
1162 });
1163 const message: OrchestrationV2ConversationMessage = {
1164 createdBy: "agent",
1165 creationSource: "provider",
1166 id: messageId,
1167 threadId: turn.input.threadId,
1168 runId: turn.input.runId,
1169 nodeId: base.nodeId,
1170 role: "assistant",
1171 text: entry.text,
1172 attachments: [],
1173 streaming: !completed,
1174 createdAt: entry.startedAt,
1175 updatedAt,
1176 };
1177 yield* emit({ type: "message.updated", driver, message });
1178 yield* emit({
1179 type: "turn_item.updated",
1180 driver,
1181 turnItem: {
1182 ...base,
1183 type: "assistant_message",
1184 messageId,
1185 text: entry.text,
1186 streaming: !completed,
1187 },
1188 });
1189 });
1190
1191 const emitTool = Effect.fnUntraced(function* (
1192 state: ThreadState,
1193 turn: ActiveTurn,
1194 id: string,
1195 status: "running" | "completed" | "failed" | "interrupted",
1196 result?: { readonly output: string | undefined; readonly metadata: unknown },
1197 ) {
1198 const tool = turn.tools.get(id);
1199 if (tool === undefined) return;
1200 const updatedAt = yield* DateTime.now;
1201 const startedAt = turn.startedAt.get(id) ?? updatedAt;
1202 const completedAt = status === "running" ? null : updatedAt;
1203 const nativeId = `${turn.scope}${id}`;
1204 yield* emitNode(state, turn, nativeId, "tool_call", status, startedAt, completedAt);
1205 yield* emit({
1206 type: "turn_item.updated",
1207 driver,
1208 turnItem: openCodeToolTurnItem(
1209 itemBase(state, turn, nativeId, status, startedAt, completedAt, updatedAt),
1210 {
1211 name: tool.name,
1212 input: tool.input,
1213 output: result?.output,
1214 completedMetadata: status === "completed" ? result?.metadata : undefined,
1215 },
1216 ),
1217 });
1218 });
1219
1220 /** The turn's compaction item; its summary is the text OpenCode carries forward. */
1221 const emitCompaction = Effect.fnUntraced(function* (
1222 state: ThreadState,
1223 turn: ActiveTurn,
1224 status: "running" | "completed" | "failed" | "interrupted",
1225 summary?: string,
1226 ) {
1227 const compaction = turn.compaction;
1228 if (compaction === undefined) return;
1229 const updatedAt = yield* DateTime.now;
1230 const completedAt = status === "running" ? null : updatedAt;
1231 yield* emit({
1232 type: "turn_item.updated",
1233 driver,
1234 turnItem: {
1235 ...itemBase(
1236 state,
1237 turn,
1238 compaction.nativeId,
1239 status,
1240 compaction.startedAt,
1241 completedAt,
1242 updatedAt,
1243 ),
1244 nodeId: turn.input.rootNodeId,
1245 type: "compaction",
1246 driver,
1247 title:
1248 status === "running"
1249 ? "Compacting context"
1250 : status === "completed"
1251 ? "Context compacted"
1252 : status === "failed"
1253 ? "Compaction failed"
1254 : "Compaction interrupted",
1255 ...(summary === undefined || summary.length === 0 ? {} : { summary }),
1256 },
1257 });
1258 if (status !== "running") turn.compaction = undefined;
1259 });
1260
1261 const emitProviderTurn = (
1262 state: ThreadState,
1263 turn: ActiveTurn,
1264 providerTurn: OrchestrationV2ProviderTurn,
1265 ) => {
1266 state.providerTurns.set(String(providerTurn.id), providerTurn);
1267 return emit({
1268 type: "provider_turn.updated",
1269 driver,
1270 threadId: turn.input.threadId,
1271 providerTurn,
1272 });
1273 };
1274
1275 const makeTurn = (
1276 owner: TurnOwner,
1277 providerTurn: OrchestrationV2ProviderTurn,
1278 options: { readonly scope: string; readonly awaitingStart: boolean },
1279 ): ActiveTurn => ({
1280 input: owner,
1281 providerTurn,
1282 texts: new Map(),
1283 tools: new Map(),
1284 startedAt: new Map(),
1285 ordinals: new Map(),
1286 nextOrdinal: providerTurn.ordinal * 100 + 1,
1287 usage: { input: 0, cached: 0, cacheWrite: 0, output: 0, reasoning: 0 },
1288 steps: 0,
1289 lastStep: undefined,
1290 before: undefined,
1291 compaction: undefined,
1292 compactions: 0,
1293 interrupted: false,
1294 unsent: false,
1295 steers: new Set(),
1296 settledInbox: new Set(),
1297 heldEnd: undefined,
1298 awaitingStart: options.awaitingStart,
1299 scope: options.scope,
1300 usedSubagents: false,
1301 });
1302
1303 /** A subagent call as its parent turn's item, node and subagent row. */
1304 const emitSubagent = Effect.fnUntraced(function* (call: SubagentCall) {
1305 const updatedAt = yield* DateTime.now;
1306 const { turn, state } = call;
1307 const nodeId = idAllocator.derive.nodeFromProviderItem({
1308 driver,
1309 nativeItemId: call.nativeId,
1310 });
1311 const completedAt = isOrchestrationV2WorkActive(call.status)
1312 ? null
1313 : (call.completedAt ?? updatedAt);
1314 const childProviderThreadId = call.child?.providerThread.id ?? null;
1315 const childThreadId = call.child?.subagent?.appThread.id ?? null;
1316 yield* emit({
1317 type: "node.updated",
1318 driver,
1319 node: {
1320 id: nodeId,
1321 threadId: turn.input.threadId,
1322 runId: turn.input.runId,
1323 parentNodeId: turn.input.rootNodeId,
1324 rootNodeId: turn.input.rootNodeId,
1325 kind: "subagent",
1326 status: call.status,
1327 countsForRun: false,
1328 providerThreadId: childProviderThreadId ?? state.providerThread.id,
1329 providerTurnId: turn.providerTurn.id,
1330 nativeItemRef: ref(call.nativeId),
1331 runtimeRequestId: null,
1332 checkpointScopeId: null,
1333 startedAt: call.startedAt,
1334 completedAt,
1335 },
1336 });
1337 yield* emit({
1338 type: "subagent.updated",
1339 driver,
1340 subagent: {
1341 id: nodeId,
1342 threadId: turn.input.threadId,
1343 runId: turn.input.runId,
1344 parentNodeId: turn.input.rootNodeId,
1345 origin: "provider_native",
1346 createdBy: "agent",
1347 driver,
1348 providerInstanceId: instanceId,
1349 providerThreadId: childProviderThreadId,
1350 childThreadId,
1351 nativeTaskRef: ref(call.nativeId),
1352 prompt: call.prompt,
1353 title: call.title,
1354 model: call.model,
1355 status: call.status,
1356 result: call.result,
1357 startedAt: call.startedAt,
1358 completedAt,
1359 updatedAt,
1360 },
1361 });
1362 yield* emit({
1363 type: "turn_item.updated",
1364 driver,
1365 turnItem: {
1366 id: idAllocator.derive.turnItemFromProviderItem({ driver, nativeItemId: call.nativeId }),
1367 threadId: turn.input.threadId,
1368 runId: turn.input.runId,
1369 nodeId,
1370 providerThreadId: state.providerThread.id,
1371 providerTurnId: turn.providerTurn.id,
1372 nativeItemRef: ref(call.nativeId),
1373 parentItemId: null,
1374 ordinal: call.ordinal,
1375 status: call.status,
1376 title: call.title,
1377 startedAt: call.startedAt,
1378 completedAt,
1379 updatedAt,
1380 type: "subagent",
1381 subagentId: nodeId,
1382 origin: "provider_native",
1383 driver,
1384 providerInstanceId: instanceId,
1385 childThreadId,
1386 prompt: call.prompt,
1387 result: call.result,
1388 },
1389 });
1390 yield* syncRoster(rootOf(call.state));
1391 });
1392
1393 /**
1394 * Lists a thread's background subagents on its provider thread, as typed
1395 * `subagent` work: the thread shows it waits on them after its turn ends,
1396 * and a Stop reaches them. Emitted only when the list changes.
1397 */
1398 const syncRoster = Effect.fnUntraced(function* (state: ThreadState) {
1399 const roster = runningCalls(state)
1400 .filter((call) => call.background)
1401 .map((call) => ({
1402 taskId: call.nativeId,
1403 kind: "subagent" as const,
1404 ...(call.title?.trim() ? { description: call.title.trim() } : {}),
1405 ...(call.child?.subagent === undefined
1406 ? {}
1407 : { childThreadId: call.child.subagent.appThread.id }),
1408 }));
1409 const current = state.providerThread.pendingBackgroundTasks ?? [];
1410 if (
1411 current.length === roster.length &&
1412 current.every(
1413 (task, index) =>
1414 task.taskId === roster[index]?.taskId &&
1415 task.description === roster[index]?.description &&
1416 (task.kind === "subagent" ? task.childThreadId : undefined) ===
1417 roster[index]?.childThreadId,
1418 )
1419 ) {
1420 return;
1421 }
1422 state.providerThread = {
1423 ...state.providerThread,
1424 pendingBackgroundTasks: roster,
1425 updatedAt: yield* DateTime.now,
1426 };
1427 yield* emit({
1428 type: "provider_thread.updated",
1429 driver,
1430 providerThread: state.providerThread,
1431 });
1432 });
1433
1434 const withReportedModel = (
1435 providerThread: OrchestrationV2ProviderThread,
1436 model: ModelRef | undefined,
1437 ): OrchestrationV2ProviderThread => {
1438 if (model === undefined) return providerThread;
1439 const modelSelection: ModelSelection = {
1440 instanceId,
1441 model: `${model.providerID}/${model.id}`,
1442 options: model.variant === undefined ? [] : [{ id: "variant", value: model.variant }],
1443 };
1444 if (
1445 providerThread.nativeMetadata?.modelSelection !== undefined &&
1446 modelSelectionsEqual(providerThread.nativeMetadata.modelSelection, modelSelection)
1447 )
1448 return providerThread;
1449 return {
1450 ...providerThread,
1451 nativeMetadata: { ...providerThread.nativeMetadata, modelSelection },
1452 };
1453 };
1454
1455 /**
1456 * Gives a subagent call its session once both are known: OpenCode names
1457 * the session on the call's progress, and announces it just before. The
1458 * session becomes a child thread under the call's item.
1459 */
1460 const attachChild = Effect.fnUntraced(function* (call: SubagentCall, childId: string) {
1461 if (call.child !== undefined) return;
1462 const info = announced.get(childId);
1463 announced.delete(childId);
1464 const now = yield* DateTime.now;
1465 const nodeId = idAllocator.derive.nodeFromProviderItem({
1466 driver,
1467 nativeItemId: call.nativeId,
1468 });
1469 const childThreadId = idAllocator.derive.threadFromProviderThread({
1470 driver,
1471 nativeThreadId: childId,
1472 providerInstanceId: instanceId,
1473 });
1474 const providerThreadId = idAllocator.derive.providerThread({
1475 driver,
1476 nativeThreadId: childId,
1477 providerInstanceId: instanceId,
1478 });
1479 if (info?.model !== undefined) call.model = `${info.model.providerID}/${info.model.id}`;
1480 call.title = call.title ?? info?.title ?? null;
1481 const parentThread = call.turn.input.appThread;
1482 const appThread = makeSubagentChildThread({
1483 parentThread,
1484 childThreadId,
1485 parentNodeId: nodeId,
1486 activeProviderThreadId: providerThreadId,
1487 providerInstanceId: instanceId,
1488 modelSelection: {
1489 instanceId,
1490 model: call.model ?? call.turn.input.modelSelection.model,
1491 },
1492 title: subagentThreadTitle({
1493 parentTitle: parentThread.title,
1494 title: call.title,
1495 prompt: call.prompt,
1496 ordinal: call.ordinal,
1497 }),
1498 now,
1499 createdBy: "agent",
1500 creationSource: "provider",
1501 });
1502 const providerThread: OrchestrationV2ProviderThread = {
1503 id: providerThreadId,
1504 driver,
1505 providerInstanceId: instanceId,
1506 providerSessionId: input.providerSessionId,
1507 appThreadId: childThreadId,
1508 ownerNodeId: nodeId,
1509 nativeThreadRef: ref(childId),
1510 nativeConversationHeadRef: null,
1511 status: "active",
1512 firstRunOrdinal: null,
1513 lastRunOrdinal: null,
1514 handoffIds: [],
1515 forkedFrom: null,
1516 createdAt: now,
1517 updatedAt: now,
1518 };
1519 // A subagent called again keeps its session's state: how many turns it
1520 // ran (each one's native id is its own), its grants and rules, and the
1521 // background work an earlier call left running, which Stop must reach.
1522 const previous = threads.get(childId);
1523 // Called again while it runs, the subagent takes the prompt as a steer
1524 // and OpenCode reports that run once, for every call that joined it.
1525 if (previous?.active !== undefined) {
1526 call.joined = [...call.state.calls.values()].findLast(
1527 (candidate) =>
1528 candidate !== call && candidate.child === previous && candidate.joined === undefined,
1529 );
1530 }
1531 const subagent = {
1532 call,
1533 appThread,
1534 turns: previous?.subagent?.turns ?? 0,
1535 prompt: call.prompt,
1536 queued: undefined,
1537 };
1538 const child =
1539 previous ?? newThreadState(childId, providerThread, call.state.directory, subagent);
1540 child.subagent = subagent;
1541 child.model = info?.model ?? child.model;
1542 child.providerThread = withReportedModel(providerThread, child.model);
1543 child.directory = call.state.directory;
1544 child.agent = info?.agent ?? call.agent ?? child.agent;
1545 // OpenCode gives a new session its parent's rules, which are the thread's.
1546 child.rules = info?.permissions ?? child.rules;
1547 threads.set(childId, child);
1548 childOwners.set(childId, rootOf(call.state));
1549 call.child = child;
1550 yield* emit({ type: "app_thread.created", driver, appThread });
1551 yield* emit({
1552 type: "provider_thread.updated",
1553 driver,
1554 providerThread: child.providerThread,
1555 });
1556 yield* emitSubagent(call);
1557 // A session called again was not made now, so it may hold the rules of
1558 // a mode the thread has left. OpenCode applies a rules change to the
1559 // asks after it, so it gets the thread's as soon as its call names it.
1560 if (info === undefined) {
1561 yield* writeRules(child, rootOf(call.state).policy).pipe(
1562 Effect.timeout(REQUEST_REPLY_TIMEOUT),
1563 Effect.ignore({ log: true }),
1564 );
1565 }
1566 });
1567
1568 /**
1569 * Ends a subagent call and every call under it that is still running. A
1570 * background one outlives the call that made it, as it outlives a turn,
1571 * unless that call failed.
1572 */
1573 const settleCall: (
1574 call: SubagentCall,
1575 status: OrchestrationV2Subagent["status"],
1576 result?: string,
1577 ) => Effect.Effect<void> = Effect.fnUntraced(function* (call, status, result) {
1578 if (!isOrchestrationV2WorkActive(call.status)) return;
1579 call.status = status;
1580 call.completedAt = yield* DateTime.now;
1581 if (result !== undefined) call.result = result;
1582 call.state.calls.delete(call.toolId);
1583 const child = call.child;
1584 if (child !== undefined) {
1585 // A snapshot: settling a call removes it from the map.
1586 for (const nested of Array.from(child.calls.values())) {
1587 if (nested.background && status !== "failed") continue;
1588 yield* settleCall(nested, status === "completed" ? "interrupted" : status);
1589 }
1590 if (child.active !== undefined && status !== "completed") {
1591 yield* finishTurn(child, { status: "interrupted" });
1592 }
1593 child.providerThread = {
1594 ...child.providerThread,
1595 status: "idle",
1596 updatedAt: call.completedAt,
1597 };
1598 yield* emit({
1599 type: "provider_thread.updated",
1600 driver,
1601 providerThread: child.providerThread,
1602 });
1603 }
1604 yield* emitSubagent(call);
1605 });
1606
1607 /**
1608 * Opens the runless turn a subagent's session runs each execution in, on
1609 * its child thread, with the prompt it answers as the turn's user message.
1610 */
1611 const startChildTurn = Effect.fnUntraced(function* (child: ThreadState) {
1612 const subagent = child.subagent;
1613 if (subagent === undefined || child.active !== undefined) return;
1614 subagent.turns += 1;
1615 const startedAt = yield* DateTime.now;
1616 const nativeTurnId = `${child.sessionId}:turn:${subagent.turns}`;
1617 const rootNodeId = idAllocator.derive.nodeFromProviderItem({
1618 driver,
1619 nativeItemId: `${nativeTurnId}:root`,
1620 });
1621 const providerTurn: OrchestrationV2ProviderTurn = {
1622 id: idAllocator.derive.providerTurn({ driver, nativeTurnId }),
1623 providerThreadId: child.providerThread.id,
1624 nodeId: rootNodeId,
1625 runAttemptId: null,
1626 nativeTurnRef: ref(nativeTurnId, "weak"),
1627 ordinal: subagent.turns,
1628 status: "running",
1629 startedAt,
1630 completedAt: null,
1631 };
1632 const turn = makeTurn(
1633 {
1634 appThread: subagent.appThread,
1635 threadId: subagent.appThread.id,
1636 runId: null,
1637 runOrdinal: subagent.call.turn.input.runOrdinal,
1638 rootNodeId,
1639 modelSelection: subagent.appThread.modelSelection,
1640 runtimePolicy: subagent.call.turn.input.runtimePolicy,
1641 },
1642 providerTurn,
1643 { scope: `${child.sessionId}:`, awaitingStart: false },
1644 );
1645 turn.before = subagent.queued;
1646 subagent.queued = undefined;
1647 child.active = turn;
1648 yield* emit({
1649 type: "node.updated",
1650 driver,
1651 node: {
1652 id: rootNodeId,
1653 threadId: turn.input.threadId,
1654 runId: null,
1655 parentNodeId: null,
1656 rootNodeId,
1657 kind: "root_turn",
1658 status: "running",
1659 countsForRun: false,
1660 providerThreadId: child.providerThread.id,
1661 providerTurnId: providerTurn.id,
1662 nativeItemRef: ref(nativeTurnId, "weak"),
1663 runtimeRequestId: null,
1664 checkpointScopeId: null,
1665 startedAt,
1666 completedAt: null,
1667 },
1668 });
1669 yield* emitProviderTurn(child, turn, providerTurn);
1670 const prompt = subagent.prompt;
1671 subagent.prompt = undefined;
1672 if (prompt !== undefined && prompt.length > 0) {
1673 const nativeId = `${nativeTurnId}:prompt`;
1674 const artifacts = makeSubagentConversationArtifacts({
1675 messageId: idAllocator.derive.messageFromProviderItem({ driver, nativeItemId: nativeId }),
1676 senderThreadId: subagent.call.turn.input.threadId,
1677 turnItemId: idAllocator.derive.turnItemFromProviderItem({
1678 driver,
1679 nativeItemId: nativeId,
1680 }),
1681 threadId: turn.input.threadId,
1682 rootNodeId,
1683 providerThreadId: child.providerThread.id,
1684 providerTurnId: providerTurn.id,
1685 nativeItemRef: ref(nativeId, "weak"),
1686 role: "user",
1687 text: prompt,
1688 ordinal: ordinalOf(turn, nativeId),
1689 now: startedAt,
1690 });
1691 yield* emit({ type: "message.updated", driver, message: artifacts.message });
1692 yield* emit({ type: "turn_item.updated", driver, turnItem: artifacts.turnItem });
1693 }
1694 });
1695
1696 /**
1697 * Asks the orchestrator for the continuation turn a held wake needs. It
1698 * names the subagents whose end woke the thread. A wake a Stop or an
1699 * earlier turn already took needs no turn, so its offer is dropped.
1700 */
1701 const offerWake = Effect.fnUntraced(function* (state: ThreadState, wake: Wake) {
1702 const route = state.providerThread.appThreadId;
1703 if (route === null || wake.messageId === undefined) return;
1704 const notification = backgroundWorkNotification(wake.reports);
1705 yield* continuationRequests.offer({
1706 threadId: route,
1707 providerThreadId: state.providerThread.id,
1708 driver,
1709 detail: wake.detail,
1710 messageId: wake.messageId,
1711 ...(notification === null ? {} : { notification }),
1712 dispatchIfCurrent: (dispatch) =>
1713 wake.dropped ? Effect.succeed(Option.none()) : Effect.map(dispatch, Option.some),
1714 });
1715 });
1716
1717 const finishTurn = Effect.fnUntraced(function* (
1718 state: ThreadState,
1719 terminal: TurnTerminal,
1720 threadDisposition: "reusable" | "broken" = "reusable",
1721 ) {
1722 const turn = state.active;
1723 if (turn === undefined) return;
1724 state.active = undefined;
1725 for (const inboxID of turn.steers) state.strandedSteers.add(inboxID);
1726 // OpenCode drops a request when the asking session's execution ends. A
1727 // subagent's request shown on this turn ends with it too, unless the
1728 // subagent runs in the background past a turn that did not fail.
1729 for (const entry of pending.values()) {
1730 const background = callsAbove(entry.sessionId).some((call) => call.background);
1731 if (
1732 entry.sessionId === state.sessionId ||
1733 (entry.turn === turn && !(background && terminal.status !== "failed"))
1734 ) {
1735 yield* settleRequest(entry, "cancelled");
1736 }
1737 }
1738 const completedAt = yield* DateTime.now;
1739 // Blocks still open when the execution ends are final as they stand.
1740 for (const open of turn.texts.values()) {
1741 yield* emitText(state, turn, open.block, open.kind, (text) => text, true);
1742 }
1743 for (const id of turn.tools.keys()) {
1744 yield* emitTool(
1745 state,
1746 turn,
1747 id,
1748 terminal.status === "completed" ? "completed" : "interrupted",
1749 );
1750 }
1751 // A foreground subagent ends with the turn that waits on it. A background
1752 // one outlives a finished or interrupted turn (a user Stop has already
1753 // stopped it), and a failed turn stops it.
1754 // A snapshot: settling a call removes it from the map.
1755 for (const call of Array.from(state.calls.values())) {
1756 if (call.turn !== turn) continue;
1757 if (call.background && terminal.status !== "failed") continue;
1758 yield* settleCall(call, terminal.status === "completed" ? "completed" : terminal.status);
1759 }
1760 if (state.subagent !== undefined) {
1761 yield* emit({
1762 type: "node.updated",
1763 driver,
1764 node: {
1765 id: turn.input.rootNodeId,
1766 threadId: turn.input.threadId,
1767 runId: null,
1768 parentNodeId: null,
1769 rootNodeId: turn.input.rootNodeId,
1770 kind: "root_turn",
1771 status: terminal.status,
1772 countsForRun: false,
1773 providerThreadId: state.providerThread.id,
1774 providerTurnId: turn.providerTurn.id,
1775 nativeItemRef: turn.providerTurn.nativeTurnRef,
1776 runtimeRequestId: null,
1777 checkpointScopeId: null,
1778 startedAt: turn.providerTurn.startedAt,
1779 completedAt,
1780 },
1781 });
1782 }
1783 yield* emitCompaction(state, turn, terminal.status === "failed" ? "failed" : "interrupted");
1784 const window = windowOf(turn.input.runtimePolicy.cwd, turn.input.modelSelection.model);
1785 const lastStep = turn.lastStep;
1786 yield* emitProviderTurn(state, turn, {
1787 ...turn.providerTurn,
1788 status: terminal.status,
1789 completedAt,
1790 turnTokenUsage: turnTokenUsage(turn, terminal.status),
1791 ...(lastStep === undefined
1792 ? {}
1793 : {
1794 tokenUsage: {
1795 usedTokens:
1796 lastStep.input + lastStep.cache.read + lastStep.cache.write + lastStep.output,
1797 maxTokens: window ?? null,
1798 inputTokens: lastStep.input + lastStep.cache.read + lastStep.cache.write,
1799 cachedInputTokens: lastStep.cache.read,
1800 outputTokens: lastStep.output,
1801 reasoningOutputTokens: lastStep.reasoning,
1802 updatedAt: DateTime.formatIso(completedAt),
1803 },
1804 }),
1805 });
1806 // A subagent's session is its call's to settle: a turn there is runless,
1807 // and T3 has no terminal to wait on.
1808 if (state.subagent !== undefined) {
1809 const call = state.subagent.call;
1810 if (!call.background && terminal.status !== "completed") {
1811 yield* settleCall(call, terminal.status);
1812 }
1813 return;
1814 }
1815 state.providerThread = {
1816 ...state.providerThread,
1817 status: threadDisposition === "broken" ? "error" : "idle",
1818 updatedAt: completedAt,
1819 };
1820 yield* emit({
1821 type: "provider_thread.updated",
1822 driver,
1823 providerThread: state.providerThread,
1824 });
1825 const anyActive = [...threads.values()].some(
1826 (candidate) => candidate.subagent === undefined && candidate.active !== undefined,
1827 );
1828 yield* setSessionStatus(pending.size > 0 ? "waiting" : anyActive ? "running" : "ready", null);
1829 const base = {
1830 type: "turn.terminal" as const,
1831 driver,
1832 // The provider thread the turn started on. After a native fork the
1833 // session's own thread has a fresh id, and the terminal must name the
1834 // thread the start was recorded against.
1835 providerThreadId: turn.providerTurn.providerThreadId,
1836 providerTurnId: turn.providerTurn.id,
1837 runOrdinal: turn.input.runOrdinal,
1838 threadDisposition,
1839 };
1840 yield* emit(
1841 terminal.status === "failed"
1842 ? {
1843 ...base,
1844 status: "failed",
1845 failure: terminal.failure,
1846 failureItemOrdinal: ordinalOf(turn, `terminal-failure:${turn.providerTurn.id}`),
1847 }
1848 : { ...base, status: terminal.status, failure: null },
1849 );
1850 // A spawned server lists its models lazily, so a window still unknown is
1851 // read again for the next turn, off this stream so it never delays one.
1852 if (window === undefined) {
1853 yield* Effect.forkIn(readModels(directoryOf(turn.input.runtimePolicy.cwd)), sessionScope);
1854 }
1855 });
1856
1857 /**
1858 * Ends the turn a request T3 could not answer would block, and stops the
1859 * session: OpenCode waits on an unanswered request forever. The stopped
1860 * run's end is its own, not the next turn's.
1861 */
1862 const abandonRequest = Effect.fnUntraced(function* (state: ThreadState, reason: string) {
1863 yield* Effect.logWarning("Could not answer an OpenCode request; ending its turn.", {
1864 reason,
1865 });
1866 if (state.active === undefined) return;
1867 markUnsettled(state);
1868 yield* finishTurn(state, {
1869 status: "failed",
1870 failure: makeProviderFailure({
1871 message: "OpenCode is waiting on a request T3 Code couldn't answer.",
1872 class: "provider_error",
1873 }),
1874 });
1875 yield* client.session
1876 .interrupt({ sessionID: Session.ID.make(state.sessionId) })
1877 .pipe(Effect.timeout("2 seconds"), Effect.ignore({ log: true }));
1878 });
1879
1880 /** Stops tracking a request; the session is waiting only while any remain. */
1881 const forgetRequest = Effect.fnUntraced(function* (entry: PendingRequest) {
1882 if (!pending.delete(entry.request.id)) return false;
1883 if (pending.size === 0 && session.status === "waiting") {
1884 yield* setSessionStatus("running", null);
1885 }
1886 return true;
1887 });
1888
1889 /**
1890 * Settles a request OpenCode answered or dropped on its own. T3's own
1891 * answers are only forgotten: the orchestrator already recorded them.
1892 */
1893 const settleRequest = Effect.fnUntraced(function* (
1894 entry: PendingRequest,
1895 status: "resolved" | "cancelled",
1896 ) {
1897 if (!(yield* forgetRequest(entry)) || entry.answering) return;
1898 const now = yield* DateTime.now;
1899 const itemStatus = status === "resolved" ? "completed" : "cancelled";
1900 yield* emit({
1901 type: "runtime_request.updated",
1902 driver,
1903 threadId: entry.turn.input.threadId,
1904 runtimeRequest: { ...entry.request, status, resolvedAt: now },
1905 });
1906 yield* emit({
1907 type: "node.updated",
1908 driver,
1909 node: { ...entry.node, status: itemStatus, completedAt: now },
1910 });
1911 yield* emit({
1912 type: "turn_item.updated",
1913 driver,
1914 turnItem: { ...entry.item, status: itemStatus, completedAt: now, updatedAt: now },
1915 });
1916 });
1917
1918 /**
1919 * Stops a run no turn of T3's waits on (one a Stop left running, or one
1920 * found asking after a reconnect): a reject without a message and a
1921 * cancelled form both end OpenCode's execution. Its end is not a turn's.
1922 */
1923 const stopStaleRequest = (
1924 sessionId: string,
1925 native: { readonly type: "permission" | "form"; readonly id: string },
1926 ): Effect.Effect<void> =>
1927 native.type === "permission"
1928 ? client.permission
1929 .reply({
1930 sessionID: Session.ID.make(sessionId),
1931 requestID: Permission.ID.make(native.id),
1932 decision: "reject",
1933 })
1934 .pipe(
1935 Effect.catchTags(permissionGone),
1936 Effect.timeout(REQUEST_REPLY_TIMEOUT),
1937 Effect.ignore({ log: true }),
1938 )
1939 : client.session.form
1940 .cancel({ sessionID: sessionId, formID: Form.ID.make(native.id) })
1941 .pipe(
1942 Effect.catchTags(formGone),
1943 Effect.timeout(REQUEST_REPLY_TIMEOUT),
1944 Effect.ignore({ log: true }),
1945 );
1946
1947 /**
1948 * Stops what a session still waits on when this runtime first loads it:
1949 * T3 shows none of those requests (a restart or a closed session expired
1950 * them), and OpenCode would wait on them forever.
1951 */
1952 const stopLeftoverRequests = Effect.fnUntraced(function* (state: ThreadState) {
1953 const sessionID = Session.ID.make(state.sessionId);
1954 const listed = yield* Effect.all([
1955 client.permission.list({ sessionID }),
1956 client.session.form.list({ sessionID }),
1957 ]).pipe(Effect.timeout(ACTIVE_CHECK_TIMEOUT), Effect.option);
1958 if (listed._tag === "None") {
1959 return yield* Effect.logWarning("Could not list an OpenCode session's open requests.");
1960 }
1961 const [permissions, forms] = listed.value;
1962 if (permissions.length === 0 && forms.length === 0) return;
1963 // The stopped run's end is not the next turn's.
1964 markUnsettled(state);
1965 for (const request of permissions) {
1966 yield* stopStaleRequest(state.sessionId, { type: "permission", id: request.id });
1967 }
1968 for (const form of forms) {
1969 yield* stopStaleRequest(state.sessionId, { type: "form", id: form.id });
1970 }
1971 });
1972
1973 /**
1974 * Shows a permission ask or question form on the thread whose session (or
1975 * subagent session) asked, under that thread's running turn. A request no
1976 * turn of T3's is waiting on is left for OpenCode's own clients.
1977 */
1978 const showRequest = Effect.fnUntraced(function* (
1979 sessionId: string,
1980 native: PendingRequest["native"],
1981 body:
1982 | {
1983 readonly type: "approval_request";
1984 readonly requestKind: Extract<
1985 OrchestrationV2TurnItem,
1986 { type: "approval_request" }
1987 >["requestKind"];
1988 readonly prompt: string;
1989 readonly options: Extract<
1990 OrchestrationV2TurnItem,
1991 { type: "approval_request" }
1992 >["options"];
1993 }
1994 | {
1995 readonly type: "user_input_request";
1996 readonly questions: ReadonlyArray<OrchestrationV2UserInputQuestion>;
1997 },
1998 ) {
1999 const target = requestTurn(sessionId);
2000 if (target === undefined) return;
2001 const { state, turn } = target;
2002 if ([...pending.values()].some((entry) => entry.native.id === native.id)) return;
2003 const now = yield* DateTime.now;
2004 const requestId = yield* idAllocator.allocate.runtimeRequest({
2005 driver,
2006 providerTurnId: turn.providerTurn.id,
2007 nativeRequestId: native.id,
2008 });
2009 const nodeId = idAllocator.derive.approvalNode({ requestId });
2010 const request: OrchestrationV2RuntimeRequest = {
2011 id: requestId,
2012 nodeId,
2013 providerTurnId: turn.providerTurn.id,
2014 nativeRequestRef: ref(native.id),
2015 kind: body.type === "approval_request" ? body.requestKind : "user_input",
2016 status: "pending",
2017 responseCapability: { type: "live", providerSessionId: input.providerSessionId },
2018 createdAt: now,
2019 resolvedAt: null,
2020 };
2021 const node: OrchestrationV2ExecutionNode = {
2022 id: nodeId,
2023 threadId: turn.input.threadId,
2024 runId: turn.input.runId,
2025 parentNodeId: turn.input.rootNodeId,
2026 rootNodeId: turn.input.rootNodeId,
2027 kind: body.type,
2028 status: "waiting",
2029 countsForRun: false,
2030 providerThreadId: state.providerThread.id,
2031 providerTurnId: turn.providerTurn.id,
2032 nativeItemRef: ref(native.id),
2033 runtimeRequestId: requestId,
2034 checkpointScopeId: null,
2035 startedAt: now,
2036 completedAt: null,
2037 };
2038 const base = {
2039 id: idAllocator.derive.approvalTurnItem({ requestId }),
2040 threadId: turn.input.threadId,
2041 runId: turn.input.runId,
2042 nodeId,
2043 providerThreadId: state.providerThread.id,
2044 providerTurnId: turn.providerTurn.id,
2045 nativeItemRef: ref(native.id),
2046 parentItemId: null,
2047 ordinal: ordinalOf(turn, native.id),
2048 status: "waiting" as const,
2049 startedAt: now,
2050 completedAt: null,
2051 updatedAt: now,
2052 };
2053 const item: OrchestrationV2TurnItem =
2054 body.type === "approval_request"
2055 ? {
2056 ...base,
2057 title: native.type === "permission" ? native.action : null,
2058 type: "approval_request",
2059 requestId,
2060 requestKind: body.requestKind,
2061 prompt: body.prompt,
2062 ...(body.options === undefined ? {} : { options: body.options }),
2063 }
2064 : {
2065 ...base,
2066 title: "User input",
2067 type: "user_input_request",
2068 requestId,
2069 questions: body.questions,
2070 };
2071 pending.set(requestId, {
2072 request,
2073 item,
2074 node,
2075 state,
2076 turn,
2077 sessionId,
2078 answering: false,
2079 native,
2080 });
2081 yield* emit({ type: "node.updated", driver, node });
2082 yield* emit({
2083 type: "runtime_request.updated",
2084 driver,
2085 threadId: turn.input.threadId,
2086 runtimeRequest: request,
2087 });
2088 yield* emit({ type: "turn_item.updated", driver, turnItem: item });
2089 yield* setSessionStatus("waiting", null);
2090 });
2091
2092 const onPermissionAsked = Effect.fnUntraced(function* (data: Permission.Request) {
2093 // The asking session's own turn knows the tool: a subagent's session runs its own.
2094 const turn = threads.get(data.sessionID)?.active;
2095 const toolName =
2096 data.source === undefined ? undefined : turn?.tools.get(data.source.id)?.name;
2097 const save = data.save ?? [];
2098 yield* showRequest(
2099 data.sessionID,
2100 { type: "permission", id: data.id, action: data.action, resources: data.resources, save },
2101 {
2102 type: "approval_request",
2103 requestKind: openCodePermissionRequestKind(data.action, toolName),
2104 prompt: data.resources.length === 0 ? data.action : data.resources.join("\n"),
2105 // "Always" in OpenCode saves a grant for the whole project, so the
2106 // session-wide choice is T3's own rule on this session instead.
2107 options: [
2108 { decision: "cancel", label: "Cancel" },
2109 { decision: "decline", label: "Decline" },
2110 ...(save.length > 0
2111 ? [
2112 {
2113 decision: "acceptForSession" as const,
2114 label: sessionGrantLabel(data.action, save),
2115 },
2116 ]
2117 : []),
2118 { decision: "accept", label: "Approve" },
2119 ],
2120 },
2121 );
2122 });
2123
2124 const onFormCreated = Effect.fnUntraced(function* (form: NativeForm) {
2125 const target = requestTurn(form.sessionID);
2126 if (target === undefined) return;
2127 const { state } = target;
2128 const mapped = formQuestions(form);
2129 if ("questions" in mapped) {
2130 return yield* showRequest(
2131 form.sessionID,
2132 { type: "form", id: form.id, form },
2133 { type: "user_input_request", questions: mapped.questions },
2134 );
2135 }
2136 // Cancelling ends OpenCode's execution as a user stop, so the turn is
2137 // failed here with the reason and that stop's end is skipped.
2138 yield* Effect.logWarning("Declined an OpenCode form T3 Code cannot show.", {
2139 reason: mapped.unsupported,
2140 });
2141 const cancelled = yield* deliver(
2142 client.session.form.cancel({ sessionID: form.sessionID, formID: form.id }),
2143 );
2144 // The session that asked is the one blocked on the form and stopped by
2145 // its cancel: a subagent's own, whose parent reads its failed call and
2146 // goes on. A subagent's session has no Stop end to skip.
2147 const asker = threads.get(form.sessionID) ?? state;
2148 if (!cancelled) return yield* abandonRequest(asker, "form cancel failed");
2149 if (asker.subagent === undefined) markUnsettled(asker);
2150 yield* finishTurn(asker, {
2151 status: "failed",
2152 failure: makeProviderFailure({
2153 message: `OpenCode asked for ${mapped.unsupported}, which T3 Code can't show. The question was declined.`,
2154 class: "provider_error",
2155 }),
2156 });
2157 });
2158
2159 /**
2160 * A permission ask or question form, from the stream or read back after a
2161 * reconnect: shown under the turn it blocks, or stopped when it is the run
2162 * a Stop left behind, which nothing answers.
2163 */
2164 const onAsked = Effect.fnUntraced(function* (
2165 asked:
2166 | { readonly type: "permission"; readonly request: Permission.Request }
2167 | { readonly type: "form"; readonly form: NativeForm },
2168 ) {
2169 const asking = asked.type === "permission" ? asked.request.sessionID : asked.form.sessionID;
2170 // A stray belongs to no turn: its request is refused, which ends its run.
2171 if (strays.has(asking)) {
2172 return yield* stopStaleRequest(
2173 asking,
2174 asked.type === "permission"
2175 ? { type: "permission", id: asked.request.id }
2176 : { type: "form", id: asked.form.id },
2177 );
2178 }
2179 const state = ownerOf(asking);
2180 if (state === undefined) return;
2181 const target = requestTurn(asking);
2182 if (target !== undefined && !target.turn.awaitingStart) {
2183 if (asked.type === "permission") return yield* onPermissionAsked(asked.request);
2184 return yield* onFormCreated(asked.form);
2185 }
2186 if (state.unsettled || state.active?.awaitingStart === true) {
2187 yield* stopStaleRequest(
2188 asking,
2189 asked.type === "permission"
2190 ? { type: "permission", id: asked.request.id }
2191 : { type: "form", id: asked.form.id },
2192 );
2193 }
2194 });
2195
2196 const sessionOfEvent = (event: OpenCode2Client.OpenCode2StreamEvent) =>
2197 event.type === "unreadable.execution.ended" || event.type === "unreadable.execution.started"
2198 ? event.sessionID
2199 : "sessionID" in event.data && typeof event.data.sessionID === "string"
2200 ? event.data.sessionID
2201 : undefined;
2202
2203 const executionEnd = (type: string) =>
2204 type === "session.execution.succeeded" ||
2205 type === "session.execution.failed" ||
2206 type === "session.execution.interrupted";
2207
2208 /** A `subagent` call's item, made when the model starts it. */
2209 const startCall = Effect.fnUntraced(function* (
2210 state: ThreadState,
2211 turn: ActiveTurn,
2212 toolId: string,
2213 ) {
2214 const nativeId = `${turn.scope}${toolId}`;
2215 const call: SubagentCall = {
2216 toolId,
2217 nativeId,
2218 state,
2219 turn,
2220 ordinal: ordinalOf(turn, nativeId),
2221 startedAt: yield* DateTime.now,
2222 prompt: "",
2223 title: null,
2224 agent: undefined,
2225 model: null,
2226 background: false,
2227 joined: undefined,
2228 child: undefined,
2229 status: "running",
2230 result: null,
2231 completedAt: null,
2232 };
2233 turn.usedSubagents = true;
2234 state.calls.set(toolId, call);
2235 yield* emitSubagent(call);
2236 });
2237
2238 /**
2239 * One event for a session's running turn: its text, tools, steps and end.
2240 * A `subagent` call is its item and child thread rather than a tool.
2241 */
2242 const onTurnEvent = Effect.fnUntraced(function* (
2243 state: ThreadState,
2244 turn: ActiveTurn,
2245 event: OpenCode2Client.OpenCode2StreamEvent,
2246 ) {
2247 switch (event.type) {
2248 case "session.text.started":
2249 case "session.reasoning.started":
2250 case "session.text.delta":
2251 case "session.reasoning.delta":
2252 case "session.text.ended":
2253 case "session.reasoning.ended": {
2254 const data = event.data;
2255 const kind = event.type.startsWith("session.text.") ? "text" : "reasoning";
2256 return yield* emitText(state, turn, data, kind, (text) =>
2257 "delta" in data ? text + data.delta : "text" in data ? data.text : text,
2258 );
2259 }
2260 case "session.tool.input.started":
2261 // Its form is the item the user answers; the tool call would repeat it.
2262 if (event.data.name === "question") return;
2263 if (event.data.name === "subagent") return yield* startCall(state, turn, event.data.id);
2264 turn.tools.set(event.data.id, { name: event.data.name, input: {} });
2265 turn.startedAt.set(event.data.id, yield* DateTime.now);
2266 return yield* emitTool(state, turn, event.data.id, "running");
2267 case "session.tool.called": {
2268 const call = state.calls.get(event.data.id);
2269 if (call !== undefined) {
2270 const { input } = event.data;
2271 call.prompt = stringField(input, "prompt") ?? call.prompt;
2272 call.title = stringField(input, "description") ?? call.title;
2273 call.agent = stringField(input, "agent");
2274 call.model = stringField(input, "model") ?? call.model;
2275 call.background = input["background"] === true;
2276 return yield* emitSubagent(call);
2277 }
2278 const tool = turn.tools.get(event.data.id);
2279 if (tool !== undefined) tool.input = event.data.input;
2280 return yield* emitTool(state, turn, event.data.id, "running");
2281 }
2282 case "session.tool.progress": {
2283 const call = state.calls.get(event.data.id);
2284 const childId = stringField(event.data.metadata, "sessionID");
2285 if (call !== undefined && childId !== undefined) yield* attachChild(call, childId);
2286 return;
2287 }
2288 case "session.tool.success": {
2289 const call = state.calls.get(event.data.id);
2290 if (call !== undefined) {
2291 const childId = stringField(event.data.metadata, "sessionID");
2292 if (childId !== undefined) yield* attachChild(call, childId);
2293 // A background call returns at launch, and so does one that joined a
2294 // background run; the report settles it.
2295 if (event.data.metadata?.["status"] === "running") {
2296 if (call.background) return;
2297 call.background = true;
2298 return yield* emitSubagent(call);
2299 }
2300 return yield* settleCall(call, "completed", subagentOutput(textOf(event.data.content)));
2301 }
2302 const output = textOf(event.data.content);
2303 yield* emitTool(state, turn, event.data.id, "completed", {
2304 output,
2305 metadata: event.data.metadata,
2306 });
2307 turn.tools.delete(event.data.id);
2308 return;
2309 }
2310 case "session.tool.failed": {
2311 const aborted = event.data.error.type === "aborted";
2312 const call = state.calls.get(event.data.id);
2313 // A refused call (unknown agent, the nesting limit) is a failed subagent.
2314 if (call !== undefined) {
2315 return yield* settleCall(
2316 call,
2317 aborted ? "interrupted" : "failed",
2318 event.data.error.message,
2319 );
2320 }
2321 yield* emitTool(state, turn, event.data.id, aborted ? "interrupted" : "failed", {
2322 output: event.data.error.message,
2323 metadata: event.data.metadata,
2324 });
2325 turn.tools.delete(event.data.id);
2326 return;
2327 }
2328 // `/compact` and OpenCode's own compaction when the context fills
2329 // (`reason: "auto"`) both run inside a turn's execution.
2330 case "session.compaction.started":
2331 turn.compaction = {
2332 nativeId: `${turn.providerTurn.id}:compaction:${turn.compactions++}`,
2333 startedAt: yield* DateTime.now,
2334 };
2335 return yield* emitCompaction(state, turn, "running");
2336 case "session.compaction.ended":
2337 return yield* emitCompaction(state, turn, "completed", event.data.text);
2338 case "session.compaction.failed":
2339 return yield* emitCompaction(state, turn, "failed");
2340 case "session.step.ended":
2341 case "session.step.failed": {
2342 const tokens = event.data.tokens;
2343 if (tokens === undefined) return;
2344 turn.steps += 1;
2345 turn.lastStep = tokens;
2346 turn.usage.input += tokens.input + tokens.cache.read + tokens.cache.write;
2347 turn.usage.cached += tokens.cache.read;
2348 turn.usage.cacheWrite += tokens.cache.write;
2349 turn.usage.output += tokens.output + tokens.reasoning;
2350 turn.usage.reasoning += tokens.reasoning;
2351 return;
2352 }
2353 case "session.execution.started":
2354 // The execution that delivers steers the last one ended without.
2355 turn.heldEnd = undefined;
2356 return;
2357 case "session.execution.succeeded":
2358 return yield* endExecution(state, turn, {
2359 status: turn.interrupted ? "interrupted" : "completed",
2360 });
2361 case "session.execution.interrupted":
2362 return yield* endExecution(state, turn, { status: "interrupted" });
2363 case "session.execution.failed":
2364 return yield* endExecution(state, turn, {
2365 status: "failed",
2366 failure: makeProviderFailure({
2367 message: event.data.error.message,
2368 code: event.data.error.type,
2369 class: "provider_error",
2370 }),
2371 });
2372 default:
2373 return;
2374 }
2375 });
2376
2377 /**
2378 * An execution OpenCode started on a thread's session with no turn of T3's
2379 * running: the parent's answer to a background subagent's report. It is
2380 * held for the continuation turn it asks for, or stopped when it only
2381 * reports subagents a Stop ended.
2382 */
2383 /**
2384 * Takes the reports an execution OpenCode started on its own delivers. When
2385 * they all report subagents a Stop ended, that execution only answers the
2386 * Stop, so it is stopped and `stopped` is true.
2387 */
2388 const takeReports = Effect.fnUntraced(function* (state: ThreadState) {
2389 const delivered = [...state.reports.values()];
2390 state.reports.clear();
2391 const stopped =
2392 delivered.length > 0 &&
2393 delivered.every((entry) => state.stoppedChildren.has(entry.childId));
2394 for (const entry of delivered) state.stoppedChildren.delete(entry.childId);
2395 if (stopped) {
2396 markUnsettled(state);
2397 yield* client.session
2398 .interrupt({ sessionID: Session.ID.make(state.sessionId) })
2399 .pipe(Effect.timeout(INTERRUPT_TIMEOUT), Effect.ignore({ log: true }));
2400 }
2401 return { delivered, stopped };
2402 });
2403
2404 const onWake = Effect.fnUntraced(function* (state: ThreadState) {
2405 const { delivered, stopped } = yield* takeReports(state);
2406 if (stopped) return;
2407 const route = state.providerThread.appThreadId;
2408 const wake: Wake = {
2409 // Only a thread's wake is offered a continuation; the ordinal is display metadata.
2410 messageId:
2411 route === null
2412 ? undefined
2413 : yield* idAllocator.allocate.message({ threadId: route, ordinal: 0 }),
2414 events: [],
2415 running: true,
2416 reports: delivered.map((entry) => entry.report),
2417 detail: delivered.length === 0 ? null : delivered.map((entry) => entry.text).join("\n\n"),
2418 dropped: false,
2419 after: delivered.at(-1)?.inboxId,
2420 first: delivered[0]?.inboxId,
2421 children: new Set(),
2422 };
2423 state.wakes.push(wake);
2424 yield* Effect.logInfo("OpenCode started a turn on its own; asking for a continuation.", {
2425 providerThreadId: state.providerThread.id,
2426 });
2427 yield* offerWake(state, wake);
2428 });
2429
2430 /** A background subagent's end, as OpenCode queues it for its parent. */
2431 const onReport = Effect.fnUntraced(function* (
2432 state: ThreadState,
2433 inboxId: string,
2434 payload: {
2435 readonly text: string;
2436 readonly metadata?: Readonly<Record<string, unknown>> | undefined;
2437 },
2438 ) {
2439 const childId = stringField(payload.metadata, "childID");
2440 if (stringField(payload.metadata, "source") !== "subagent" || childId === undefined) return;
2441 // A stray that reports has ended: no Stop needs to reach it.
2442 strays.delete(childId);
2443 const call = [...state.calls.values()].find(
2444 (candidate) => candidate.child?.sessionId === childId,
2445 );
2446 const calls = [...state.calls.values()].filter(
2447 (candidate) => candidate === call || (call !== undefined && candidate.joined === call),
2448 );
2449 const outcome = reportOutcome(stringField(payload.metadata, "state"));
2450 state.reports.set(inboxId, {
2451 inboxId,
2452 childId,
2453 text: payload.text,
2454 report: {
2455 kind: "subagent",
2456 label: call?.title ?? stringField(payload.metadata, "description"),
2457 childThreadId: call?.child?.subagent?.appThread.id,
2458 outcome,
2459 },
2460 });
2461 const status = state.stoppedChildren.has(childId)
2462 ? "interrupted"
2463 : outcome === "failed"
2464 ? "failed"
2465 : outcome === "cancelled"
2466 ? "cancelled"
2467 : "completed";
2468 for (const each of calls) yield* settleCall(each, status, subagentOutput(payload.text));
2469 });
2470
2471 /**
2472 * The thread whose held execution an event belongs to: the thread's own
2473 * session, or a subagent session its held execution started.
2474 */
2475 const holderOf = (sessionId: string) => {
2476 const own = threads.get(sessionId);
2477 if (own !== undefined) return own.wakes.at(-1)?.running === true ? own : undefined;
2478 // A subagent a held execution started is named only when that execution replays.
2479 const parent = announced.get(sessionId)?.parentID;
2480 const holder = parent === undefined ? undefined : threads.get(parent);
2481 return holder !== undefined && holder.active === undefined && holder.wakes.length > 0
2482 ? holder
2483 : undefined;
2484 };
2485
2486 /**
2487 * Ends the turn with its execution, unless a steer sent into it is still
2488 * undelivered: OpenCode then starts another execution for it, which the
2489 * turn owns too. A Stop leaves steers undelivered and starts nothing.
2490 */
2491 const endExecution = Effect.fnUntraced(function* (
2492 state: ThreadState,
2493 turn: ActiveTurn,
2494 end: TurnTerminal,
2495 ) {
2496 if (end.status === "completed" && turn.steers.size > 0) {
2497 turn.heldEnd = end;
2498 return;
2499 }
2500 yield* finishTurn(state, end);
2501 });
2502
2503 /** Ends a turn held for its steers once none is left to deliver. */
2504 const endIfSettled = (state: ThreadState, turn: ActiveTurn) =>
2505 turn.heldEnd !== undefined && turn.steers.size === 0
2506 ? finishTurn(state, turn.heldEnd)
2507 : Effect.void;
2508
2509 const handleEvent = Effect.fnUntraced(function* (event: OpenCode2Client.OpenCode2StreamEvent) {
2510 const sessionId = sessionOfEvent(event);
2511 // `revert.clear` wakes the session into an empty execution of its own
2512 // (2.0.18's `Session.revert.clear` ends with a wake). It is no run of
2513 // T3's, and no follow-up to a subagent either.
2514 const cleared = sessionId === undefined ? undefined : clearing.get(sessionId);
2515 if (cleared !== undefined) {
2516 if (event.type === "unreadable.execution.ended" || executionEnd(event.type)) {
2517 return yield* Deferred.succeed(cleared, undefined);
2518 }
2519 if (
2520 event.type === "session.execution.started" ||
2521 event.type === "unreadable.execution.started"
2522 ) {
2523 return;
2524 }
2525 }
2526 if (sessionId !== undefined) {
2527 if (
2528 event.type === "session.execution.started" ||
2529 event.type === "unreadable.execution.started"
2530 ) {
2531 // A session runs one execution at a time, each opening with `started`
2532 // on this ordered stream. Once the run `unsettled` waits on has
2533 // started, a later start means that run is over: an end not seen by
2534 // now was lost with a dropped stream, and the next end is this
2535 // execution's own. A run not yet started when it was marked starts here.
2536 const state = threads.get(sessionId);
2537 if (state?.unsettled === true) {
2538 if (state.unsettledStarted) state.unsettled = false;
2539 else state.unsettledStarted = true;
2540 }
2541 busy.add(sessionId);
2542 } else if (event.type === "unreadable.execution.ended" || executionEnd(event.type)) {
2543 busy.delete(sessionId);
2544 }
2545 }
2546 // A subagent's session: named on its parent's `subagent` call just after.
2547 if (event.type === "session.created" && event.data.parentID !== undefined) {
2548 // A stray's subagent is a stray too: no call of the thread's names it.
2549 const strayCaller = strays.get(event.data.parentID);
2550 if (strayCaller !== undefined) {
2551 strays.set(event.data.sessionID, strayCaller);
2552 return;
2553 }
2554 const owner = ownerOf(event.data.parentID);
2555 if (owner !== undefined) {
2556 childOwners.set(event.data.sessionID, owner);
2557 announced.set(event.data.sessionID, event.data);
2558 holderOf(event.data.parentID)?.wakes.at(-1)?.children.add(event.data.sessionID);
2559 }
2560 return;
2561 }
2562 // What a held execution does, and what the subagents it starts do,
2563 // waits for the turn that takes it.
2564 const holder = sessionId === undefined ? undefined : holderOf(sessionId);
2565 const held = holder?.wakes.at(-1);
2566 if (holder !== undefined && held !== undefined) {
2567 held.events.push(event);
2568 if (
2569 sessionId === holder.sessionId &&
2570 (event.type === "unreadable.execution.ended" || executionEnd(event.type))
2571 ) {
2572 held.running = false;
2573 }
2574 return;
2575 }
2576 return yield* route(event, sessionId);
2577 });
2578
2579 /**
2580 * Handles one event for its session, as the turn running there sees it.
2581 * `held` is set for a held execution's events as its turn replays them.
2582 */
2583 const route = Effect.fnUntraced(function* (
2584 event: OpenCode2Client.OpenCode2StreamEvent,
2585 sessionId: string | undefined,
2586 held = false,
2587 ) {
2588 // The end of the run a timed-out Stop left behind; no turn is its own.
2589 // A held execution's end is its own: any such run was over, or was this
2590 // execution, before it started.
2591 const ended =
2592 !held && (event.type === "unreadable.execution.ended" || executionEnd(event.type))
2593 ? threads.get(sessionId ?? "")
2594 : undefined;
2595 if (ended?.unsettled === true) {
2596 ended.unsettled = false;
2597 return;
2598 }
2599 // Marks where a running turn's own execution begins; it never ends one.
2600 // With no turn running it is a subagent's or a follow-up's start, below.
2601 if (event.type === "unreadable.execution.started") {
2602 const turn = threads.get(event.sessionID)?.active;
2603 if (turn !== undefined) {
2604 turn.awaitingStart = false;
2605 turn.heldEnd = undefined;
2606 return;
2607 }
2608 }
2609 if (event.type === "unreadable.execution.ended") {
2610 const state = threads.get(event.sessionID);
2611 const turn = state?.active;
2612 if (state === undefined || turn === undefined || turn.awaitingStart) return;
2613 return yield* endExecution(
2614 state,
2615 turn,
2616 event.executionType === "session.execution.succeeded"
2617 ? { status: turn.interrupted ? "interrupted" : "completed" }
2618 : event.executionType === "session.execution.interrupted"
2619 ? { status: "interrupted" }
2620 : {
2621 status: "failed",
2622 failure: makeProviderFailure({
2623 message: "OpenCode ended the turn with an error this version cannot read.",
2624 class: "provider_error",
2625 }),
2626 },
2627 );
2628 }
2629 if (event.type === "permission.asked") {
2630 return yield* onAsked({ type: "permission", request: event.data });
2631 }
2632 if (event.type === "form.created")
2633 return yield* onAsked({ type: "form", form: event.data.form });
2634 // Answered in another OpenCode client, or dropped by OpenCode: a reject
2635 // it sends on its own (a Stop, or another reject in the same session)
2636 // cancels the request. T3's own answers are settled where they are sent.
2637 if (
2638 event.type === "permission.replied" ||
2639 event.type === "form.replied" ||
2640 event.type === "form.cancelled"
2641 ) {
2642 const nativeId = event.type === "permission.replied" ? event.data.requestID : event.data.id;
2643 const entry = [...pending.values()].find((candidate) => candidate.native.id === nativeId);
2644 if (entry === undefined || entry.answering) return;
2645 const answered =
2646 event.type === "form.replied" ||
2647 (event.type === "permission.replied" && event.data.reply !== "reject");
2648 return yield* settleRequest(entry, answered ? "resolved" : "cancelled");
2649 }
2650 if (sessionId === undefined) return;
2651 const state = threads.get(sessionId);
2652 if (state === undefined) return;
2653 if (event.type === "session.model.selected" || event.type === "session.step.started") {
2654 if (event.type === "session.model.selected") state.model = event.data.model;
2655 const providerThread = withReportedModel(state.providerThread, event.data.model);
2656 if (providerThread !== state.providerThread) {
2657 state.providerThread = { ...providerThread, updatedAt: yield* DateTime.now };
2658 yield* emit({
2659 type: "provider_thread.updated",
2660 driver,
2661 providerThread: state.providerThread,
2662 });
2663 }
2664 }
2665 if (
2666 event.type === "session.inbox.enqueued" &&
2667 state.subagent !== undefined &&
2668 state.active === undefined
2669 ) {
2670 state.subagent.queued ??= event.data.inboxID;
2671 }
2672 if (event.type === "session.inbox.enqueued" && event.data.item.type === "synthetic") {
2673 return yield* onReport(state, event.data.inboxID, event.data.item.payload);
2674 }
2675 // Cancelled, or delivered into a turn already running: no wake reports it.
2676 // A steer of the running turn's is settled the same way.
2677 if (event.type === "session.inbox.cancelled" || event.type === "session.inbox.delivered") {
2678 state.reports.delete(event.data.inboxID);
2679 const turn = state.active;
2680 if (turn !== undefined && turn.steers.delete(event.data.inboxID)) {
2681 turn.settledInbox.add(event.data.inboxID);
2682 // A cancelled steer may leave a held turn nothing to wait for.
2683 if (event.type === "session.inbox.cancelled") yield* endIfSettled(state, turn);
2684 }
2685 return;
2686 }
2687 // A subagent's later prompts (a resumed subagent) open its next turn's message.
2688 if (
2689 event.type === "session.inbox.enqueued" &&
2690 event.data.item.type === "user" &&
2691 state.subagent !== undefined &&
2692 state.subagent.prompt === undefined &&
2693 state.active === undefined
2694 ) {
2695 state.subagent.prompt = event.data.item.payload.text;
2696 return;
2697 }
2698 const started =
2699 event.type === "session.execution.started" || event.type === "unreadable.execution.started";
2700 // Each execution of a subagent's session is a turn on its child thread,
2701 // unless it only answers the reports of nested subagents a Stop ended.
2702 if (state.subagent !== undefined && state.active === undefined && started) {
2703 if ((yield* takeReports(state)).stopped) {
2704 state.subagent.queued = undefined;
2705 return;
2706 }
2707 return yield* startChildTurn(state);
2708 }
2709 const turn = state.active;
2710 if (turn === undefined) {
2711 // OpenCode started the thread's session on its own.
2712 if (started && state.subagent === undefined) return yield* onWake(state);
2713 return;
2714 }
2715 // A session runs one execution at a time, and each opens with `started`
2716 // on this ordered stream, so what comes before it is the stopped run's.
2717 if (turn.awaitingStart) {
2718 if (event.type === "session.execution.started") turn.awaitingStart = false;
2719 return;
2720 }
2721 return yield* onTurnEvent(state, turn, event);
2722 });
2723
2724 // The stream is the only terminal signal, and it is volatile: events sent
2725 // while it is down are gone, and a restarted server never ends the
2726 // execution it lost. So a lost stream reconnects, then reconciles each
2727 // running turn from the server's own state (see `reconcile`). Only when
2728 // reconnecting keeps failing are the turns failed and the session broken,
2729 // so T3 reopens it. Set first, so a turn starting meanwhile waits or refuses.
2730 let streamFailure: string | undefined;
2731 let reconnected = yield* Deferred.make<void>();
2732 const failAll = Effect.fnUntraced(function* (message: string) {
2733 streamFailure = message;
2734 yield* Deferred.succeed(reconnected, undefined);
2735 for (const state of threads.values()) {
2736 const failure = makeProviderFailure({ message, class: "transport_error" });
2737 yield* finishTurn(state, { status: "failed", failure }, "broken");
2738 }
2739 // Nothing can report a background subagent's end any more.
2740 for (const state of threads.values()) {
2741 for (const call of runningCalls(state)) yield* settleCall(call, "failed");
2742 for (const wake of state.wakes.splice(0)) wake.dropped = true;
2743 state.reports.clear();
2744 }
2745 yield* setSessionStatus("error", message);
2746 yield* Queue.end(events);
2747 });
2748 /**
2749 * Emits what a running turn missed while the stream was down, from the
2750 * session's history since the turn began: text, reasoning and tools, each
2751 * under the same native id its live events would have used, so nothing
2752 * already shown is duplicated. A turn begins at its prompt, whose id T3
2753 * chose (`nativeTurnRef`), or after `before` for a `/name` command or a
2754 * continuation.
2755 * Returns how that history says the turn's execution ended: the `idle` item
2756 * OpenCode appends after each one, or undefined when there is none after
2757 * the turn's start.
2758 */
2759 const backfill = Effect.fnUntraced(function* (sessionId: string, state: ThreadState) {
2760 const turn = state.active;
2761 if (turn === undefined) return undefined;
2762 const promptId = promptOf(turn.providerTurn);
2763 const { before } = turn;
2764 if (promptId === undefined && before === undefined) return undefined;
2765 const start = before !== undefined ? before : promptId;
2766 const read = yield* OpenCode2Client.paginate(
2767 { sessionID: Session.ID.make(sessionId), order: "desc" as const, limit: 50 },
2768 client.message.list,
2769 ).pipe(
2770 Stream.takeUntil((message) => message.id === start),
2771 Stream.runCollect,
2772 );
2773 // Without its start the history holds only earlier turns (the stream
2774 // dropped before the prompt landed): none of it is this turn's. A null
2775 // `before` is a command that started on an empty history.
2776 const found = start === null || read.at(-1)?.id === start;
2777 if (!found) return undefined;
2778 const recent = before !== undefined && before !== null ? read.slice(0, -1) : read;
2779 const idle = recent.find((message) => message.type === "idle");
2780 for (const message of recent.toReversed()) {
2781 if (message.type !== "assistant" || state.active !== turn) continue;
2782 const ordinals = { text: 0, reasoning: 0 };
2783 for (const part of message.content) {
2784 if (part.type === "text" || part.type === "reasoning") {
2785 const block = { assistantMessageID: message.id, ordinal: ordinals[part.type]++ };
2786 yield* emitText(state, turn, block, part.type, () => part.text, true);
2787 continue;
2788 }
2789 if (part.type !== "tool") continue;
2790 if (!turn.tools.has(part.id)) {
2791 turn.tools.set(part.id, { name: part.name, input: {} });
2792 turn.startedAt.set(part.id, yield* DateTime.now);
2793 }
2794 const tool = turn.tools.get(part.id)!;
2795 if (typeof part.state.input === "object" && part.state.input !== null) {
2796 tool.input = part.state.input as Record<string, unknown>;
2797 }
2798 if (part.state.status === "completed") {
2799 yield* emitTool(state, turn, part.id, "completed", {
2800 output: textOf(part.state.content),
2801 metadata: part.state.metadata,
2802 });
2803 turn.tools.delete(part.id);
2804 } else if (part.state.status === "error") {
2805 yield* emitTool(state, turn, part.id, "failed", {
2806 output: part.state.error.message,
2807 metadata: part.state.metadata,
2808 });
2809 turn.tools.delete(part.id);
2810 } else {
2811 yield* emitTool(state, turn, part.id, "running");
2812 }
2813 }
2814 }
2815 return idle === undefined ? undefined : { outcome: idle.outcome };
2816 });
2817
2818 /**
2819 * Settles each thread turn that was running when the stream dropped, from
2820 * the server: a session still running keeps its turn (its next events
2821 * arrive on the new stream); one that stopped ends the turn with the
2822 * outcome of the `idle` item after the turn's start. A restarted server
2823 * writes no such item for the execution it lost, and `session.outcome` is
2824 * still the previous execution's, so no `idle` means the turn was
2825 * interrupted. A continuation turn is settled the same way, from the report
2826 * its execution answers. A turn still holding for an undelivered steer only
2827 * ends once the session is idle. Permission asks and question forms are
2828 * read back from every session still running, so a run that asked during
2829 * the gap can be answered.
2830 *
2831 * What the stream carried outside a turn is not read back: a background
2832 * subagent's report, a follow-up OpenCode started on its own, or one held
2833 * for its continuation turn. So background work is settled, not resumed: a
2834 * subagent whose session stopped ends as interrupted, and a thread's held
2835 * follow-ups and queued reports are dropped, so nothing waits on an event
2836 * that already went by.
2837 */
2838 const reconcile = Effect.gen(function* () {
2839 // A subagent's turn ends before the turn that waits on it, so the
2840 // deepest sessions are read back first.
2841 const running = [...threads]
2842 .filter(([, state]) => state.active !== undefined)
2843 .toSorted(([left], [right]) => callsAbove(right).length - callsAbove(left).length);
2844 const background = [...threads.values()].filter(
2845 (state) => state.subagent === undefined && hasBackground(state),
2846 );
2847 const marked = [...threads.values()].some((state) => state.unsettled);
2848 if (
2849 running.length === 0 &&
2850 background.length === 0 &&
2851 busy.size === 0 &&
2852 !marked &&
2853 strays.size === 0
2854 ) {
2855 return;
2856 }
2857 const active = yield* client.session.active();
2858 // An execution that ended while the stream was down never said so.
2859 for (const sessionId of busy) if (!(sessionId in active)) busy.delete(sessionId);
2860 // A run `unsettled` waits on is over once its session is idle: any start
2861 // from here is a new execution's, even if the run's own start was lost in
2862 // the gap. A session still active may not have started that run yet, so
2863 // its next start may still be the run's own; its end clears the mark.
2864 for (const state of threads.values()) {
2865 if (state.unsettled && !(state.sessionId in active)) state.unsettledStarted = true;
2866 }
2867 // A stray that stopped meanwhile runs nothing to stop.
2868 for (const childId of strays.keys()) if (!(childId in active)) strays.delete(childId);
2869 for (const [sessionId, state] of running) {
2870 const turn = state.active;
2871 if (turn === undefined) continue;
2872 const ended = yield* backfill(sessionId, state);
2873 if (sessionId in active || state.active !== turn) continue;
2874 yield* finishTurn(
2875 state,
2876 ended?.outcome === "succeeded"
2877 ? { status: turn.interrupted ? "interrupted" : "completed" }
2878 : ended?.outcome === "failed"
2879 ? {
2880 status: "failed",
2881 failure: makeProviderFailure({
2882 message:
2883 "OpenCode ended the turn with an error while T3 Code was reconnecting.",
2884 class: "provider_error",
2885 }),
2886 }
2887 : { status: "interrupted" },
2888 );
2889 }
2890 for (const state of background) {
2891 // A subagent still running keeps its call; its end arrives on the new stream.
2892 for (const call of runningCalls(state)) {
2893 const child = call.child?.sessionId;
2894 if (child === undefined || !(child in active)) {
2895 yield* settleCall(call, "interrupted", LOST_BACKGROUND);
2896 }
2897 }
2898 // A subagent's session holds reports of its own background subagents.
2899 for (const session of sessionsOf(state)) {
2900 discardWakes(session, session.wakes.splice(0));
2901 session.reports.clear();
2902 }
2903 }
2904 // Stopped off the lock this reconcile holds; one still running waits for a Stop.
2905 for (const thread of new Set([...strays.values()].map(rootOf))) {
2906 yield* Effect.forkIn(stopStrays(thread), sessionScope);
2907 }
2908 // A request asked while the stream was down was never shown, and the
2909 // run waits on it; one T3 shows that OpenCode no longer lists was
2910 // answered elsewhere or dropped with its execution. A session that
2911 // stopped dropped its requests.
2912 const listed = new Map<string, Set<string>>();
2913 for (const sessionId of Object.keys(active)) {
2914 if (ownerOf(sessionId) === undefined) continue;
2915 const sessionID = Session.ID.make(sessionId);
2916 const [permissions, forms] = yield* Effect.all([
2917 client.permission.list({ sessionID }),
2918 client.session.form.list({ sessionID }),
2919 ]);
2920 listed.set(sessionId, new Set([...permissions, ...forms].map((request) => request.id)));
2921 const shown = new Set([...pending.values()].map((entry) => entry.native.id));
2922 for (const request of permissions) {
2923 if (!shown.has(request.id)) yield* onAsked({ type: "permission", request });
2924 }
2925 for (const form of forms) {
2926 if (!shown.has(form.id)) yield* onAsked({ type: "form", form });
2927 }
2928 }
2929 // A snapshot: settling a request removes it from the map.
2930 for (const entry of Array.from(pending.values())) {
2931 if (listed.get(entry.sessionId)?.has(entry.native.id) !== true) {
2932 yield* settleRequest(entry, "cancelled");
2933 }
2934 }
2935 });
2936
2937 /**
2938 * Subscribes again, on a restarted server if the old one is gone, and
2939 * reconciles. Retried a few times; the caller fails everything after that.
2940 */
2941 const reconnect = Effect.gen(function* () {
2942 // Returned on any failure, including the interrupt a closing session
2943 // sends a reconnect still in flight, so a spawned server can idle-stop.
2944 const scope = yield* Scope.make();
2945 const { next, stream } = yield* Effect.gen(function* () {
2946 const next = yield* borrow.pipe(Effect.provideService(Scope.Scope, scope));
2947 return { next, stream: yield* next.events };
2948 }).pipe(Effect.onError(() => Scope.close(scope, Exit.void)));
2949 const previous = currentScope;
2950 connection = next;
2951 client = next.client;
2952 currentScope = scope;
2953 yield* Scope.close(previous, Exit.void);
2954 // A restarted server forgot T3's MCP servers; the next turn adds them again.
2955 for (const state of threads.values()) state.mcp = undefined;
2956 yield* lock.withPermit(reconcile).pipe(Effect.timeout(RECONCILE_TIMEOUT));
2957 return stream;
2958 }).pipe(
2959 Effect.retry({ times: RECONNECT_ATTEMPTS - 1, schedule: Schedule.spaced(RECONNECT_DELAY) }),
2960 );
2961
2962 let currentScope = initial.scope;
2963 // The borrow in use when the session closes is returned with it, so a
2964 // spawned server can still reach its idle shutdown.
2965 yield* Effect.addFinalizer(() => Scope.close(currentScope, Exit.void));
2966 const follow = (
2967 stream: Stream.Stream<OpenCode2Client.OpenCode2StreamEvent, unknown>,
2968 ): Effect.Effect<void> =>
2969 stream.pipe(
2970 Stream.runForEach((event) => lock.withPermit(handleEvent(event))),
2971 Effect.exit,
2972 Effect.flatMap(() =>
2973 Effect.gen(function* () {
2974 streamFailure = "The OpenCode event stream was lost. Reconnecting.";
2975 yield* Effect.logWarning("Lost the OpenCode event stream; reconnecting.");
2976 // The last failure is kept for the log: it can carry the server's
2977 // URL or response text, so only its tag is annotated and the full
2978 // failure stays in the log's cause.
2979 const next = yield* reconnect.pipe(
2980 Effect.tapCause((cause) =>
2981 Effect.logWarning(
2982 "Could not reconnect to the OpenCode event stream; failing the session.",
2983 cause,
2984 ).pipe(Effect.annotateLogs({ errorTag: causeErrorTag(cause) })),
2985 ),
2986 Effect.option,
2987 );
2988 if (next._tag === "None") {
2989 return yield* failAll("The OpenCode event stream was lost and could not reconnect.");
2990 }
2991 streamFailure = undefined;
2992 const done = reconnected;
2993 reconnected = yield* Deferred.make<void>();
2994 yield* Deferred.succeed(done, undefined);
2995 return yield* follow(next.value);
2996 }),
2997 ),
2998 );
2999 // Subscribed before any session or prompt call, so no event of theirs is missed.
3000 yield* follow(yield* connection.events).pipe(Effect.forkScoped);
3001
3002 // A server T3 did not start keeps running after T3 stops, so stop the turns
3003 // it would otherwise finish unseen. A spawned server stops with its owner.
3004 if (connection.external) {
3005 yield* Effect.addFinalizer(() =>
3006 Effect.forEach(
3007 [...threads].filter(([, state]) => state.active !== undefined),
3008 ([sessionId]) =>
3009 client.session
3010 .interrupt({ sessionID: Session.ID.make(sessionId) })
3011 .pipe(Effect.timeout("1 second"), Effect.ignore({ log: true })),
3012 { concurrency: 8, discard: true },
3013 ),
3014 );
3015 }
3016
3017 // Context windows come from the server's model list for a directory, read
3018 // when the session opens, before a thread first runs in another directory,
3019 // and again after a turn whose model had none yet. A directory counts as
3020 // known once its read starts, so a failed read is retried only after a turn.
3021 const readModels = (directory: string) =>
3022 Effect.suspend(() => {
3023 const windows = contextWindows.get(directory) ?? new Map<string, number>();
3024 contextWindows.set(directory, windows);
3025 return client.model.list({ location: { directory } }).pipe(
3026 Effect.timeout("5 seconds"),
3027 Effect.tap((models) =>
3028 Effect.sync(() => {
3029 for (const model of models.data) {
3030 // A model's input limit, when it has one, is its real headroom.
3031 windows.set(
3032 `${model.providerID}/${model.id}`,
3033 model.limit.input ?? model.limit.context,
3034 );
3035 }
3036 }),
3037 ),
3038 Effect.ignore({ log: true }),
3039 );
3040 });
3041 const readModelsOnce = (cwd: string | null | undefined) =>
3042 contextWindows.has(directoryOf(cwd)) ? Effect.void : readModels(directoryOf(cwd));
3043 yield* readModels(session.cwd);
3044
3045 // Each agent's own path allows, by the directory they were listed for.
3046 const agentRules = new Map<string, ReadonlyMap<string, ReadonlyArray<Rule>>>();
3047 const pathsFor = Effect.fnUntraced(function* (
3048 directory: string,
3049 agents: ReadonlyArray<string>,
3050 ) {
3051 let known = agentRules.get(directory);
3052 if (known === undefined) {
3053 // A fresh server lists no agents for its first moments, like its models.
3054 const listed = yield* client.agent.list({ location: { directory } }).pipe(
3055 Effect.repeat({
3056 until: (list) => list.data.length > 0,
3057 schedule: Schedule.spaced("250 millis"),
3058 }),
3059 Effect.timeout("5 seconds"),
3060 // The failure can carry the server's URL or response text, so only its
3061 // tag is annotated; the full failure stays in the log's cause.
3062 Effect.tapCause((cause) =>
3063 Effect.logWarning(
3064 "Could not list OpenCode agents; their path rules are skipped.",
3065 cause,
3066 ).pipe(Effect.annotateLogs({ errorTag: causeErrorTag(cause) })),
3067 ),
3068 Effect.option,
3069 );
3070 if (listed._tag === "None") return [];
3071 known = new Map(
3072 listed.value.data.map((agent) => [agent.id, agentPaths(agent.permissions)]),
3073 );
3074 agentRules.set(directory, known);
3075 }
3076 const paths = new Map<string, Rule>();
3077 for (const agent of agents) {
3078 for (const entry of known.get(agent) ?? []) {
3079 paths.set(`${entry.action}\u0000${entry.resource}`, entry);
3080 }
3081 }
3082 return [...paths.values()];
3083 });
3084
3085 /**
3086 * The rules a thread's session runs `policy` with. Full access allows
3087 * every path already, so the agent's own path rules are read only for the
3088 * modes that narrow it.
3089 */
3090 const rulesFor = Effect.fnUntraced(function* (
3091 thread: Pick<ThreadState, "directory" | "agent" | "grants">,
3092 policy: RulesPolicy,
3093 appThreadId: string | null,
3094 ) {
3095 const plan = policy.interactionMode === "plan";
3096 const paths =
3097 policy.runtimeMode === "full-access" && !plan
3098 ? []
3099 : yield* pathsFor(thread.directory, plan ? [thread.agent, "plan"] : [thread.agent]);
3100 return sessionRules(
3101 policy,
3102 paths,
3103 policy.runtimeMode === "full-access" ? [] : thread.grants,
3104 appThreadId === null ? null : yield* mcpServerNameFor(appThreadId),
3105 );
3106 });
3107
3108 /** Writes the session's rules for `policy` when they differ from what it has. */
3109 const writeRules = Effect.fnUntraced(function* (state: ThreadState, policy: RulesPolicy) {
3110 // A subagent's session may use its thread's T3 server, the root's.
3111 const rules = yield* rulesFor(state, policy, rootOf(state).providerThread.appThreadId);
3112 if (!sameRules(state.rules, rules)) {
3113 yield* client.session.update({
3114 sessionID: Session.ID.make(state.sessionId),
3115 permissions: rules,
3116 });
3117 state.rules = rules;
3118 }
3119 state.policy = policy;
3120 });
3121
3122 /** Names the session after its thread when they differ. */
3123 const writeTitle = Effect.fnUntraced(function* (state: ThreadState, title: string) {
3124 const next = title.trim();
3125 // An empty title asks OpenCode to generate one.
3126 if (next === "" || next === state.title) return;
3127 yield* client.session.update({ sessionID: Session.ID.make(state.sessionId), title: next });
3128 state.title = next;
3129 });
3130
3131 const register = (
3132 providerThread: OrchestrationV2ProviderThread,
3133 native: {
3134 readonly id: string;
3135 readonly model?: ModelRef | undefined;
3136 readonly agent?: string | undefined;
3137 readonly title?: string | undefined;
3138 readonly permissions?: ReadonlyArray<Rule> | undefined;
3139 },
3140 directory: string,
3141 ) => {
3142 providerThread = withReportedModel(providerThread, native.model);
3143 const existing = threads.get(native.id);
3144 if (existing !== undefined) {
3145 existing.providerThread = providerThread;
3146 existing.model = native.model;
3147 existing.directory = directory;
3148 existing.agent = native.agent ?? existing.agent;
3149 existing.title = native.title;
3150 existing.rules = native.permissions;
3151 return existing;
3152 }
3153 const state = newThreadState(native.id, providerThread, directory, undefined);
3154 state.model = native.model;
3155 state.agent = native.agent ?? state.agent;
3156 state.title = native.title;
3157 state.rules = native.permissions;
3158 threads.set(native.id, state);
3159 return state;
3160 };
3161
3162 /**
3163 * Refuses to cut or copy a session's history while something writes to
3164 * it: a turn of T3's, an execution seen running on the stream (a held
3165 * follow-up), or a run on the server this runtime does not own: one a
3166 * timed-out Stop or an unanswered request left behind, or any run on a
3167 * session loaded after the server outlived T3. Only that last case asks
3168 * the server. A rollback also waits for background subagents, whose
3169 * reports wake the session into the history it would cut.
3170 */
3171 const ensureQuiet = Effect.fnUntraced(function* (
3172 sessionId: string,
3173 verb: "roll back" | "fork",
3174 ) {
3175 const state = threads.get(sessionId);
3176 const busyHere =
3177 state !== undefined &&
3178 (state.active !== undefined ||
3179 busy.has(sessionId) ||
3180 (verb === "roll back" && hasBackground(state)));
3181 const busyThere =
3182 !busyHere &&
3183 (state === undefined || state.unsettled) &&
3184 sessionId in (yield* client.session.active().pipe(Effect.timeout(ACTIVE_CHECK_TIMEOUT)));
3185 if (busyHere || busyThere) {
3186 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
3187 driver: OPENCODE_PROVIDER,
3188 detail: `Cannot ${verb} OpenCode session ${sessionId} while it is still working`,
3189 });
3190 }
3191 });
3192
3193 /**
3194 * Takes back steers a finished turn left in OpenCode's inbox. One whose
3195 * cancel fails may still be there, so it is tried again before the next prompt.
3196 */
3197 const cancelStrandedSteers = Effect.fnUntraced(function* (state: ThreadState) {
3198 const sessionID = Session.ID.make(state.sessionId);
3199 // A snapshot: a cancelled steer leaves the set.
3200 for (const inboxID of Array.from(state.strandedSteers)) {
3201 const cancelled = yield* client.session.inbox
3202 .cancel({ sessionID, inboxID: SessionMessage.ID.make(inboxID) })
3203 .pipe(
3204 Effect.timeout(REQUEST_REPLY_TIMEOUT),
3205 Effect.tapCause((cause) =>
3206 Effect.logWarning("Could not take back an OpenCode steer.", cause),
3207 ),
3208 Effect.exit,
3209 Effect.map(Exit.isSuccess),
3210 );
3211 if (cancelled) state.strandedSteers.delete(inboxID);
3212 }
3213 });
3214
3215 /**
3216 * Clears a staged revert and waits out the empty execution `clear` runs,
3217 * which is no turn of T3's. 2.0.18 wakes the session after every clear,
3218 * with or without a stage (seen live), so that execution always comes.
3219 */
3220 const clearRevert = Effect.fnUntraced(function* (sessionId: string) {
3221 const settled = yield* Deferred.make<void>();
3222 clearing.set(sessionId, settled);
3223 yield* client.session.revert
3224 .clear({ sessionID: Session.ID.make(sessionId) })
3225 .pipe(
3226 Effect.andThen(Deferred.await(settled)),
3227 Effect.timeout(REQUEST_REPLY_TIMEOUT),
3228 Effect.ensuring(Effect.sync(() => clearing.delete(sessionId))),
3229 );
3230 stagedReverts.delete(sessionId);
3231 });
3232
3233 /**
3234 * Truncates the session's history before `boundary`: OpenCode stages the
3235 * revert, then commits it. If either fails or is interrupted the stage is
3236 * cleared, since OpenCode commits a staged revert on the next prompt; a
3237 * clear that fails too is tried again before that prompt. Files stay where
3238 * they are: T3 restores its own checkpoint when the user asked for files,
3239 * and a rewind without files must not touch them.
3240 */
3241 const rollBackTo = Effect.fnUntraced(function* (
3242 sessionId: string,
3243 boundary: SessionMessage.ID,
3244 ) {
3245 const sessionID = Session.ID.make(sessionId);
3246 // Each step is bounded: a server that never answers would otherwise
3247 // hold the session's gate, and the next turn with it.
3248 yield* client.session.revert.stage({ sessionID, messageID: boundary, files: false }).pipe(
3249 Effect.timeout(REQUEST_REPLY_TIMEOUT),
3250 Effect.andThen(
3251 client.session.revert.commit({ sessionID }).pipe(Effect.timeout(REQUEST_REPLY_TIMEOUT)),
3252 ),
3253 Effect.onError(() =>
3254 clearRevert(sessionId).pipe(
3255 Effect.tapError(() => Effect.sync(() => stagedReverts.add(sessionId))),
3256 Effect.ignore({ log: true }),
3257 ),
3258 ),
3259 );
3260 });
3261
3262 /**
3263 * The ids fork and rollback can cut before: the session's prompts, and the
3264 * reports continuations answer. The list takes one type, so it reads all.
3265 */
3266 const userMessages = (sessionId: string) =>
3267 OpenCode2Client.paginate(
3268 { sessionID: Session.ID.make(sessionId), limit: 100 },
3269 client.message.list,
3270 ).pipe(
3271 Stream.runCollect,
3272 Effect.map(
3273 (messages) =>
3274 new Set(
3275 messages.flatMap((message) =>
3276 message.type === "user" || message.type === "synthetic" ? [String(message.id)] : [],
3277 ),
3278 ),
3279 ),
3280 );
3281
3282 /** The session's history as V2 messages, read after any change T3 made to it. */
3283 const snapshotOf = Effect.fnUntraced(function* (
3284 providerThread: OrchestrationV2ProviderThread,
3285 sessionId: string,
3286 ) {
3287 const history = yield* OpenCode2Client.paginate(
3288 { sessionID: Session.ID.make(sessionId), order: "asc" as const, limit: 100 },
3289 client.message.list,
3290 ).pipe(Stream.runCollect);
3291 const snapshotAt = yield* DateTime.now;
3292 const messages = history.flatMap((message): Array<OrchestrationV2ConversationMessage> => {
3293 const text =
3294 message.type === "user"
3295 ? message.text
3296 : message.type === "assistant"
3297 ? textOf(message.content)
3298 : "";
3299 if (text.length === 0 || (message.type !== "user" && message.type !== "assistant")) {
3300 return [];
3301 }
3302 const createdAt = message.time.created;
3303 return [
3304 {
3305 createdBy: message.type === "user" ? "user" : "agent",
3306 creationSource: "provider",
3307 id: idAllocator.derive.messageFromProviderItem({
3308 driver,
3309 nativeItemId: message.id,
3310 }),
3311 threadId: providerThread.appThreadId ?? input.threadId,
3312 runId: null,
3313 nodeId: null,
3314 role: message.type,
3315 text,
3316 attachments: [],
3317 streaming: false,
3318 createdAt,
3319 updatedAt: createdAt,
3320 },
3321 ];
3322 });
3323 const lastUser = history.findLast((message) => message.type === "user")?.id;
3324 const state = threads.get(sessionId);
3325 return {
3326 providerThread: {
3327 ...providerThread,
3328 providerSessionId: input.providerSessionId,
3329 nativeConversationHeadRef: lastUser === undefined ? null : ref(lastUser, "weak"),
3330 status: "idle" as const,
3331 updatedAt: snapshotAt,
3332 },
3333 providerTurns: state === undefined ? [] : [...state.providerTurns.values()],
3334 messages,
3335 runtimeRequests: [],
3336 };
3337 });
3338
3339 const promptText = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) =>
3340 providerMessageTextWithAttachmentPaths({
3341 text: turnInput.message.text,
3342 attachments: turnInput.message.attachments,
3343 resolveAttachmentPath: host.resolveAttachmentPath,
3344 }).trim();
3345
3346 const removeMcp = (mcp: { readonly name: string; readonly directory: string }) =>
3347 Effect.suspend(() =>
3348 client.mcp.remove({ server: mcp.name, location: { directory: mcp.directory } }),
3349 ).pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true }));
3350
3351 // T3's MCP registrations outlive a session only on an external server; a
3352 // spawned one forgets them when it stops.
3353 yield* Effect.addFinalizer(() =>
3354 Effect.forEach(
3355 [...threads.values()].flatMap((state) => (state.mcp === undefined ? [] : [state.mcp])),
3356 removeMcp,
3357 { concurrency: 8, discard: true },
3358 ),
3359 );
3360
3361 /**
3362 * T3's MCP server for this thread, and the per-turn instructions. The MCP
3363 * server is registered for the session's directory under the thread's own
3364 * name and credential (the session rules allow only it), and removed when
3365 * the thread unloads or the session closes. OpenCode 2 has no per-prompt
3366 * system field, so the instructions are a session instructions entry,
3367 * which applies from the next step; it is only rewritten when it changes.
3368 */
3369 const prepareTurn = Effect.fnUntraced(function* (
3370 sessionId: string,
3371 state: ThreadState,
3372 turnInput: ProviderAdapter.ProviderAdapterV2TurnInput,
3373 ) {
3374 const mcpSession = yield* mcpSessions.read(turnInput.threadId);
3375 const directory = turnInput.runtimePolicy.cwd ?? host.paths.cwd;
3376 const name = yield* mcpServerNameFor(turnInput.threadId);
3377 // An external server may not reach T3's MCP endpoint, as with 1.x.
3378 const wanted =
3379 mcpSession === undefined || connection.external
3380 ? undefined
3381 : { name, directory, credential: mcpSession.authorizationHeader };
3382 if (
3383 state.mcp !== undefined &&
3384 (wanted === undefined ||
3385 state.mcp.directory !== wanted.directory ||
3386 state.mcp.credential !== wanted.credential)
3387 ) {
3388 yield* removeMcp(state.mcp);
3389 state.mcp = undefined;
3390 }
3391 // T3's tools are an addition: a server that cannot add them still runs the turn.
3392 if (wanted !== undefined && state.mcp === undefined) {
3393 const added = yield* client.mcp
3394 .add({
3395 server: name,
3396 location: { directory },
3397 config: new Mcp.RemoteConfig({
3398 type: "remote",
3399 url: mcpSession!.endpoint,
3400 headers: { Authorization: wanted.credential },
3401 oauth: false,
3402 }),
3403 })
3404 .pipe(
3405 Effect.timeout(INVENTORY_TIMEOUT),
3406 Effect.as(true),
3407 Effect.catchCause((cause) =>
3408 Effect.logWarning("Could not add T3 Code's MCP server to OpenCode.", cause).pipe(
3409 Effect.as(false),
3410 ),
3411 ),
3412 );
3413 if (added) state.mcp = wanted;
3414 }
3415 const instructions = [
3416 buildRuntimeInstructions({ harness: "OpenCode", model: turnInput.modelSelection.model }),
3417 t3OrchestrationSystemPrompt(state.mcp !== undefined),
3418 ]
3419 .filter((part) => part !== undefined && part.length > 0)
3420 .join("\n\n");
3421 if (instructions !== state.instructions) {
3422 yield* client.session.instructions.entry.put({
3423 sessionID: Session.ID.make(sessionId),
3424 key: INSTRUCTIONS_KEY,
3425 value: instructions,
3426 });
3427 state.instructions = instructions;
3428 }
3429 });
3430
3431 const markBefore = (sessionId: string, before: string | null) =>
3432 Effect.sync(() => {
3433 const turn = threads.get(sessionId)?.active;
3434 if (turn !== undefined) turn.before = before;
3435 });
3436
3437 /** Installs a turn T3 started; every path after it ends the turn with a terminal. */
3438 const beginTurn = (
3439 state: ThreadState,
3440 turnInput: ProviderAdapter.ProviderAdapterV2TurnInput,
3441 awaitingStart: boolean,
3442 ) =>
3443 Effect.gen(function* () {
3444 const startedAt = yield* DateTime.now;
3445 const nativeTurnId = `${state.sessionId}:attempt:${turnInput.attemptId}`;
3446 const providerTurn: OrchestrationV2ProviderTurn = {
3447 id: idAllocator.derive.providerTurn({ driver, nativeTurnId }),
3448 providerThreadId: turnInput.providerThread.id,
3449 nodeId: turnInput.rootNodeId,
3450 runAttemptId: turnInput.attemptId,
3451 // The user message it prompts with, where fork and rollback cut. A
3452 // continuation prompts nothing; it records the report it answers
3453 // once it takes its execution.
3454 nativeTurnRef: ref(
3455 isContinuation(turnInput)
3456 ? wakeTurnId(state.sessionId, turnInput.attemptId)
3457 : turnPromptId(state.sessionId, turnInput.attemptId),
3458 "weak",
3459 ),
3460 ordinal: turnInput.providerTurnOrdinal,
3461 status: "running",
3462 startedAt,
3463 completedAt: null,
3464 };
3465 const turn = makeTurn(turnInput, providerTurn, { scope: "", awaitingStart });
3466 // No stream is left to end this turn, so it must not start.
3467 if (streamFailure !== undefined) {
3468 return yield* new ProviderAdapter.ProviderAdapterEventStreamError({
3469 driver,
3470 providerSessionId: input.providerSessionId,
3471 cause: streamFailure,
3472 });
3473 }
3474 state.active = turn;
3475 yield* emitProviderTurn(state, turn, providerTurn);
3476 state.providerThread = {
3477 ...state.providerThread,
3478 status: "active",
3479 firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal,
3480 lastRunOrdinal: turnInput.runOrdinal,
3481 updatedAt: startedAt,
3482 };
3483 yield* emit({
3484 type: "provider_thread.updated",
3485 driver,
3486 providerThread: state.providerThread,
3487 });
3488 yield* setSessionStatus("running", null);
3489 return turn;
3490 });
3491
3492 /** Feeds a held execution's events to the turn now running on its session. */
3493 const replay = Effect.fnUntraced(function* (wake: Wake) {
3494 wake.dropped = true;
3495 for (const event of wake.events) yield* route(event, sessionOfEvent(event), true);
3496 });
3497
3498 /**
3499 * Drops held executions no turn will take. The subagents one started are
3500 * named only by its replay, so they stop routing to the thread at once and
3501 * become strays for {@link stopStrays}. A subagent that registered its own
3502 * call outside the held execution is the thread's, and is left alone.
3503 */
3504 const discardWakes = (caller: ThreadState, wakes: ReadonlyArray<Wake>) => {
3505 const retired = new Set<string>();
3506 const retire = (childId: string) => {
3507 const info = announced.get(childId);
3508 if (info === undefined) return;
3509 announced.delete(childId);
3510 childOwners.delete(childId);
3511 strays.set(childId, caller);
3512 retired.add(childId);
3513 // OpenCode reports its end to the session that called it; that
3514 // report answers no turn, so the execution it wakes is stopped. A
3515 // deeper one reports to a stray, which T3 does not track.
3516 threads.get(info.parentID ?? "")?.stoppedChildren.add(childId);
3517 };
3518 for (const wake of wakes) {
3519 wake.dropped = true;
3520 for (const childId of wake.children) retire(childId);
3521 }
3522 // Their own subagents, at any depth, were announced live but no call
3523 // of the thread's names them either.
3524 for (let grew = retired.size > 0; grew;) {
3525 grew = false;
3526 for (const [childId, info] of announced) {
3527 if (info.parentID === undefined || !retired.has(info.parentID)) continue;
3528 retire(childId);
3529 grew = true;
3530 }
3531 }
3532 };
3533
3534 /**
3535 * Stops a thread's strays one by one, as a Stop stops background
3536 * subagents, and never under `lock`: one that does not answer would
3537 * otherwise hold up every event. A stray is forgotten once stopped or
3538 * gone; one still running waits for the next Stop.
3539 */
3540 const stopStrays = (thread: ThreadState) =>
3541 Effect.forEach(
3542 [...strays].flatMap(([childId, caller]) => (rootOf(caller) === thread ? [childId] : [])),
3543 (childId) =>
3544 client.session.interrupt({ sessionID: Session.ID.make(childId) }).pipe(
3545 Effect.asVoid,
3546 Effect.catchTags({ SessionNotFoundError: () => Effect.void }),
3547 Effect.timeout(INTERRUPT_TIMEOUT),
3548 Effect.tap(() => Effect.sync(() => strays.delete(childId))),
3549 Effect.ignore({ log: true }),
3550 ),
3551 { discard: true },
3552 );
3553
3554 /**
3555 * A user turn that starts while OpenCode runs an execution on its own: the
3556 * prompt joins that execution, so the turn takes it, and its continuation
3557 * turn is no longer needed. Executions that already ended stay for theirs.
3558 */
3559 const takeRunningWake = Effect.fnUntraced(function* (state: ThreadState) {
3560 const wake = state.wakes.at(-1);
3561 if (wake?.running !== true) return;
3562 state.wakes.pop();
3563 yield* replay(wake);
3564 });
3565
3566 /**
3567 * The continuation turn for an execution OpenCode started on its own: it
3568 * takes the wake it was dispatched for, named by its message, and ends
3569 * with it. Wakes held before that one had their continuation cancelled or
3570 * dropped before it started, so nothing will replay them: they are
3571 * dropped. One already taken (a user turn joined it) leaves nothing to
3572 * run, so the turn ends at once.
3573 */
3574 const runWake = Effect.fnUntraced(function* (
3575 state: ThreadState,
3576 turnInput: ProviderAdapter.ProviderAdapterV2TurnInput,
3577 ) {
3578 yield* beginTurn(state, turnInput, false);
3579 yield* lock.withPermit(
3580 Effect.gen(function* () {
3581 const index = state.wakes.findIndex(
3582 (held) => held.messageId === turnInput.message.messageId,
3583 );
3584 if (index < 0) return yield* finishTurn(state, { status: "completed" });
3585 // Queued continuations can't be reordered (they carry a notification),
3586 // so an earlier wake here lost its continuation to a cancel or a failed
3587 // dispatch. Dropping it keeps that wake from pinning the session.
3588 discardWakes(state, state.wakes.splice(0, index));
3589 yield* Effect.forkIn(stopStrays(state), sessionScope);
3590 const wake = state.wakes.shift()!;
3591 const turn = state.active;
3592 if (turn !== undefined && wake.after !== undefined) turn.before = wake.after;
3593 // Its first report is where its history begins, so fork and rollback cut there.
3594 if (turn !== undefined && wake.first !== undefined) {
3595 turn.providerTurn = { ...turn.providerTurn, nativeTurnRef: ref(wake.first, "weak") };
3596 }
3597 // The execution still runs, so the run a mark waits on is this one or
3598 // was over before it started: its end, still to come, is this turn's.
3599 if (wake.running) state.unsettled = false;
3600 yield* replay(wake);
3601 }),
3602 );
3603 });
3604
3605 /**
3606 * Stops a thread's background subagents and any execution OpenCode is
3607 * running on its own. Their sessions end as interrupted; the reports
3608 * OpenCode then queues wake the parent into an execution that is stopped
3609 * as well, so no turn takes it. A subagent the Stop did not reach runs on,
3610 * so its call stays tracked: the session is not released under it, and
3611 * the next Stop tries it again.
3612 */
3613 const stopBackground = Effect.fnUntraced(function* (state: ThreadState) {
3614 const calls = runningCalls(state).filter((call) => call.background);
3615 // Every session under the thread, which a stopped subagent may report to.
3616 const callers = sessionsOf(state);
3617 // A subagent whose call ended runs again when OpenCode wakes it to
3618 // answer a report of its own background subagent.
3619 const followUps = callers.filter(
3620 (caller) =>
3621 caller.subagent !== undefined &&
3622 busy.has(caller.sessionId) &&
3623 !isOrchestrationV2WorkActive(caller.subagent.call.status),
3624 );
3625 // OpenCode announces a child's session before the call's progress names
3626 // it, so a call without a child yet is stopped through its caller's
3627 // announced children.
3628 const announcedTo = (caller: ThreadState) =>
3629 [...announced.values()].flatMap((info) =>
3630 info.parentID === caller.sessionId ? [info.sessionID] : [],
3631 );
3632 const childrenOf = (call: SubagentCall) =>
3633 call.child === undefined ? announcedTo(call.state) : [call.child.sessionId];
3634 // Each child reports to the session that called it, so its marker goes
3635 // on that caller's state: the thread's own, or a subagent's for a nested one.
3636 const children = new Map(
3637 calls.flatMap((call) => childrenOf(call).map((childId) => [childId, call.state] as const)),
3638 );
3639 const unreached = new Set<string>();
3640 for (const [childId, caller] of children) {
3641 caller.stoppedChildren.add(childId);
3642 const reached = yield* client.session
3643 .interrupt({ sessionID: Session.ID.make(childId) })
3644 .pipe(
3645 // A session that is gone runs nothing.
3646 Effect.catchTags({ SessionNotFoundError: () => Effect.void }),
3647 Effect.timeout(INTERRUPT_TIMEOUT),
3648 Effect.tapCause((cause) =>
3649 Effect.logWarning("Could not stop an OpenCode subagent.", cause),
3650 ),
3651 Effect.exit,
3652 Effect.map(Exit.isSuccess),
3653 );
3654 if (!reached) unreached.add(childId);
3655 }
3656 // Its end closes its turn; one the Stop did not reach stays pending work.
3657 for (const followUp of followUps) {
3658 yield* client.session
3659 .interrupt({ sessionID: Session.ID.make(followUp.sessionId) })
3660 .pipe(
3661 Effect.catchTags({ SessionNotFoundError: () => Effect.void }),
3662 Effect.timeout(INTERRUPT_TIMEOUT),
3663 Effect.ignore({ log: true }),
3664 );
3665 }
3666 yield* lock.withPermit(
3667 Effect.gen(function* () {
3668 for (const call of calls) {
3669 if (childrenOf(call).some((childId) => unreached.has(childId))) continue;
3670 yield* settleCall(call, "interrupted");
3671 }
3672 // A report already queued starts a follow-up the Stop must end too.
3673 for (const caller of callers) {
3674 for (const report of caller.reports.values()) {
3675 caller.stoppedChildren.add(report.childId);
3676 }
3677 }
3678 // A held wake no turn will take: its execution is stopped, not replayed.
3679 const running = state.wakes.some((wake) => wake.running);
3680 discardWakes(state, state.wakes.splice(0));
3681 if (running) {
3682 markUnsettled(state);
3683 yield* client.session
3684 .interrupt({ sessionID: Session.ID.make(state.sessionId) })
3685 .pipe(Effect.timeout(INTERRUPT_TIMEOUT), Effect.ignore({ log: true }));
3686 }
3687 }),
3688 );
3689 // Subagents of held executions this or an earlier Stop dropped.
3690 yield* stopStrays(state);
3691 });
3692
3693 /**
3694 * What a turn sends: `/compact` compacts, `/name args` naming a workspace
3695 * command runs it, and anything else is a prompt with the `$skill`s it
3696 * names attached. Commands and skills are read from the session's
3697 * directory only when the text could use them. A turn stopped meanwhile
3698 * sends nothing.
3699 */
3700 const submit = Effect.fnUntraced(function* (
3701 sessionId: string,
3702 turnInput: ProviderAdapter.ProviderAdapterV2TurnInput,
3703 state: ThreadState,
3704 turn: ActiveTurn,
3705 ) {
3706 const sessionID = Session.ID.make(sessionId);
3707 /** Whether the turn still sends; past this point a Stop goes to the server. */
3708 const sending = () => {
3709 if (state.active !== turn || turn.interrupted) return false;
3710 turn.unsent = false;
3711 return true;
3712 };
3713 const text = turnInput.message.text.trim();
3714 const bare = turnInput.message.attachments.length === 0;
3715 // The turn's own id, so fork and rollback cut before this turn's item.
3716 const id = turnPromptId(sessionId, turnInput.attemptId);
3717 if (bare && text === "/compact") {
3718 if (!sending()) return;
3719 return yield* client.session.compact({ sessionID, id }).pipe(Effect.asVoid);
3720 }
3721 const location = { directory: turnInput.runtimePolicy.cwd ?? host.paths.cwd };
3722 const command = bare ? commandOf(text) : undefined;
3723 if (command !== undefined) {
3724 const commands = yield* client.command.list({ location }).pipe(
3725 Effect.timeout(INVENTORY_TIMEOUT),
3726 Effect.map((list) => list.data),
3727 Effect.orElseSucceed(() => []),
3728 );
3729 if (commands.some((entry) => entry.name === command.name)) {
3730 // `session.command` takes no id of T3's and its answer carries none,
3731 // so the turn remembers where the history stood before it.
3732 const newest = yield* client.message.list({ sessionID, order: "desc", limit: 1 });
3733 yield* markBefore(sessionId, newest.data[0]?.id ?? null);
3734 if (!sending()) return;
3735 return yield* client.session.command({ sessionID, ...command });
3736 }
3737 }
3738 const skills = SKILL_MENTION.test(text)
3739 ? skillsNamed(
3740 text,
3741 yield* client.skill.list({ location }).pipe(
3742 Effect.timeout(INVENTORY_TIMEOUT),
3743 Effect.map((list) => new Set(list.data.map((skill) => skill.id))),
3744 Effect.orElseSucceed(() => new Set<string>()),
3745 ),
3746 )
3747 : [];
3748 if (!sending()) return;
3749 return yield* client.session
3750 .prompt({
3751 sessionID,
3752 id,
3753 text: promptText(turnInput),
3754 ...(skills.length === 0
3755 ? {}
3756 : { skills: skills.map((id) => ({ id: Skill.ID.make(id) })) }),
3757 })
3758 .pipe(Effect.asVoid);
3759 });
3760
3761 const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = {
3762 instanceId,
3763 driver,
3764 providerSessionId: input.providerSessionId,
3765 get providerSession() {
3766 return session;
3767 },
3768 events: Stream.fromQueue(events),
3769 // A background subagent keeps its session busy after its parent's turn,
3770 // and a held wake still needs its turn: idle release must wait for both.
3771 hasPendingBackgroundWork: Effect.sync(() => [...threads.values()].some(owesWork)),
3772 hasPendingBackgroundWorkForThread: (providerThread) =>
3773 Effect.sync(() => {
3774 const nativeId = providerThread.nativeThreadRef?.nativeId;
3775 const state = nativeId == null ? undefined : threads.get(nativeId);
3776 return state !== undefined && hasBackground(state);
3777 }),
3778 // A caller that names no directory gets the one this session opened in.
3779 getModelContextWindow: (selection, cwd) =>
3780 selection.instanceId === instanceId
3781 ? windowOf(cwd === undefined ? session.cwd : cwd, selection.model)
3782 : undefined,
3783 ensureThread: (threadInput) =>
3784 Effect.gen(function* () {
3785 if (threadInput.existingProviderThread?.nativeThreadRef != null) {
3786 return yield* runtime.resumeThread({
3787 providerThread: threadInput.existingProviderThread,
3788 threadId: threadInput.threadId,
3789 modelSelection: threadInput.modelSelection,
3790 runtimePolicy: threadInput.runtimePolicy,
3791 });
3792 }
3793 yield* readModelsOnce(threadInput.runtimePolicy.cwd);
3794 const model = modelRef(threadInput.modelSelection);
3795 if (model === undefined) {
3796 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
3797 driver: OPENCODE_PROVIDER,
3798 detail: malformedModel(threadInput.modelSelection.model),
3799 });
3800 }
3801 const directory = threadInput.runtimePolicy.cwd ?? host.paths.cwd;
3802 const policy = threadInput.runtimePolicy;
3803 // A new session runs OpenCode's default agent.
3804 const permissions = yield* rulesFor(
3805 { directory, agent: "build", grants: [] },
3806 policy,
3807 threadInput.threadId,
3808 );
3809 const title = threadInput.title?.trim() || undefined;
3810 // A session created with a title is never titled by OpenCode's own model.
3811 const created = yield* client.session.create({
3812 location: Location.PublicRef.make({ directory: AbsolutePath.make(directory) }),
3813 model,
3814 permissions,
3815 ...(title === undefined ? {} : { title }),
3816 });
3817 const createdAt = yield* DateTime.now;
3818 const providerThread: OrchestrationV2ProviderThread = {
3819 ...(threadInput.existingProviderThread ?? {
3820 id: idAllocator.derive.providerThread({ driver, nativeThreadId: created.id }),
3821 driver,
3822 providerInstanceId: instanceId,
3823 appThreadId: threadInput.threadId,
3824 ownerNodeId: null,
3825 firstRunOrdinal: null,
3826 lastRunOrdinal: null,
3827 handoffIds: [],
3828 forkedFrom: null,
3829 createdAt,
3830 }),
3831 providerSessionId: input.providerSessionId,
3832 nativeThreadRef: ref(created.id),
3833 nativeConversationHeadRef: null,
3834 status: "idle",
3835 updatedAt: createdAt,
3836 };
3837 const state = register(
3838 providerThread,
3839 {
3840 id: created.id,
3841 model: created.model,
3842 agent: created.agent,
3843 title: title ?? created.title,
3844 permissions,
3845 },
3846 directory,
3847 );
3848 state.policy = policy;
3849 return state.providerThread;
3850 }).pipe(
3851 Effect.mapError((cause) =>
3852 isProviderAdapterError(cause)
3853 ? cause
3854 : new ProviderAdapter.ProviderAdapterEnsureThreadError({
3855 driver,
3856 threadId: threadInput.threadId,
3857 cause,
3858 }),
3859 ),
3860 ),
3861 resumeThread: (threadInput) =>
3862 Effect.gen(function* () {
3863 const sessionId = yield* sessionIdOf(threadInput.providerThread);
3864 if (threadInput.runtimePolicy !== undefined) {
3865 yield* readModelsOnce(threadInput.runtimePolicy.cwd);
3866 }
3867 // 1.x session ids survive the upgrade; a server without this session
3868 // fails the resume, so T3 recreates the thread with a handoff.
3869 const native = yield* client.session.get({ sessionID: Session.ID.make(sessionId) });
3870 const providerThread: OrchestrationV2ProviderThread = {
3871 ...threadInput.providerThread,
3872 providerSessionId: input.providerSessionId,
3873 status: "idle",
3874 updatedAt: yield* DateTime.now,
3875 };
3876 const cwd = threadInput.runtimePolicy?.cwd;
3877 const loaded = threads.has(sessionId);
3878 const state = register(providerThread, native, cwd ?? native.location.directory);
3879 // OpenCode keeps no request across its own restart, but a server that
3880 // outlived T3 may still wait on one T3 no longer shows.
3881 if (!loaded) yield* stopLeftoverRequests(state);
3882 // The session gets the rules for this thread's mode: it may have run
3883 // another mode, or been made by 1.x or an earlier build.
3884 yield* writeRules(state, threadInput.runtimePolicy ?? state.policy);
3885 // A thread moved to another worktree takes its session with it.
3886 if (cwd != null && native.location.directory !== cwd) {
3887 yield* client.session.move({
3888 sessionID: Session.ID.make(sessionId),
3889 directory: AbsolutePath.make(cwd),
3890 });
3891 }
3892 return state.providerThread;
3893 }).pipe(
3894 Effect.mapError((cause) =>
3895 isProviderAdapterError(cause)
3896 ? cause
3897 : new ProviderAdapter.ProviderAdapterResumeThreadError({
3898 driver,
3899 providerSessionId: input.providerSessionId,
3900 providerThreadId: threadInput.providerThread.id,
3901 cause,
3902 }),
3903 ),
3904 ),
3905 // `/compact` is its own turn, which `startTurn` sends as `session.compact`.
3906 compactThread: (turnInput) =>
3907 runtime.startTurn({ ...turnInput, message: { ...turnInput.message, text: "/compact" } }),
3908 startTurn: (turnInput) =>
3909 Effect.gen(function* () {
3910 // A lost stream is reconnecting: wait for it rather than prompt into
3911 // a dead stream, and refuse the turn before any request if it gave up.
3912 // Waited out before the session's gate, so a rollback or fork is not
3913 // held behind a reconnect.
3914 if (streamFailure !== undefined) {
3915 yield* Deferred.await(reconnected).pipe(Effect.timeout(RECONNECT_WAIT), Effect.ignore);
3916 }
3917 if (streamFailure !== undefined) {
3918 return yield* new ProviderAdapter.ProviderAdapterEventStreamError({
3919 driver,
3920 providerSessionId: input.providerSessionId,
3921 cause: streamFailure,
3922 });
3923 }
3924 // Its checks and setup take the session's gate, so a rollback or
3925 // fork sees either no turn or this one installed; the prompt after
3926 // it does not hold the gate.
3927 const started = yield* exclusive(turnInput.providerThread)(
3928 Effect.gen(function* () {
3929 const sessionId = yield* sessionIdOf(turnInput.providerThread);
3930 const state = threads.get(sessionId);
3931 if (state === undefined) {
3932 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
3933 driver: OPENCODE_PROVIDER,
3934 detail: `OpenCode session ${sessionId} is not registered`,
3935 });
3936 }
3937 if (state.active !== undefined) {
3938 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
3939 driver: OPENCODE_PROVIDER,
3940 detail: `OpenCode session ${sessionId} already has an active turn`,
3941 });
3942 }
3943 // OpenCode already ran this turn on its own; it prompts nothing.
3944 if (isContinuation(turnInput)) return yield* runWake(state, turnInput);
3945 // After a timed-out Stop the server says whether that run is gone. A
3946 // run still going is stopped again and this turn fails so it can be
3947 // sent again; a run that is gone may still have its end on the
3948 // stream, which the turn skips.
3949 const afterUnsettled = state.unsettled;
3950 let stillStopping = false;
3951 if (state.unsettled) {
3952 const active = yield* client.session
3953 .active()
3954 .pipe(Effect.timeout(ACTIVE_CHECK_TIMEOUT));
3955 stillStopping = sessionId in active;
3956 if (stillStopping) {
3957 yield* client.session
3958 .interrupt({ sessionID: Session.ID.make(sessionId) })
3959 .pipe(Effect.timeout(INTERRUPT_TIMEOUT), Effect.ignore({ log: true }));
3960 } else {
3961 state.unsettled = false;
3962 }
3963 }
3964 // Installs the turn; every path after it ends the turn with a terminal.
3965 const begin = beginTurn(state, turnInput, afterUnsettled);
3966 if (stillStopping) {
3967 yield* begin;
3968 return yield* finishTurn(state, {
3969 status: "failed",
3970 failure: makeProviderFailure({
3971 message: OPENCODE_2_STILL_STOPPING,
3972 class: "provider_error",
3973 }),
3974 });
3975 }
3976 // A turn T3 will not run still starts and fails, so the refusal is what
3977 // the user reads.
3978 const model = modelRef(turnInput.modelSelection);
3979 if (model === undefined) {
3980 yield* begin;
3981 return yield* finishTurn(state, {
3982 status: "failed",
3983 failure: makeProviderFailure({
3984 message: malformedModel(turnInput.modelSelection.model),
3985 class: "validation_error",
3986 }),
3987 });
3988 }
3989 // Plan mode is OpenCode's `plan` agent, switched before the rules are
3990 // written so they keep that agent's own path allows. Switching to or
3991 // from `plan` queues OpenCode's own "Plan mode" reminder for the prompt.
3992 const agent = agentFor(turnInput);
3993 if (agent !== state.agent) {
3994 yield* client.session.switchAgent({
3995 sessionID: Session.ID.make(sessionId),
3996 agent: Agent.ID.make(agent),
3997 });
3998 state.agent = agent;
3999 }
4000 // The thread's mode may have changed since the session was loaded,
4001 // and its subagents still running hold the rules they started with.
4002 // Those run on whether or not this turn starts, so theirs are best effort.
4003 yield* writeRules(state, turnInput.runtimePolicy);
4004 // A thread renamed since the last turn, or titled after its first prompt.
4005 yield* writeTitle(state, turnInput.appThread.title).pipe(
4006 Effect.timeout(REQUEST_REPLY_TIMEOUT),
4007 Effect.ignore({ log: true }),
4008 );
4009 for (const call of runningCalls(state)) {
4010 if (call.child === undefined) continue;
4011 yield* writeRules(call.child, turnInput.runtimePolicy).pipe(
4012 Effect.timeout(REQUEST_REPLY_TIMEOUT),
4013 Effect.ignore({ log: true }),
4014 );
4015 }
4016 // A selection changed since the last turn applies now; OpenCode keeps
4017 // the session's model otherwise.
4018 if (!sameModel(model, state.model)) {
4019 yield* client.session.switchModel({ sessionID: Session.ID.make(sessionId), model });
4020 state.model = model;
4021 }
4022 // Steers a stopped turn never delivered would reach the model first,
4023 // and a revert a failed rollback left staged would be committed.
4024 yield* cancelStrandedSteers(state);
4025 if (stagedReverts.has(sessionId)) yield* clearRevert(sessionId);
4026 yield* prepareTurn(sessionId, state, turnInput);
4027 const turn = yield* begin;
4028 turn.unsent = true;
4029 // An execution OpenCode is running on its own takes this prompt at
4030 // its next step, so this turn is that execution from here on.
4031 yield* lock.withPermit(takeRunningWake(state));
4032 return { state, sessionId, turn };
4033 }),
4034 );
4035 if (started === undefined) return;
4036 const { state, sessionId, turn } = started;
4037 yield* submit(sessionId, turnInput, state, turn).pipe(
4038 // Deleted outside T3: the thread is broken, and forgetting it makes
4039 // the next turn resume, fail, and recreate it with a handoff.
4040 Effect.catchTags({
4041 SessionNotFoundError: () =>
4042 finishTurn(
4043 state,
4044 {
4045 status: "failed",
4046 failure: makeProviderFailure({
4047 message:
4048 "The OpenCode session no longer exists. Send the message again to continue in a new session.",
4049 class: "provider_error",
4050 }),
4051 },
4052 "broken",
4053 ).pipe(Effect.andThen(Effect.sync(() => threads.delete(sessionId)))),
4054 }),
4055 Effect.tapError((cause) =>
4056 state.active === turn
4057 ? Effect.gen(function* () {
4058 // Without a clear rejection the server may have taken the
4059 // prompt, so the next turn checks before it prompts again.
4060 if (!CLEAR_PROMPT_REJECTIONS.has(cause._tag)) markUnsettled(state);
4061 yield* finishTurn(state, {
4062 status: "failed",
4063 failure: makeProviderFailure({ cause, class: "provider_error" }),
4064 });
4065 })
4066 : Effect.void,
4067 ),
4068 );
4069 }).pipe(
4070 Effect.mapError((cause) =>
4071 isProviderAdapterError(cause)
4072 ? cause
4073 : new ProviderAdapter.ProviderAdapterTurnStartError({
4074 driver,
4075 threadId: turnInput.threadId,
4076 providerThreadId: turnInput.providerThread.id,
4077 runId: turnInput.runId,
4078 cause,
4079 }),
4080 ),
4081 ),
4082 steerTurn: (steerInput) =>
4083 Effect.gen(function* () {
4084 const sessionId = yield* sessionIdOf(steerInput.providerThread);
4085 const state = threads.get(sessionId);
4086 const turn = state?.active;
4087 if (
4088 state === undefined ||
4089 turn === undefined ||
4090 turn.providerTurn.id !== steerInput.providerTurnId ||
4091 turn.interrupted
4092 ) {
4093 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
4094 driver: OPENCODE_PROVIDER,
4095 detail: `OpenCode turn ${steerInput.providerTurnId} is not active`,
4096 });
4097 }
4098 const inboxID = steerPromptId(sessionId, steerInput.message.messageId);
4099 // A retried steer that already reached the model is not sent again.
4100 if (turn.settledInbox.has(inboxID)) return;
4101 turn.steers.add(inboxID);
4102 state.strandedSteers.delete(inboxID);
4103 yield* client.session
4104 .prompt({
4105 sessionID: Session.ID.make(sessionId),
4106 id: inboxID,
4107 text: providerMessageTextWithAttachmentPaths({
4108 text: steerInput.message.text,
4109 attachments: steerInput.message.attachments,
4110 resolveAttachmentPath: host.resolveAttachmentPath,
4111 }).trim(),
4112 delivery: "steer",
4113 })
4114 .pipe(
4115 Effect.tapError((cause) =>
4116 lock.withPermit(
4117 Effect.gen(function* () {
4118 turn.steers.delete(inboxID);
4119 // Without a clear refusal OpenCode may hold it; the next prompt takes it back.
4120 if (!CLEAR_PROMPT_REJECTIONS.has(cause._tag)) state.strandedSteers.add(inboxID);
4121 if (state.active === turn) yield* endIfSettled(state, turn);
4122 }),
4123 ),
4124 ),
4125 );
4126 }).pipe(
4127 Effect.mapError((cause) =>
4128 isProviderAdapterError(cause)
4129 ? cause
4130 : new ProviderAdapter.ProviderAdapterSteerRunError({
4131 driver,
4132 providerThreadId: steerInput.providerThread.id,
4133 providerTurnId: steerInput.providerTurnId,
4134 cause,
4135 }),
4136 ),
4137 ),
4138 interruptTurn: (interruptInput) =>
4139 Effect.gen(function* () {
4140 const sessionId = yield* sessionIdOf(interruptInput.providerThread);
4141 const state = threads.get(sessionId);
4142 if (state === undefined) return;
4143 const turn = state.active;
4144 // OpenCode stops a foreground subagent with its parent, but not a
4145 // background one: a user Stop (`requestRuntimeRestart`) stops those
4146 // too, and the execution OpenCode starts to report them. A turn
4147 // interrupted to restart it with new input leaves them running.
4148 if (interruptInput.requestRuntimeRestart === true) yield* stopBackground(state);
4149 if (turn === undefined || turn.providerTurn.id !== interruptInput.providerTurnId) {
4150 return;
4151 }
4152 // The session answers with `session.execution.interrupted`, which ends
4153 // the turn. A server that does not answer in time is stuck, so the turn
4154 // ends here instead of waiting on it.
4155 turn.interrupted = true;
4156 const reply = yield* client.session
4157 .interrupt({ sessionID: Session.ID.make(sessionId) })
4158 .pipe(
4159 Effect.timeoutOption(INTERRUPT_TIMEOUT),
4160 // A Stop that never reached the server stopped nothing.
4161 Effect.tapError(() =>
4162 Effect.sync(() => {
4163 turn.interrupted = false;
4164 }),
4165 ),
4166 );
4167 if (reply._tag === "None") {
4168 markUnsettled(state);
4169 return yield* lock.withPermit(finishTurn(state, { status: "interrupted" }));
4170 }
4171 // Between the executions a late steer spans nothing runs yet. The
4172 // steers are taken back, and a run they already started is skipped.
4173 if (!reply.value.interrupted && state.active === turn && turn.heldEnd !== undefined) {
4174 markUnsettled(state);
4175 yield* lock.withPermit(finishTurn(state, { status: "interrupted" }));
4176 return yield* cancelStrandedSteers(state);
4177 }
4178 // Nothing was running because the turn has not sent its request yet
4179 // (it is reading the workspace's commands or skills): it ends here,
4180 // and never sends it.
4181 if (!reply.value.interrupted && state.active === turn && turn.unsent) {
4182 return yield* lock.withPermit(finishTurn(state, { status: "interrupted" }));
4183 }
4184 // Nothing was running. Unless the execution already ended (its event
4185 // is on the way), the turn is still open and nothing stopped.
4186 if (!reply.value.interrupted && state.active === turn) {
4187 turn.interrupted = false;
4188 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
4189 driver: OPENCODE_PROVIDER,
4190 detail: `OpenCode session ${sessionId} had nothing running to stop`,
4191 });
4192 }
4193 }).pipe(
4194 Effect.mapError((cause) =>
4195 isProviderAdapterError(cause)
4196 ? cause
4197 : new ProviderAdapter.ProviderAdapterInterruptError({
4198 driver,
4199 providerThreadId: interruptInput.providerThread.id,
4200 providerTurnId: interruptInput.providerTurnId,
4201 cause,
4202 }),
4203 ),
4204 ),
4205 unloadThread: ({ providerThread }) =>
4206 Effect.gen(function* () {
4207 const nativeId = providerThread.nativeThreadRef?.nativeId;
4208 const state = nativeId == null ? undefined : threads.get(nativeId);
4209 if (
4210 nativeId == null ||
4211 state === undefined ||
4212 state.active !== undefined ||
4213 hasBackground(state)
4214 ) {
4215 return;
4216 }
4217 threads.delete(nativeId);
4218 for (const [child, owner] of childOwners) {
4219 if (owner !== state) continue;
4220 childOwners.delete(child);
4221 threads.delete(child);
4222 }
4223 if (state.mcp !== undefined) yield* removeMcp(state.mcp);
4224 }),
4225 respondToRuntimeRequest: (requestInput) =>
4226 Effect.gen(function* () {
4227 const entry = pending.get(requestInput.requestId);
4228 if (entry === undefined || entry.answering) {
4229 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
4230 driver,
4231 detail: `No pending OpenCode request ${requestInput.requestId}`,
4232 });
4233 }
4234 const { decision, answers } = requestInput;
4235 const { native } = entry;
4236 if (native.type === "permission" && decision === undefined) {
4237 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
4238 driver,
4239 detail: `OpenCode approval request ${requestInput.requestId} requires a decision`,
4240 });
4241 }
4242 entry.answering = true;
4243 // OpenCode's own "always" saves a grant for the whole project, so a
4244 // session-wide answer is a rule on this session instead. The grant
4245 // is best effort: this request is answered either way.
4246 // The session that asked waits on the answer and holds the grant: a
4247 // subagent's own, not its parent's.
4248 const asker = threads.get(entry.sessionId) ?? entry.state;
4249 if (
4250 native.type === "permission" &&
4251 (decision === "acceptForSession" || decision === "acceptAlways")
4252 ) {
4253 const state = asker;
4254 for (const resource of native.save) {
4255 if (
4256 !state.grants.some(
4257 (grant) => grant.action === native.action && grant.resource === resource,
4258 )
4259 ) {
4260 state.grants.push({ action: native.action, resource, effect: "allow" });
4261 }
4262 }
4263 // A subagent runs under its thread's mode.
4264 yield* writeRules(state, rootOf(state).policy).pipe(Effect.ignore({ log: true }));
4265 }
4266 const sessionID = Session.ID.make(entry.sessionId);
4267 // Best effort: the decline stands without the note.
4268 if (native.type === "permission" && decision === "decline") {
4269 yield* client.session
4270 .synthetic({
4271 sessionID,
4272 text: declinedNote(native.action, native.resources),
4273 delivery: "steer",
4274 resume: false,
4275 })
4276 .pipe(Effect.timeout(REQUEST_REPLY_TIMEOUT), Effect.ignore({ log: true }));
4277 }
4278 const delivered = yield* native.type === "permission"
4279 ? deliver(
4280 client.permission
4281 .reply({
4282 sessionID,
4283 requestID: Permission.ID.make(native.id),
4284 decision: decision !== undefined && approves(decision) ? "once" : "reject",
4285 // Without a message OpenCode ends the whole run, which is Cancel.
4286 ...(decision === "decline" ? { message: DECLINED } : {}),
4287 })
4288 .pipe(Effect.catchTags(permissionGone)),
4289 )
4290 : deliver(
4291 (answers === undefined || decision === "decline" || decision === "cancel"
4292 ? client.session.form.cancel({
4293 sessionID: entry.sessionId,
4294 formID: Form.ID.make(native.id),
4295 })
4296 : client.session.form.reply({
4297 sessionID: entry.sessionId,
4298 formID: Form.ID.make(native.id),
4299 answer: formAnswer(native.form, answers),
4300 })
4301 ).pipe(Effect.catchTags(formGone)),
4302 );
4303 yield* forgetRequest(entry);
4304 if (!delivered) yield* abandonRequest(asker, "answer not delivered");
4305 }).pipe(
4306 Effect.mapError((cause) =>
4307 isProviderAdapterError(cause)
4308 ? cause
4309 : new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({
4310 driver,
4311 requestId: requestInput.requestId,
4312 cause,
4313 }),
4314 ),
4315 ),
4316 readThreadSnapshot: ({ providerThread }) =>
4317 Effect.gen(function* () {
4318 const sessionId = yield* sessionIdOf(providerThread);
4319 return yield* snapshotOf(providerThread, sessionId);
4320 }).pipe(
4321 Effect.mapError((cause) =>
4322 isProviderAdapterError(cause)
4323 ? cause
4324 : new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({
4325 driver,
4326 providerThreadId: providerThread.id,
4327 cause,
4328 }),
4329 ),
4330 ),
4331 rollbackThread: (rollbackInput) =>
4332 Effect.gen(function* () {
4333 const { providerThread, target } = rollbackInput;
4334 const sessionId = yield* sessionIdOf(providerThread);
4335 // A stopped run's end may still be on the stream, so `unsettled`
4336 // stays for the next turn to skip it.
4337 yield* ensureQuiet(sessionId, "roll back");
4338 // A session this runtime has not loaded yet is loaded the way a turn
4339 // would load it, which stops anything it still waits on. Requests it
4340 // stops end their run, so the server is asked again after that.
4341 if (!threads.has(sessionId)) {
4342 yield* runtime.resumeThread({ providerThread });
4343 yield* ensureQuiet(sessionId, "roll back");
4344 }
4345 const boundary = yield* boundaryAfter(
4346 rollbackInput.providerThreadTurns,
4347 providerThread.id,
4348 target.type === "provider_turn" ? target.providerTurn : undefined,
4349 yield* userMessages(sessionId),
4350 );
4351 if (boundary !== null) {
4352 yield* rollBackTo(sessionId, SessionMessage.ID.make(boundary));
4353 }
4354 // The turns after the target left the history with their messages.
4355 // The snapshot lists the ones kept as T3 recorded them, which a
4356 // runtime that loaded the session after they ran never saw.
4357 const keptOrdinal = target.type === "provider_turn" ? target.providerTurn.ordinal : 0;
4358 const loaded = threads.get(sessionId);
4359 for (const [id, turn] of loaded?.providerTurns ?? []) {
4360 if (turn.ordinal > keptOrdinal) loaded?.providerTurns.delete(id);
4361 }
4362 const snapshot = yield* snapshotOf(providerThread, sessionId);
4363 const kept = new Map(
4364 rollbackInput.providerThreadTurns
4365 .filter(
4366 (turn) =>
4367 turn.providerThreadId === providerThread.id && turn.ordinal <= keptOrdinal,
4368 )
4369 .map((turn) => [turn.id, turn] as const),
4370 );
4371 for (const turn of snapshot.providerTurns) kept.set(turn.id, turn);
4372 // The snapshot's head is the last user message OpenCode kept.
4373 return { ...snapshot, providerTurns: [...kept.values()] };
4374 }).pipe(
4375 exclusive(rollbackInput.providerThread),
4376 Effect.mapError((cause) =>
4377 isProviderAdapterError(cause)
4378 ? cause
4379 : new ProviderAdapter.ProviderAdapterRollbackThreadError({
4380 driver,
4381 providerThreadId: rollbackInput.providerThread.id,
4382 checkpointId: rollbackInput.target.checkpointId,
4383 cause,
4384 }),
4385 ),
4386 ),
4387 forkThread: (forkInput) =>
4388 Effect.gen(function* () {
4389 const source = forkInput.sourceProviderThread;
4390 const sourceId = yield* sessionIdOf(source);
4391 // OpenCode forks whatever history the source has, so a fork taken
4392 // while anything writes to it would copy a run half done.
4393 yield* ensureQuiet(sourceId, "fork");
4394 const selected =
4395 forkInput.providerTurnId === undefined
4396 ? undefined
4397 : forkInput.sourceProviderTurns?.find((turn) => turn.id === forkInput.providerTurnId);
4398 if (forkInput.providerTurnId !== undefined && selected === undefined) {
4399 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
4400 driver: OPENCODE_PROVIDER,
4401 detail: `OpenCode fork boundary turn ${forkInput.providerTurnId} was not found`,
4402 });
4403 }
4404 const before =
4405 selected === undefined
4406 ? null
4407 : yield* boundaryAfter(
4408 forkInput.sourceProviderTurns ?? [],
4409 source.id,
4410 selected,
4411 yield* userMessages(sourceId),
4412 );
4413 // A fork copies the source's history, model, location and rules; its
4414 // message ids are new, so it has no turns T3 could cut at yet.
4415 const forked = yield* client.session.fork({
4416 sessionID: Session.ID.make(sourceId),
4417 ...(before === null ? {} : { before: SessionMessage.ID.make(before) }),
4418 });
4419 const createdAt = yield* DateTime.now;
4420 const providerThread: OrchestrationV2ProviderThread = {
4421 id: idAllocator.derive.providerThread({ driver, nativeThreadId: forked.id }),
4422 driver,
4423 providerInstanceId: instanceId,
4424 providerSessionId: input.providerSessionId,
4425 appThreadId: forkInput.targetThreadId,
4426 ownerNodeId: forkInput.ownerNodeId ?? null,
4427 nativeThreadRef: ref(forked.id),
4428 nativeConversationHeadRef: null,
4429 status: "idle",
4430 firstRunOrdinal: null,
4431 lastRunOrdinal: null,
4432 handoffIds: [],
4433 forkedFrom: {
4434 providerThreadId: source.id,
4435 ...(forkInput.providerTurnId === undefined
4436 ? {}
4437 : { providerTurnId: forkInput.providerTurnId }),
4438 },
4439 createdAt,
4440 updatedAt: createdAt,
4441 };
4442 // A fork keeps the source's location. The target thread usually
4443 // inherits that worktree too, but its first turn starts without a
4444 // resume, so a target in another worktree takes the session there now.
4445 const cwd = forkInput.runtimePolicy?.cwd;
4446 const state = register(providerThread, forked, cwd ?? forked.location.directory);
4447 // The fork runs the target thread's mode, not whatever the source ran.
4448 yield* writeRules(state, forkInput.runtimePolicy ?? state.policy);
4449 if (cwd != null && forked.location.directory !== cwd) {
4450 yield* client.session.move({
4451 sessionID: Session.ID.make(forked.id),
4452 directory: AbsolutePath.make(cwd),
4453 });
4454 }
4455 return state.providerThread;
4456 }).pipe(
4457 exclusive(forkInput.sourceProviderThread),
4458 Effect.mapError((cause) =>
4459 isProviderAdapterError(cause)
4460 ? cause
4461 : new ProviderAdapter.ProviderAdapterForkThreadError({
4462 driver,
4463 providerThreadId: forkInput.sourceProviderThread.id,
4464 cause,
4465 }),
4466 ),
4467 ),
4468 };
4469 return runtime;
4470 });
4471
4472 return ProviderAdapter.ProviderAdapterV2.of({
4473 instanceId,
4474 driver,
4475 getCapabilities: () => Effect.succeed(OpenCode2ProviderCapabilities),
4476 planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()),
4477 // The session borrows the instance's server for as long as it is open, so a
4478 // spawned server is not idle-stopped under a long tool call.
4479 openSession: (input) =>
4480 Effect.gen(function* () {
4481 const scope = yield* Scope.make();
4482 yield* Effect.addFinalizer((exit) => Scope.close(scope, exit));
4483 const connection = yield* borrow.pipe(Effect.provideService(Scope.Scope, scope));
4484 return yield* openSession(input, { connection, scope });
4485 }).pipe(
4486 Effect.mapError(
4487 (cause) =>
4488 new ProviderAdapter.ProviderAdapterOpenSessionError({
4489 driver,
4490 providerSessionId: input.providerSessionId,
4491 cause,
4492 }),
4493 ),
4494 ),
4495 });
4496});