@jskit-ai/assistant-core 0.1.200 → 0.1.202

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/examples/conversation/README.md +8 -1
  2. package/examples/conversation/package.json +5 -5
  3. package/fixtures/responsive-assistant/App.vue +4 -1
  4. package/package.json +99 -36
  5. package/src/client/conversation/AssistantConversationElement.vue +27 -4
  6. package/src/client/conversation/AssistantConversationStatus.vue +24 -0
  7. package/src/client/conversation/AssistantGoalControl.vue +8 -5
  8. package/src/client/conversation/AssistantTranscript.vue +10 -3
  9. package/src/client/conversation/index.js +2 -0
  10. package/src/client/conversation/messageDelivery.js +115 -32
  11. package/src/client/conversation/retainedConversation.js +32 -0
  12. package/src/client/conversation/useAssistantQuestions.js +76 -0
  13. package/src/client/lib/assistantApi.js +72 -2
  14. package/src/server/conversation/agentRun.js +222 -0
  15. package/src/server/conversation/attachments.js +93 -0
  16. package/src/server/conversation/claudeClient.js +221 -0
  17. package/src/server/conversation/claudeHistory.js +256 -0
  18. package/src/server/conversation/claudeProcess.js +327 -0
  19. package/src/server/conversation/claudeTools.js +42 -0
  20. package/src/server/conversation/claudeTurn.js +1082 -0
  21. package/src/server/conversation/codexConfiguration.js +1183 -0
  22. package/src/server/conversation/codexConversations.js +1082 -0
  23. package/src/server/conversation/codexDetachedTurn.js +332 -0
  24. package/src/server/conversation/codexEvents.js +194 -1
  25. package/src/server/conversation/codexGoalCommands.js +124 -0
  26. package/src/server/conversation/codexHelperExecution.js +306 -0
  27. package/src/server/conversation/codexHelperThreadLedger.js +804 -0
  28. package/src/server/conversation/codexHelperThreadRecovery.js +395 -0
  29. package/src/server/conversation/codexHelperThreads.js +638 -0
  30. package/src/server/conversation/codexHistoryAdapter.js +369 -0
  31. package/src/server/conversation/codexMessageCommands.js +663 -0
  32. package/src/server/conversation/codexMessageDelivery.js +211 -0
  33. package/src/server/conversation/codexNativeHistoryExport.js +95 -0
  34. package/src/server/conversation/codexNotificationObserver.js +482 -0
  35. package/src/server/conversation/codexNotificationQueue.js +103 -0
  36. package/src/server/conversation/codexObservationControl.js +174 -0
  37. package/src/server/conversation/codexOutput.js +931 -0
  38. package/src/server/conversation/codexProcess.js +1559 -0
  39. package/src/server/conversation/codexProcessLeader.js +14 -0
  40. package/src/server/conversation/codexProvider.js +2710 -0
  41. package/src/server/conversation/codexProviderOwner.js +1058 -0
  42. package/src/server/conversation/codexProviderSelection.js +218 -0
  43. package/src/server/conversation/codexReasoning.js +314 -0
  44. package/src/server/conversation/codexRenewalCommands.js +290 -0
  45. package/src/server/conversation/codexResultSettlement.js +409 -0
  46. package/src/server/conversation/codexRunJournal.js +658 -0
  47. package/src/server/conversation/codexRuntimeLifecycle.js +288 -0
  48. package/src/server/conversation/codexSessionCleanup.js +96 -0
  49. package/src/server/conversation/codexSessionReconciliation.js +284 -0
  50. package/src/server/conversation/codexThreadReadiness.js +397 -0
  51. package/src/server/conversation/codexThreadStatus.js +354 -0
  52. package/src/server/conversation/codexTurn.js +701 -255
  53. package/src/server/conversation/codexTurnControl.js +317 -0
  54. package/src/server/conversation/codexTurnRecovery.js +990 -0
  55. package/src/server/conversation/codexTurnState.js +549 -0
  56. package/src/server/conversation/commandHook.js +18 -0
  57. package/src/server/conversation/commandWrapper.js +37 -0
  58. package/src/server/conversation/configuration.js +43 -0
  59. package/src/server/conversation/continuity.js +289 -0
  60. package/src/server/conversation/fileStorage.js +55 -0
  61. package/src/server/conversation/hookBridge.js +19 -0
  62. package/src/server/conversation/index.js +13 -0
  63. package/src/server/conversation/localExecution.js +665 -0
  64. package/src/server/conversation/memoryStorage.js +7 -72
  65. package/src/server/conversation/nativeHistoryExport.js +90 -0
  66. package/src/server/conversation/nativeStorage.js +23 -0
  67. package/src/server/conversation/openCodeClient.js +209 -19
  68. package/src/server/conversation/openCodePlugin.js +84 -0
  69. package/src/server/conversation/openCodeProcess.js +476 -0
  70. package/src/server/conversation/openCodeRuntime.js +1578 -0
  71. package/src/server/conversation/openCodeTools.js +60 -0
  72. package/src/server/conversation/openCodeTurn.js +557 -0
  73. package/src/server/conversation/provider.js +30 -0
  74. package/src/server/conversation/providers/api.js +136 -0
  75. package/src/server/conversation/providers/claude.js +63 -0
  76. package/src/server/conversation/providers/claudeDriver.js +425 -0
  77. package/src/server/conversation/providers/codex.js +248 -0
  78. package/src/server/conversation/providers/codexDriver.js +1078 -0
  79. package/src/server/conversation/providers/opencode.js +49 -0
  80. package/src/server/conversation/providers/opencodeDriver.js +650 -0
  81. package/src/server/conversation/providers/scoped.js +312 -0
  82. package/src/server/conversation/runtime.js +2220 -0
  83. package/src/server/conversation/runtimeStateUpgrade.js +125 -0
  84. package/src/server/conversation/storage.js +184 -0
  85. package/src/server/conversation/streams.js +7 -2
  86. package/src/server/conversation/structuredOutput.js +240 -0
  87. package/src/server/conversation/systemPrompt.js +54 -0
  88. package/src/server/conversation/tools.js +84 -0
  89. package/src/server/conversation/transcript.js +5 -4
  90. package/src/server/hosts/claudeHistory.js +5 -0
  91. package/src/server/hosts/claudeProcess.js +11 -0
  92. package/src/server/hosts/claudeTurn.js +4 -0
  93. package/src/server/hosts/codexConfiguration.js +20 -0
  94. package/src/server/hosts/codexEvents.js +13 -0
  95. package/src/server/hosts/codexProcess.js +12 -0
  96. package/src/server/hosts/codexProvider.js +23 -0
  97. package/src/server/hosts/codexTurn.js +20 -0
  98. package/src/server/hosts/nativeHistory.js +3 -0
  99. package/src/server/hosts/openCodeClient.js +3 -0
  100. package/src/server/hosts/openCodeProcess.js +23 -0
  101. package/src/server/hosts/openCodeTurn.js +7 -0
  102. package/src/server/index.js +2 -0
  103. package/src/server/lib/aiConnectionClient.js +13 -6
  104. package/src/server/lib/assistantCompletion.js +190 -0
  105. package/src/server/lib/assistantToolLoop.js +314 -0
  106. package/src/server/lib/serviceToolCatalog.js +25 -10
  107. package/src/shared/assistantResponseText.js +29 -0
  108. package/src/shared/conversation/attachments.js +19 -0
  109. package/src/shared/conversation/index.js +3 -0
  110. package/src/shared/conversation/pagination.js +78 -0
  111. package/src/shared/conversation/patches.js +150 -0
  112. package/src/shared/conversation/questions.js +48 -0
  113. package/src/shared/conversation/streaming.js +7 -4
  114. package/src/shared/index.js +2 -0
  115. package/src/shared/nativeProviders.js +45 -0
  116. package/src/testing/conversationStorageContract.js +31 -1
  117. package/src/testing/nativeCodex.js +11 -0
  118. package/src/testing/nativeOpenCode.js +3 -0
  119. package/test/aiConnectionClient.test.js +2 -0
  120. package/test/apiConversationDriver.test.js +73 -0
  121. package/test/assistantApiSurfaceHeader.test.js +85 -0
  122. package/test/assistantBoundedToolLoop.test.js +285 -0
  123. package/test/assistantScroll.browser.test.js +16 -0
  124. package/test/claudeClient.test.js +189 -0
  125. package/test/claudeConversationSession.test.js +1173 -0
  126. package/test/claudeInstructionResume.test.js +82 -0
  127. package/test/claudeProcess.test.js +204 -0
  128. package/test/claudeTurn.test.js +253 -0
  129. package/test/codexConversationSession.test.js +2122 -0
  130. package/test/codexGoal.test.js +37 -0
  131. package/test/codexHelperDeletion.test.js +163 -0
  132. package/test/codexHelperThreadLedger.test.js +494 -0
  133. package/test/codexHistoryAdapter.test.js +541 -0
  134. package/test/codexInteractiveArguments.test.js +30 -0
  135. package/test/codexIsolation.test.js +756 -0
  136. package/test/codexModelCatalog.test.js +362 -0
  137. package/test/codexNativeHistoryExport.test.js +81 -0
  138. package/test/codexThreadSelection.test.js +136 -0
  139. package/test/codexTurn.test.js +2158 -0
  140. package/test/conversation.test.js +110 -0
  141. package/test/conversationAgentRun.test.js +326 -0
  142. package/test/conversationApiFacilities.test.js +90 -0
  143. package/test/conversationAttachments.test.js +238 -0
  144. package/test/conversationCommandWrapper.test.js +48 -0
  145. package/test/conversationConfiguration.test.js +174 -0
  146. package/test/conversationNativeStorage.test.js +25 -0
  147. package/test/conversationReplacement.test.js +172 -0
  148. package/test/conversationRuntime.test.js +241 -0
  149. package/test/conversationRuntimeStateUpgrade.test.js +144 -0
  150. package/test/conversationSession.test.js +1080 -0
  151. package/test/conversationStreaming.test.js +68 -0
  152. package/test/conversationSystemPrompt.test.js +84 -0
  153. package/test/conversationTools.test.js +416 -0
  154. package/test/fileConversationStorage.test.js +73 -0
  155. package/test/fixtures/codexHelperThreadLedger.js +39 -0
  156. package/test/localConversationExecution.test.js +90 -0
  157. package/test/messageDelivery.test.js +234 -1
  158. package/test/nativeConversationRetirement.test.js +215 -0
  159. package/test/openCodeClient.test.js +515 -0
  160. package/test/openCodeProcess.test.js +177 -0
  161. package/test/openCodeRuntime.test.js +322 -0
  162. package/test/openCodeTools.test.js +287 -0
  163. package/test/opencodeConversationSession.test.js +789 -0
  164. package/test/serviceToolCatalog.test.js +24 -0
@@ -0,0 +1,1578 @@
1
+ import {
2
+ OPENCODE_INTERRUPT_TIMEOUT_MS, waitForOpenCodeFinalResponse, openCodeLastAssistantResult, inspectOpenCodeMessageAdmission, observeOpenCodeEvents,
3
+ openCodeMessageRows, openCodeRowsForInput, openCodePersistentConversationMessages, openCodeStructuredOutput, projectOpenCodeConversationMessages, waitForOpenCodeMessages, steerOpenCodeTurn, dispatchOpenCodeTurn, observeOpenCodeTurnCompletion, runOpenCodeConversationTurn
4
+ } from "./openCodeTurn.js";
5
+ import { openCodeAssistantMessageText as assistantMessageText } from "./openCodeClient.js";
6
+ import { randomUUID } from "node:crypto";
7
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
8
+ import path from "node:path";
9
+
10
+ const text = value => String(value ?? "").trim();
11
+ const sharedRuntimes = new Map();
12
+
13
+ function openCodeTurnSnapshot(turn = null, threadId = "") {
14
+ const source = turn && typeof turn === "object" && !Array.isArray(turn) ? turn : {};
15
+ const active = source.active === true;
16
+ return active || text(source.id)
17
+ ? {
18
+ active,
19
+ error: text(source.error),
20
+ id: text(source.id),
21
+ phase: active && !source.observationError ? text(source.phase) : "",
22
+ startedAt: text(source.startedAt),
23
+ state: text(source.state) || (active ? "active" : "completed"),
24
+ status: source.observationError ? "observation_lost" : text(source.state) || (active ? "active" : "completed"),
25
+ threadId: text(source.threadId || threadId),
26
+ updatedAt: text(source.updatedAt)
27
+ }
28
+ : null;
29
+ }
30
+
31
+ function openCodeCredentialFailure(value = "") {
32
+ const failure = text(value);
33
+ return (
34
+ /\b(?:401|403)\b/u.test(failure) ||
35
+ (
36
+ /(?:api[-_\s]?key|authentication|authorization|credential|access[-_\s]?token|unauthori[sz]ed|forbidden)/iu.test(failure) &&
37
+ /(?:denied|expired|failed|forbidden|incorrect|invalid|missing|rejected|revoked|unauthori[sz]ed)/iu.test(failure)
38
+ )
39
+ );
40
+ }
41
+
42
+ function openCodeProviderApiFailure(error = {}) {
43
+ return text(error?.name) === "APIError";
44
+ }
45
+
46
+
47
+ export function assertOpenCodeModelProvider(selected, input = {}) {
48
+ if (selected?.modelProviderId && input.model?.providerID !== selected.modelProviderId) {
49
+ throw new Error("Assistants must use the session's selected AI account.");
50
+ }
51
+ }
52
+
53
+ export function limitOpenCodeModelOutput(input = {}, output = {}) {
54
+ const advertisedOutputTokenLimit = input.model?.limit?.output;
55
+ const supportedOutputTokenLimit = (
56
+ Number.isSafeInteger(advertisedOutputTokenLimit) && advertisedOutputTokenLimit > 0
57
+ ) ? advertisedOutputTokenLimit : 32_000;
58
+ if (Number.isSafeInteger(output.maxOutputTokens) && output.maxOutputTokens > supportedOutputTokenLimit) {
59
+ output.maxOutputTokens = supportedOutputTokenLimit;
60
+ }
61
+ }
62
+
63
+ export async function readOpenCodeEnvironments(registryPath = "") {
64
+ if (!registryPath) return [];
65
+ const source = JSON.parse(await readFile(registryPath, "utf8"));
66
+ return Array.isArray(source?.sessions) ? source.sessions : [];
67
+ }
68
+
69
+ function pathContains(root = "", candidate = "") {
70
+ const relative = path.relative(root, candidate);
71
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
72
+ }
73
+
74
+ export function openCodeEnvironmentForDirectory(environments, cwd = "") {
75
+ const normalizedCwd = path.resolve(text(cwd) || process.cwd());
76
+ return environments
77
+ .filter(entry => text(entry?.workdir) && pathContains(path.resolve(entry.workdir), normalizedCwd))
78
+ .sort((left, right) => path.resolve(right.workdir).length - path.resolve(left.workdir).length)[0] || null;
79
+ }
80
+
81
+ export async function openCodeEnvironmentForSession(environments, sessionId = "", client = null) {
82
+ const visited = new Set();
83
+ let id = text(sessionId);
84
+ while (id && !visited.has(id) && visited.size < 32) {
85
+ const selected = environments.find(entry => text(entry?.upstreamSessionId) === id);
86
+ if (selected) return selected;
87
+ if (!client) return null;
88
+ visited.add(id);
89
+ const result = await client.session.get({ path: { id } });
90
+ if (result.error) throw Object.assign(new Error("The subagent's parent session could not be verified."), {
91
+ code: "assistant_opencode_parent_unverified"
92
+ });
93
+ id = text(result.data?.parentID);
94
+ }
95
+ return null;
96
+ }
97
+
98
+ export function openCodeServerForDirectory(server = {}, workdir = "") {
99
+ return Object.freeze({
100
+ ...server,
101
+ client: typeof server.client?.forDirectory === "function"
102
+ ? server.client.forDirectory(workdir)
103
+ : server.client
104
+ });
105
+ }
106
+
107
+ export function openCodeModel(selection = {}, executionProfile = null) {
108
+ const modelId = text(executionProfile?.model) || text(selection.modelId);
109
+ const variantId = executionProfile
110
+ ? text(executionProfile.thinking)
111
+ : text(selection.variantId);
112
+ return {
113
+ id: modelId,
114
+ providerID: text(selection.modelProviderId),
115
+ ...(variantId ? { variant: variantId } : {})
116
+ };
117
+ }
118
+
119
+ export function sameOpenCodeSelection(left = {}, right = {}) {
120
+ return ["agentId", "modelId", "modelProviderId", "variantId"]
121
+ .every((name) => text(left?.[name]) === text(right?.[name]));
122
+ }
123
+
124
+ /** Native selection and identity publication share the same pending operation. */
125
+ export async function ensureOpenCodeSession(target, { selection, model, workdir = target.workdir, identity = {}, signal,
126
+ invalidIdentity = () => new Error("OpenCode did not return a native conversation ID.") }) {
127
+ if (target.upstreamStart) {
128
+ return target.upstreamStart;
129
+ }
130
+ const start = Promise.resolve().then(async () => {
131
+ await target.server.client.prepareDirectory({ signal });
132
+ let upstream = (
133
+ target.upstream &&
134
+ sameOpenCodeSelection(target.upstreamSelection, selection)
135
+ ) ? target.upstream : null;
136
+ if (!upstream && target.upstreamSessionId) {
137
+ upstream = await target.server.client.readSession(target.upstreamSessionId, { signal });
138
+ await target.server.client.switchModel(target.upstreamSessionId, model, { signal });
139
+ await target.server.client.switchAgent(target.upstreamSessionId, selection.agentId, { signal });
140
+ } else if (!upstream) {
141
+ upstream = await target.server.client.createSession({
142
+ agent: selection.agentId,
143
+ location: { directory: workdir },
144
+ model
145
+ }, { signal });
146
+ }
147
+ const nativeId = text(upstream?.id);
148
+ if (!nativeId) {
149
+ throw invalidIdentity();
150
+ }
151
+ await identity.write?.(nativeId);
152
+ target.upstreamSessionId = nativeId;
153
+ target.upstream = upstream;
154
+ target.upstreamSelection = { ...selection };
155
+ await identity.publish?.(nativeId);
156
+ return target;
157
+ });
158
+ target.upstreamStart = start;
159
+ try {
160
+ return await start;
161
+ } finally {
162
+ if (target.upstreamStart === start) {
163
+ delete target.upstreamStart;
164
+ }
165
+ }
166
+ }
167
+
168
+ // The production provider's users and pending acquisitions share this owner.
169
+ // Hosts supply authorized startup inputs and their own publication/cleanup work.
170
+ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
171
+ if (scope !== undefined && !path.isAbsolute(scope)) throw new TypeError("OpenCode runtime scopes require an absolute directory.");
172
+ if (scope && sharedRuntimes.has(scope)) return sharedRuntimes.get(scope);
173
+ const processes = new Map();
174
+ const processStarts = new Map();
175
+ const monitors = new Map();
176
+ const turns = new Map();
177
+ const temporaryConversations = new Map();
178
+ const processExitProofs = new Map();
179
+ let sharedProcess = null;
180
+ let sharedProcessStart = null;
181
+ let sharedProcessStop = null;
182
+ let failedStartup = null;
183
+ let closed = false;
184
+ const bindingScopes = new Map();
185
+ let registryPath = "";
186
+ let bindingWrite = Promise.resolve();
187
+
188
+ function writeBindings(filename, scope, entries) {
189
+ if (!path.isAbsolute(filename || "") || registryPath && registryPath !== filename) {
190
+ throw new TypeError("An OpenCode runtime requires one absolute private binding registry path.");
191
+ }
192
+ registryPath = filename;
193
+ if (entries.length) bindingScopes.set(scope, entries);
194
+ else bindingScopes.delete(scope);
195
+ const writing = bindingWrite.then(async () => {
196
+ await mkdir(path.dirname(registryPath), { mode: 0o700, recursive: true });
197
+ const temporaryPath = `${registryPath}.${process.pid}.${randomUUID()}.tmp`;
198
+ await writeFile(temporaryPath, `${JSON.stringify({
199
+ sessions: [...bindingScopes.values()].flat()
200
+ })}\n`, { mode: 0o600 });
201
+ await rename(temporaryPath, registryPath);
202
+ });
203
+ bindingWrite = writing.catch(() => {});
204
+ return writing;
205
+ }
206
+
207
+ async function stop(reason = "opencode-last-session-closed") {
208
+ if (sharedProcessStop) return sharedProcessStop;
209
+ const target = sharedProcess;
210
+ const startup = failedStartup;
211
+ if (!target && !startup) return { exited: true, reason };
212
+ onStop({ reason, sessions: processes.size, startingSessions: processStarts.size });
213
+ const stopping = (async () => {
214
+ const proof = startup ? await startup.retryCleanup() : await target.server.stop();
215
+ if (proof?.exited !== true) {
216
+ throw Object.assign(new Error("OpenCode process exit could not be verified."), {
217
+ code: "assistant_opencode_stop_unverified", details: {}, statusCode: 503
218
+ });
219
+ }
220
+ if (sharedProcess === target) sharedProcess = null;
221
+ if (failedStartup === startup) failedStartup = null;
222
+ return proof;
223
+ })();
224
+ sharedProcessStop = stopping;
225
+ try {
226
+ return await stopping;
227
+ } finally {
228
+ if (sharedProcessStop === stopping) sharedProcessStop = null;
229
+ }
230
+ }
231
+
232
+ async function ensure(selected, start) {
233
+ await sharedProcessStop;
234
+ if (failedStartup) await stop("opencode-startup-cleanup");
235
+ if (sharedProcess) {
236
+ const currentConnection = sharedProcess.connections.get(selected?.modelProviderId);
237
+ if (!selected || (
238
+ currentConnection?.canonicalUrl === selected.canonicalUrl &&
239
+ currentConnection?.fingerprint === selected.fingerprint &&
240
+ currentConnection?.endpointCode === selected.endpointCode
241
+ )) {
242
+ try {
243
+ await sharedProcess.server.client.health({ signal: AbortSignal.timeout(1_000) });
244
+ return sharedProcess;
245
+ } catch {
246
+ await stop("opencode-health-check-failed");
247
+ }
248
+ } else {
249
+ await stop("opencode-connection-changed");
250
+ }
251
+ }
252
+ if (sharedProcessStart) return sharedProcessStart;
253
+ sharedProcessStart = Promise.resolve().then(async () => {
254
+ const { connections, server } = await start();
255
+ sharedProcess = {
256
+ connections: new Map(connections.map(connection => [
257
+ connection.modelProviderId,
258
+ { canonicalUrl: connection.canonicalUrl, endpointCode: connection.endpointCode, fingerprint: connection.fingerprint }
259
+ ])),
260
+ server
261
+ };
262
+ for (const target of processes.values()) {
263
+ target.server = openCodeServerForDirectory(server, target.workdir);
264
+ target.upstream = null;
265
+ target.upstreamSelection = null;
266
+ }
267
+ return sharedProcess;
268
+ }).catch(error => {
269
+ if (error.cleanupFailed && typeof error.retryCleanup === "function") failedStartup = error;
270
+ throw error;
271
+ });
272
+ try {
273
+ return await sharedProcessStart;
274
+ } finally {
275
+ sharedProcessStart = null;
276
+ }
277
+ }
278
+
279
+ function targetFor(key, prepared) {
280
+ const current = processes.get(key);
281
+ const nativeId = prepared.upstreamSessionId;
282
+ if (current && current.upstreamSessionId !== nativeId) {
283
+ current.upstreamSessionId = nativeId;
284
+ current.upstream = null;
285
+ }
286
+ if (
287
+ current && !current.abortController.signal.aborted &&
288
+ current.canonicalUrl === prepared.connection.canonicalUrl &&
289
+ current.connectionFingerprint === prepared.connection.fingerprint &&
290
+ current.endpointCode === prepared.connection.endpointCode &&
291
+ current.modelProviderId === prepared.connection.modelProviderId &&
292
+ current.projectContextRoot === prepared.projectContextRoot &&
293
+ current.workdir === prepared.workdir &&
294
+ text(current.selection?.catalogRevision) === text(prepared.selection.catalogRevision) &&
295
+ sameOpenCodeSelection(current.selection, prepared.selection)
296
+ ) {
297
+ current.helperModelId = prepared.helperModelId;
298
+ current.server = openCodeServerForDirectory(prepared.server, prepared.workdir);
299
+ return current;
300
+ }
301
+ const server = openCodeServerForDirectory(prepared.server, prepared.workdir);
302
+ const created = (current && !current.abortController.signal.aborted ? current : null) || {
303
+ abortController: new AbortController(),
304
+ key,
305
+ sessionId: prepared.sessionId,
306
+ upstreamSessionId: nativeId
307
+ };
308
+ Object.assign(created, {
309
+ helperModelId: prepared.helperModelId,
310
+ canonicalUrl: prepared.connection.canonicalUrl,
311
+ connectionFingerprint: prepared.connection.fingerprint,
312
+ endpointCode: prepared.connection.endpointCode,
313
+ modelProviderId: prepared.connection.modelProviderId,
314
+ projectContextRoot: prepared.projectContextRoot,
315
+ selection: prepared.selection,
316
+ server,
317
+ workdir: prepared.workdir
318
+ });
319
+ return created;
320
+ }
321
+
322
+ async function acquire(key, prepare, onFailure = async () => {}) {
323
+ const pending = processStarts.get(key);
324
+ if (pending) return pending;
325
+ const start = Promise.resolve().then(async () => {
326
+ const target = await prepare();
327
+ processes.set(key, target);
328
+ return target;
329
+ }).catch(async error => {
330
+ if (processStarts.get(key) === start) processStarts.delete(key);
331
+ if (!processes.has(key)) {
332
+ await onFailure();
333
+ if (processes.size === 0 && processStarts.size === 0 && !failedStartup) {
334
+ await stop("opencode-session-start-failed").catch(() => null);
335
+ }
336
+ }
337
+ throw error;
338
+ });
339
+ processStarts.set(key, start);
340
+ try {
341
+ return await start;
342
+ } finally {
343
+ if (processStarts.get(key) === start) processStarts.delete(key);
344
+ }
345
+ }
346
+
347
+ async function release(target, { retainSharedProcess = false, onRemoved } = {}) {
348
+ if (processes.get(target.key) === target) {
349
+ processes.delete(target.key);
350
+ await onRemoved?.();
351
+ }
352
+ return !retainSharedProcess && processes.size === 0 && processStarts.size === 0
353
+ ? stop()
354
+ : { exited: true, sharedProcessRetained: true };
355
+ }
356
+
357
+ async function releaseTarget(target, { retainSharedProcess = false, onRemoved } = {}) {
358
+ const activeThreads = new Set();
359
+ if (turns.get(target.key)?.active) activeThreads.add(target.upstreamSessionId);
360
+ for (const entry of temporaryConversations.values()) {
361
+ if (entry.active && entry.target?.abortController === target.abortController) {
362
+ activeThreads.add(entry.conversationId);
363
+ }
364
+ }
365
+ for (const threadId of activeThreads) await stopUnobservedSession(target, threadId);
366
+ target.abortController.abort();
367
+ turns.get(target.key)?.admission?.resolve();
368
+ await monitors.get(target.key)?.catch(() => null);
369
+ await Promise.all([...temporaryConversations.values()]
370
+ .filter((entry) => entry.target?.abortController === target.abortController)
371
+ .map((entry) => entry.completion?.catch(() => null)));
372
+ return release(target, { retainSharedProcess, onRemoved });
373
+ }
374
+
375
+ async function releaseProcessTarget(target = null, { retainSharedProcess = false } = {}, application) {
376
+ if (!target) {
377
+ return { exited: true };
378
+ }
379
+ await application.beforeRelease(target);
380
+ const proof = await releaseTarget(target, {
381
+ retainSharedProcess,
382
+ onRemoved: () => application.onRemoved(target)
383
+ }).catch(application.failure);
384
+ if (target.sessionId) {
385
+ processExitProofs.set(target.sessionId, proof);
386
+ }
387
+ return proof;
388
+ }
389
+
390
+ async function closeSession(id, options, application) {
391
+ const terminalClose = await application.closeTerminals();
392
+ const pending = [...processStarts.entries()]
393
+ .filter(([key]) => key.endsWith(`\0${id}`))
394
+ .map(([, start]) => start.catch(() => null));
395
+ await Promise.all(pending);
396
+ const targets = [...processes.values()].filter((target) => target.sessionId === id);
397
+ const proofs = await Promise.all(targets.map((target) => releaseProcessTarget(target, options, application)));
398
+ await application.afterRelease();
399
+ for (const key of [...turns.keys()]) {
400
+ if (key.endsWith(`\0${id}`)) {
401
+ turns.delete(key);
402
+ }
403
+ }
404
+ for (const key of [...temporaryConversations.keys()]) {
405
+ if (temporaryConversations.get(key)?.target?.sessionId === id) {
406
+ temporaryConversations.delete(key);
407
+ }
408
+ }
409
+ const result = {
410
+ closed: targets.length,
411
+ ok: proofs.every((proof) => proof?.exited !== false),
412
+ processExitProof: proofs.at(-1) || processExitProofs.get(id) || { exited: true },
413
+ processExitProofs: proofs
414
+ };
415
+ return {
416
+ ...result,
417
+ closed: result.closed + Number(terminalClose?.closed || 0),
418
+ ok: terminalClose?.ok !== false && result.ok
419
+ };
420
+ }
421
+
422
+ async function closeProject(input = {}, application) {
423
+ await Promise.all([...processStarts.entries()]
424
+ .filter(([key]) => key.includes("\0"))
425
+ .map(([, start]) => start.catch(() => null)));
426
+ const projectContextRoot = text(input.projectContextRoot);
427
+ const targets = [...processes.values()].filter((target) => (
428
+ application.ownsTarget(target) && (!projectContextRoot || target.projectContextRoot === path.resolve(projectContextRoot))
429
+ ));
430
+ const results = await Promise.all(targets.map((target) => releaseProcessTarget(target, {}, application)));
431
+ if (processes.size === 0 && (sharedProcess || failedStartup)) {
432
+ results.push(await stop(text(input.reason) || "opencode-project-close").catch(application.failure));
433
+ }
434
+ return {
435
+ closed: results.length,
436
+ ok: results.every((result) => result?.exited !== false)
437
+ };
438
+ }
439
+
440
+ async function invalidateRuntimes(input = {}, preparation) {
441
+ if (text(input.reason) === "server-shutdown") {
442
+ closed = true;
443
+ }
444
+ const application = preparation.release;
445
+ await Promise.all([...processStarts.entries()]
446
+ .filter(([key]) => key.includes("\0"))
447
+ .map(([, start]) => start.catch(() => null)));
448
+ const targets = [...processes.values()].filter(target => application.ownsTarget(target));
449
+ const results = await Promise.all(targets.map((target) => releaseProcessTarget(target, {}, application)));
450
+ if (processes.size === 0 && processStarts.size === 0 && (sharedProcess || failedStartup)) {
451
+ results.push(await stop(text(input.reason) || "opencode-runtime-invalidation").catch(application.failure));
452
+ }
453
+ return {
454
+ closed: results.length,
455
+ ok: results.every((result) => result?.exited !== false)
456
+ };
457
+ }
458
+
459
+ async function closePreparedSession(prepared) {
460
+ return closeSession(prepared.sessionId, prepared.options, prepared.application);
461
+ }
462
+
463
+ async function releaseRenewalPredecessorProcessExitProof(preparation) {
464
+ const closed = await closePreparedSession(preparation.cleanup);
465
+ const released = releaseProcessExitProof(preparation.sessionId);
466
+ return {
467
+ ...closed,
468
+ ...released,
469
+ ok: closed.ok !== false && released.ok !== false
470
+ };
471
+ }
472
+
473
+ function releaseProcessExitProof(id) {
474
+ const proof = processExitProofs.get(id) || null;
475
+ processExitProofs.delete(id);
476
+ return {
477
+ ok: Boolean(proof?.exited),
478
+ processExitProof: proof,
479
+ released: Boolean(proof?.exited)
480
+ };
481
+ }
482
+
483
+ async function startPreparedProcess(prepared) {
484
+ const server = await prepared.create(prepared.options);
485
+ return { connections: prepared.connections, server };
486
+ }
487
+
488
+ async function acquirePrepared(prepared) {
489
+ return acquire(prepared.key, async () => {
490
+ const configured = await prepared.configure();
491
+ const shared = await ensure(prepared.connection,
492
+ async () => startPreparedProcess(await configured.prepareServer())).catch(prepared.failure);
493
+ configured.target.server = shared.server;
494
+ return targetFor(prepared.key, configured.target);
495
+ }, prepared.onFailure).catch(prepared.failure);
496
+ }
497
+
498
+ function scopedTarget(target, prepared) {
499
+ const workdir = prepared.workdir;
500
+ return workdir ? { ...target, server: openCodeServerForDirectory(target.server, workdir), workdir } : target;
501
+ }
502
+
503
+ async function runPreparedRenewalTurn(preparation) {
504
+ const acquired = await acquirePrepared(await preparation.process());
505
+ const target = await ensureOpenCodeSession(acquired, preparation.session());
506
+ const turn = preparation.turn(target);
507
+ const result = await runOpenCodeConversationTurn(target.server.client, target.upstreamSessionId, turn.input, {
508
+ ...turn.options,
509
+ ...(turn.release ? { close: () => releaseProcessTarget(target, {}, turn.release) } : {})
510
+ });
511
+ return turn.completeResult(result);
512
+ }
513
+
514
+ async function createPreparedConversation(prepared) {
515
+ const target = scopedTarget(await acquirePrepared(prepared.process), prepared);
516
+ const conversation = await createConversation(prepared.key, target, prepared.selection(), prepared.executionProfile);
517
+ return { conversationId: conversation.id, ephemeral: prepared.ephemeral, ok: true, status: "ready" };
518
+ }
519
+
520
+ async function selectPreparedConversation(prepared) {
521
+ const target = scopedTarget(await acquirePrepared(prepared.process), prepared);
522
+ const selection = prepared.selection();
523
+ const conversationId = selection.conversationId;
524
+ const previous = conversationId ? temporaryConversations.get(`${prepared.key}\0${conversationId}`) : null;
525
+ if (selection.scoped && previous && JSON.stringify(previous.executionProfile || null) !== JSON.stringify(prepared.executionProfile)) {
526
+ throw selection.profileChanged();
527
+ }
528
+ if (!conversationId && !selection.createIfMissing) throw selection.identityRequired();
529
+ const selected = await selectConversation(prepared.key, target, conversationId, selection.input, selection.registration);
530
+ return { ...selected, executionProfile: prepared.executionProfile, target };
531
+ }
532
+
533
+ async function existingPreparedConversation(prepared) {
534
+ return { conversationId: prepared.conversationId, ...await existingConversation(
535
+ prepared.key, prepared.conversationId, prepared.persistent,
536
+ async () => acquirePrepared(await prepared.process())
537
+ ) };
538
+ }
539
+
540
+ async function runPreparedConversationTurn(prepared) {
541
+ const selected = await selectPreparedConversation(prepared);
542
+ const turn = prepared.turn(selected);
543
+ return runConversationTurn(selected.target, selected.conversationId, selected.tracked,
544
+ turn.input, turn.options).catch(prepared.failure);
545
+ }
546
+
547
+ async function readPreparedConversation(prepared, input) {
548
+ const { conversationId, target, tracked } = await existingPreparedConversation(prepared);
549
+ if (!target) throw prepared.readUnavailable();
550
+ if (!input.persistent) return prepared.projectResult(
551
+ await readConversation(target, conversationId, tracked, {}, prepared.readError), tracked?.executionProfile);
552
+ return readPersistentConversation(target, conversationId, tracked, prepared.messages).catch(prepared.failure);
553
+ }
554
+
555
+ async function waitPreparedConversationTurn(prepared, input) {
556
+ const { conversationId, target, tracked } = await existingPreparedConversation(prepared);
557
+ if (!target) throw prepared.readUnavailable();
558
+ return waitForConversationTurn(target, conversationId, tracked, input, prepared.readError);
559
+ }
560
+
561
+ async function stopPreparedConversation(prepared, input) {
562
+ const { conversationId, target, tracked } = await existingPreparedConversation(prepared);
563
+ return stopConversation(target, conversationId, tracked, { persistent: input.persistent }).catch(prepared.failure);
564
+ }
565
+
566
+ async function deletePreparedConversation(prepared, input) {
567
+ const { conversationId, key, target, tracked } = await existingPreparedConversation(prepared);
568
+ const result = await deleteConversation(key, target, conversationId, tracked, {
569
+ allowMissing: Boolean(input.persistent || prepared.scoped), onRemoved: prepared.onRemoved
570
+ });
571
+ if (!result.deleted) return result;
572
+ const cleanup = prepared.cleanupAfterDelete;
573
+ const providerExit = cleanup ? await closeSession(cleanup.sessionId, cleanup.options, cleanup.application) : null;
574
+ return { conversationId, deleted: true, ok: providerExit?.ok !== false,
575
+ ...(providerExit ? { providerExit } : {}) };
576
+ }
577
+
578
+ async function sendPreparedMessage(prepared, options) {
579
+ if (Object.hasOwn(prepared, "value")) return prepared.value;
580
+ return prepared.project(await sendMessage(prepared.key, prepared.input, options, prepared.application));
581
+ }
582
+
583
+ async function inspectPreparedMessage(prepared) {
584
+ const target = await acquirePrepared(prepared.process);
585
+ const observation = await inspectOpenCodeMessageAdmission(target.server.client, prepared.threadId, prepared.inputMessageId);
586
+ return prepared.project(observation);
587
+ }
588
+
589
+ async function interruptPreparedTurn(prepared) {
590
+ const target = processes.get(prepared.key) || null;
591
+ return interruptTurn(prepared.key, target, {
592
+ threadId: target ? "" : prepared.threadId, writeRun: prepared.writeRun
593
+ }).catch(prepared.failure);
594
+ }
595
+
596
+ function observePreparedEvents(target, turn, abortController, signal, projection) {
597
+ const observation = observeOpenCodeEvents(target.server.client, target.upstreamSessionId, {
598
+ abortController, signal,
599
+ eventStartedAt: turn ? turn.eventStartedAt : null,
600
+ readError: projection.readError,
601
+ onEvent: summary => projection.onEvent(summary, target)
602
+ });
603
+ void observation.completion.then(() => {
604
+ projection.failure(observation.readFailure());
605
+ projection.closed();
606
+ });
607
+ return {
608
+ close: observation.close,
609
+ readFailure: observation.readFailure,
610
+ waitUntilReady(options) {
611
+ const ready = observation.waitUntilReady(options);
612
+ void ready.catch(projection.failure);
613
+ return ready;
614
+ }
615
+ };
616
+ }
617
+
618
+ async function stopUnobservedSession(target, threadId) {
619
+ const signal = AbortSignal.timeout(OPENCODE_INTERRUPT_TIMEOUT_MS);
620
+ try {
621
+ const confirmed = await target.server.client.interrupt(threadId, { signal });
622
+ const status = await target.server.client.sessionStatus(threadId, { signal });
623
+ if (confirmed !== true || status?.type !== "idle") {
624
+ throw new Error("OpenCode did not confirm that the session stopped.");
625
+ }
626
+ } catch (error) {
627
+ // The process owns the final stop proof when its control channel cannot.
628
+ // Stop this exact server, even if another connection has since replaced it.
629
+ const server = target.server;
630
+ const proof = sharedProcess?.server.stop === server.stop
631
+ ? await stop("opencode-observation-lost")
632
+ : await server.stop();
633
+ if (proof?.exited !== true) {
634
+ throw new Error("OpenCode observation was lost and process exit could not be verified.", { cause: error });
635
+ }
636
+ for (const affected of processes.values()) {
637
+ if (affected.server.stop === server.stop) {
638
+ affected.abortController.abort(new Error("OpenCode's shared service was stopped after observation failed. Send a message to continue."));
639
+ }
640
+ }
641
+ }
642
+ }
643
+
644
+ async function createConversation(key, target, input, executionProfile = null) {
645
+ await target.server.client.prepareDirectory({ signal: target.abortController?.signal });
646
+ const conversation = await target.server.client.createSession({
647
+ agent: input.agent,
648
+ location: { directory: target.workdir },
649
+ model: input.model
650
+ });
651
+ temporaryConversations.set(`${key}\0${conversation.id}`, {
652
+ active: false,
653
+ executionProfile,
654
+ target
655
+ });
656
+ return conversation;
657
+ }
658
+
659
+ async function selectConversation(parentKey, target, conversationId, input, registration) {
660
+ await target.server.client.prepareDirectory({ signal: target.abortController?.signal });
661
+ if (!conversationId) {
662
+ const created = await target.server.client.createSession({
663
+ agent: input.agent,
664
+ location: { directory: target.workdir },
665
+ model: input.model
666
+ });
667
+ conversationId = created.id;
668
+ } else {
669
+ await target.server.client.switchModel(conversationId, input.model);
670
+ await target.server.client.switchAgent(conversationId, input.agent);
671
+ }
672
+ const key = `${parentKey}\0${conversationId}`;
673
+ const tracked = temporaryConversations.get(key) || { active: false, target };
674
+ tracked.target = target;
675
+ tracked.executionProfile = registration.executionProfile;
676
+ tracked.conversationId = conversationId;
677
+ tracked.promptContext = registration.promptContext;
678
+ temporaryConversations.set(key, tracked);
679
+ await registration.onSelected();
680
+ return { conversationId, key, tracked };
681
+ }
682
+
683
+ async function existingConversation(parentKey, conversationId, persistent, prepareTarget) {
684
+ const key = `${parentKey}\0${conversationId}`;
685
+ let tracked = temporaryConversations.get(key) || null;
686
+ let target = tracked?.target || processes.get(parentKey) || null;
687
+ if (persistent && (!target || target.abortController.signal.aborted)) {
688
+ temporaryConversations.delete(key);
689
+ tracked = null;
690
+ target = await prepareTarget();
691
+ }
692
+ return { key, tracked, target };
693
+ }
694
+
695
+ async function readConversation(target = {}, conversationId = "", tracked = null, options = {}, readError) {
696
+ const messages = await target.server.client.messages(conversationId, {
697
+ limit: 100,
698
+ order: "desc"
699
+ }, options);
700
+ options.signal?.throwIfAborted();
701
+ const result = openCodeLastAssistantResult(messages, { readError });
702
+ return {
703
+ conversationId,
704
+ error: text(tracked?.error?.message) || result.error,
705
+ ok: !tracked?.error && !result.error,
706
+ runId: tracked?.inputMessageId || "",
707
+ status: tracked?.active ? "inProgress"
708
+ : tracked?.interrupted ? "interrupted"
709
+ : tracked?.error || result.error ? "failed" : "completed",
710
+ text: result.text
711
+ };
712
+ }
713
+
714
+ async function readPersistentConversation(target, conversationId, tracked, projection) {
715
+ const { readError } = projection;
716
+ let status = await target.server.client.sessionStatus(conversationId);
717
+ if (!tracked && status.type !== "idle") {
718
+ await stopUnobservedSession(target, conversationId);
719
+ status = { type: "idle" };
720
+ }
721
+ const rows = openCodeMessageRows(await target.server.client.messages(conversationId));
722
+ const result = openCodeLastAssistantResult(rows, { readError });
723
+ const messages = openCodePersistentConversationMessages(rows, projection.messageId);
724
+ let conversationStatus = "completed";
725
+ if (status.type !== "idle" || tracked?.active) conversationStatus = "inProgress";
726
+ else if (tracked?.interrupted) conversationStatus = "interrupted";
727
+ else if (tracked?.error || result.error) conversationStatus = "failed";
728
+ return {
729
+ conversationId,
730
+ ok: true,
731
+ messages,
732
+ text: result.text,
733
+ admitted: Boolean(projection.inputMessageId && rows.some((row) => row.id === projection.inputMessageId)),
734
+ error: text(tracked?.error?.message) || result.error,
735
+ runId: tracked?.inputMessageId || [...rows].reverse().find((row) => row.type === "user")?.id || "",
736
+ status: conversationStatus
737
+ };
738
+ }
739
+
740
+ async function waitForConversationTurn(target, conversationId, tracked, input = {}, readError) {
741
+ if (tracked?.completion && (tracked.executionProfile || !input.timeoutMs)) {
742
+ return tracked.completion;
743
+ }
744
+ await waitForOpenCodeMessages(target.server.client, conversationId, tracked?.inputMessageId || "", {
745
+ signal: input.timeoutMs ? AbortSignal.timeout(Number(input.timeoutMs)) : undefined,
746
+ readError
747
+ });
748
+ return readConversation(target, conversationId, null, {}, readError);
749
+ }
750
+
751
+ async function runConversationTurn(target, conversationId, tracked, input, options) {
752
+ if (tracked.active && tracked.error) {
753
+ await stopUnobservedSession(tracked.target, conversationId);
754
+ tracked.active = false;
755
+ }
756
+ if (tracked.active && input.steer === true) {
757
+ const messageId = input.steeringId;
758
+ await steerOpenCodeTurn(target.server.client, conversationId, tracked, {
759
+ agent: input.agent,
760
+ id: messageId,
761
+ model: input.model,
762
+ prompt: input.prompt,
763
+ attachments: input.attachments
764
+ }, { signal: AbortSignal.any([target.abortController.signal, tracked.abortController.signal]) });
765
+ return {
766
+ conversationId,
767
+ ok: true,
768
+ runId: tracked.inputMessageId,
769
+ status: "inProgress",
770
+ deliveryMode: "steer"
771
+ };
772
+ }
773
+ if (tracked.active) {
774
+ throw Object.assign(new Error("This conversation is still working."), {
775
+ code: "assistant_opencode_conversation_busy", details: {}, statusCode: 409
776
+ });
777
+ }
778
+ tracked.active = true;
779
+ tracked.error = null;
780
+ tracked.interrupted = false;
781
+ const turnAbort = new AbortController();
782
+ tracked.abortController = turnAbort;
783
+ const inputMessageId = input.id;
784
+ const observedTarget = { ...target, upstreamSessionId: conversationId };
785
+ const signal = AbortSignal.any([target.abortController.signal, turnAbort.signal, ...(options.signal ? [options.signal] : [])]);
786
+ const events = options.observation
787
+ ? observePreparedEvents(observedTarget, null, turnAbort, signal, options.observation())
788
+ : options.observe(observedTarget, turnAbort, signal);
789
+ let admitted;
790
+ try {
791
+ admitted = await dispatchOpenCodeTurn(null, null, tracked, {
792
+ get agent() { return input.agent; },
793
+ id: inputMessageId,
794
+ get model() { return input.model; },
795
+ get prompt() { return input.prompt; },
796
+ get attachments() { return input.attachments; }
797
+ }, {
798
+ get client() { return target.server.client; },
799
+ get conversationId() { return conversationId; },
800
+ events, signal,
801
+ readiness: options.readiness,
802
+ beforeDispatch: options.beforeDispatch
803
+ });
804
+ } catch (error) {
805
+ await events.close();
806
+ tracked.error = error;
807
+ if (tracked.promptAttempted) await stopUnobservedSession(observedTarget, conversationId);
808
+ tracked.active = false;
809
+ throw error;
810
+ }
811
+ tracked.inputMessageId = text(admitted.id);
812
+ tracked.completion = (async () => {
813
+ const completion = options.completion;
814
+ const onMessages = completion.onMessages;
815
+ const result = await observeOpenCodeTurnCompletion(runtime, observedTarget, conversationId, tracked, {
816
+ ...completion, events, signal,
817
+ ...(onMessages ? {
818
+ onMessages: (messages, inputMessageId) => onMessages(
819
+ openCodePersistentConversationMessages(openCodeRowsForInput(messages, inputMessageId), completion.messageId),
820
+ inputMessageId
821
+ )
822
+ } : {}),
823
+ projectResult: async () => {
824
+ const result = completion.projectResult(
825
+ await readConversation(target, conversationId, null, { signal }, completion.readError)
826
+ );
827
+ return { ...result, text: input.outputSchema ? openCodeStructuredOutput(result.text) : result.text };
828
+ }
829
+ });
830
+ if (result.interrupted) {
831
+ return { conversationId, ok: true, runId: tracked.inputMessageId, status: "interrupted", text: "" };
832
+ }
833
+ return result;
834
+ })();
835
+ // Keep a failed admitted turn observable to the client's conversation reads.
836
+ void tracked.completion.catch((error) => {
837
+ if (tracked.abortController === turnAbort) tracked.error = error;
838
+ });
839
+ options.onCompletion(tracked.completion);
840
+ return options.waitForCompletion ? tracked.completion : {
841
+ conversationId,
842
+ ok: true,
843
+ runId: tracked.inputMessageId,
844
+ status: "inProgress",
845
+ threadId: conversationId,
846
+ turnId: tracked.inputMessageId
847
+ };
848
+ }
849
+
850
+ async function stopConversation(target, conversationId, tracked, { persistent = false } = {}) {
851
+ if (!target) {
852
+ return { conversationId, ok: true, stopped: false };
853
+ }
854
+ try {
855
+ if (tracked?.active && tracked.error) {
856
+ await stopUnobservedSession(target, conversationId);
857
+ } else {
858
+ const confirmed = await target.server.client.interrupt(conversationId, {
859
+ signal: AbortSignal.timeout(OPENCODE_INTERRUPT_TIMEOUT_MS)
860
+ });
861
+ const status = persistent ? await target.server.client.sessionStatus(conversationId, {
862
+ signal: AbortSignal.timeout(OPENCODE_INTERRUPT_TIMEOUT_MS)
863
+ }) : null;
864
+ if (confirmed !== true || status && status.type !== "idle") {
865
+ throw Object.assign(new Error("OpenCode did not confirm Stop. Try Stop again."), {
866
+ code: "assistant_opencode_interrupt_unconfirmed", details: {}, statusCode: 502
867
+ });
868
+ }
869
+ }
870
+ } catch (error) {
871
+ if (persistent && error.statusCode === 404) return { conversationId, ok: true, stopped: true };
872
+ if (error?.name === "TimeoutError") {
873
+ throw Object.assign(new Error("OpenCode did not confirm Stop within 5 seconds. Try Stop again."), {
874
+ code: "assistant_opencode_interrupt_timeout", details: {}, statusCode: 504
875
+ });
876
+ }
877
+ throw error;
878
+ }
879
+ if (tracked) {
880
+ tracked.interrupted = true;
881
+ tracked.abortController?.abort();
882
+ await tracked.completion?.catch(() => null);
883
+ tracked.active = false;
884
+ }
885
+ return { conversationId, ok: true, stopped: true };
886
+ }
887
+
888
+ async function deleteConversation(key, target, conversationId, tracked, { allowMissing, onRemoved }) {
889
+ if (!target) {
890
+ temporaryConversations.delete(key);
891
+ await onRemoved();
892
+ return { conversationId, deleted: false, ok: true };
893
+ }
894
+ try {
895
+ await target.server.client.deleteSession(conversationId);
896
+ } catch (error) {
897
+ if (!allowMissing || error.statusCode !== 404) throw error;
898
+ }
899
+ if (tracked) {
900
+ tracked.interrupted = true;
901
+ tracked.abortController?.abort();
902
+ await tracked.completion?.catch(() => null);
903
+ }
904
+ temporaryConversations.delete(key);
905
+ await onRemoved();
906
+ return { conversationId, deleted: true, ok: true };
907
+ }
908
+
909
+ async function writeConversationProjection(sessionId, messages = null, {
910
+ inputMessageId = "",
911
+ streaming = false
912
+ } = {}, projection) {
913
+ let failure = "";
914
+ let providerApiFailure = false;
915
+ const rows = (inputMessageId
916
+ ? openCodeRowsForInput(messages, inputMessageId)
917
+ : openCodeMessageRows(messages)).filter((message) => message?.type === "assistant");
918
+ for (const [index, message] of rows.entries()) {
919
+ failure ||= projection.readError(message);
920
+ providerApiFailure ||= openCodeProviderApiFailure(message.error);
921
+ if (message.summary === true) continue;
922
+ const assistantText = assistantMessageText(message);
923
+ const messageId = assistantText ? projection.messageId(message.id, "assistant") : "";
924
+ const inFlight = streaming && index === rows.length - 1;
925
+ const reasoning = (message.content || []).filter((part) => part.type === "reasoning" && text(part.text))
926
+ .map((part) => ({
927
+ messageId: message.id,
928
+ partId: part.id,
929
+ text: part.text,
930
+ createdAt: message.time?.created,
931
+ complete: Boolean(part.time?.end)
932
+ }));
933
+ await projection.reasoning(reasoning, {
934
+ complete: !inFlight || Boolean(message.time?.completed || message.finish),
935
+ flush: !streaming || Boolean(assistantText && !inFlight)
936
+ });
937
+ if (streaming && assistantText && !inFlight) {
938
+ // A completed round persists as soon as the next one starts, so
939
+ // superseded narration never evaporates from the transcript.
940
+ const turn = await projection.store.writeConversationAssistantMessage(sessionId, {
941
+ messageId,
942
+ text: assistantText
943
+ });
944
+ projection.store.completeConversationStreamMessage(sessionId, messageId);
945
+ await projection.publishTurn(turn);
946
+ continue;
947
+ }
948
+ if (streaming && assistantText && inFlight) {
949
+ const conversationStream = projection.store.updateConversationStream(sessionId, {
950
+ turnId: inputMessageId,
951
+ messageId,
952
+ text: assistantText
953
+ });
954
+ if (conversationStream) {
955
+ await projection.publishStream(conversationStream);
956
+ }
957
+ continue;
958
+ }
959
+ if (assistantText && !streaming) {
960
+ const turn = await projection.store.writeConversationAssistantMessage(sessionId, {
961
+ messageId,
962
+ text: assistantText
963
+ });
964
+ projection.store.completeConversationStreamMessage(sessionId, messageId);
965
+ await projection.publishTurn(turn);
966
+ }
967
+ }
968
+ return { failure, providerApiFailure };
969
+ }
970
+
971
+ async function sendMessage(key, input, options, application) {
972
+ if (input.duplicate) {
973
+ const currentTurn = openCodeTurnSnapshot(turns.get(key));
974
+ return {
975
+ delivered: true,
976
+ duplicate: true,
977
+ ok: true,
978
+ thread: { id: currentTurn?.threadId || input.threadId },
979
+ turn: currentTurn
980
+ };
981
+ }
982
+ const currentTurn = turns.get(key);
983
+ if (currentTurn?.observationError) {
984
+ await monitors.get(key);
985
+ }
986
+ if (currentTurn?.active && currentTurn.observationError) {
987
+ // An explicit Send may retry stopping uncertain work, but cannot overlap it.
988
+ try {
989
+ await stopUnobservedSession(processes.get(key), currentTurn.threadId);
990
+ } catch (error) {
991
+ return { failure: { error, recovery: true } };
992
+ }
993
+ currentTurn.active = false;
994
+ currentTurn.state = "interrupted";
995
+ await application.writeRun(currentTurn, "interrupted", currentTurn.observationError);
996
+ }
997
+ const currentMonitor = monitors.get(key);
998
+ let currentThreadId = input.threadId;
999
+ const ownershipMatchesTurn = Boolean(
1000
+ currentMonitor &&
1001
+ options.turnOwnership &&
1002
+ text(options.turnOwnership.threadId) === currentThreadId &&
1003
+ text(options.turnOwnership.turnId) === text(currentTurn?.id)
1004
+ );
1005
+ if (ownershipMatchesTurn && options.turnOwnership.reusable !== true) {
1006
+ return { ownerConflict: true, threadId: currentThreadId,
1007
+ turn: openCodeTurnSnapshot(currentTurn, currentThreadId) };
1008
+ }
1009
+ const eventStartedAt = Date.now();
1010
+ const startedAt = new Date(eventStartedAt).toISOString();
1011
+ const providerMessageId = input.id;
1012
+ const startingTurn = currentMonitor
1013
+ ? null
1014
+ : {
1015
+ active: true,
1016
+ error: "",
1017
+ eventStartedAt,
1018
+ id: providerMessageId,
1019
+ inputMessageId: providerMessageId,
1020
+ startedAt,
1021
+ state: "starting",
1022
+ threadId: currentThreadId,
1023
+ updatedAt: startedAt
1024
+ };
1025
+ if (startingTurn) {
1026
+ turns.set(key, startingTurn);
1027
+ await application.writeRun(startingTurn, "starting");
1028
+ }
1029
+ let admitted = null;
1030
+ let target = null;
1031
+ let admission = null;
1032
+ let dispatchTurn = null;
1033
+ try {
1034
+ const preparation = await application.prepare({ overwrite: !currentMonitor, threadId: currentThreadId });
1035
+ const preparedTarget = preparation.process ? await acquirePrepared(preparation.process) : preparation.target;
1036
+ target = await ensureOpenCodeSession(preparedTarget, preparation.session);
1037
+ currentThreadId = target.upstreamSessionId;
1038
+ if (startingTurn) {
1039
+ startingTurn.threadId = currentThreadId;
1040
+ }
1041
+ const prompt = await application.prompt();
1042
+ let eventReady = null;
1043
+ if (!currentMonitor) {
1044
+ admission = Promise.withResolvers();
1045
+ eventReady = Promise.withResolvers();
1046
+ beginMessageMonitor(key, target, { id: providerMessageId, eventStartedAt, startedAt }, {
1047
+ ...options, admission, eventReady
1048
+ }, application.monitor);
1049
+ }
1050
+ dispatchTurn = turns.get(key);
1051
+ admitted = await dispatchOpenCodeTurn(null, null, dispatchTurn, {
1052
+ get agent() { return prompt.agent; },
1053
+ delivery: currentMonitor ? "steer" : "queue",
1054
+ id: providerMessageId,
1055
+ get model() { return prompt.model; },
1056
+ get prompt() { return prompt.prompt; },
1057
+ get attachments() { return prompt.attachments; }
1058
+ }, {
1059
+ get client() { return target.server.client; },
1060
+ get conversationId() { return target.upstreamSessionId; },
1061
+ events: eventReady ? { waitUntilReady: () => eventReady.promise } : null,
1062
+ get signal() {
1063
+ return AbortSignal.any([
1064
+ target.abortController.signal,
1065
+ turns.get(key).abortController.signal
1066
+ ]);
1067
+ },
1068
+ beforeDispatch: () => prompt.beforeDispatch(target.upstreamSessionId),
1069
+ promptTimeoutMs: options.promptTimeoutMs,
1070
+ authorizeAttachments: prompt.authorizeAttachments
1071
+ });
1072
+ } catch (error) {
1073
+ if (error?.statusCode >= 400 && error.statusCode < 500 && error.statusCode !== 408) {
1074
+ await input.onPromptRejected?.();
1075
+ }
1076
+ admission?.reject(error);
1077
+ if (admission) await monitors.get(key);
1078
+ if (startingTurn && !admission && !monitors.has(key)) {
1079
+ startingTurn.active = false;
1080
+ startingTurn.error = text(error?.message) || "OpenCode prompt delivery failed.";
1081
+ startingTurn.state = "failed";
1082
+ startingTurn.updatedAt = new Date().toISOString();
1083
+ await application.writeRun(
1084
+ startingTurn,
1085
+ "failed",
1086
+ startingTurn.error
1087
+ ).catch(() => null);
1088
+ }
1089
+ return { failure: { error, attempted: dispatchTurn?.promptAttempted === true, threadId: currentThreadId,
1090
+ turn: openCodeTurnSnapshot(turns.get(key) || startingTurn, currentThreadId) } };
1091
+ }
1092
+ let conversationTurn;
1093
+ try {
1094
+ conversationTurn = await application.commit(admitted);
1095
+ } catch (error) {
1096
+ admission?.reject(error);
1097
+ throw error;
1098
+ }
1099
+ const activeMonitor = monitors.get(key);
1100
+ if (activeMonitor) {
1101
+ const activeTurn = turns.get(key);
1102
+ activeTurn.inputMessageId = text(admitted.id);
1103
+ activeTurn.updatedAt = new Date().toISOString();
1104
+ } else {
1105
+ beginMessageMonitor(key, target, {
1106
+ ...admitted,
1107
+ eventStartedAt,
1108
+ startedAt
1109
+ }, options, application.monitor);
1110
+ }
1111
+ admission?.resolve();
1112
+ const turn = openCodeTurnSnapshot(turns.get(key), target.upstreamSessionId);
1113
+ return {
1114
+ conversationTurn,
1115
+ delivered: true,
1116
+ deliveryMode: currentMonitor ? "steer" : "new_turn",
1117
+ ok: true,
1118
+ thread: { id: target.upstreamSessionId },
1119
+ turn,
1120
+ workdir: input.workdir
1121
+ };
1122
+ }
1123
+
1124
+ function hasActiveTemporaryConversation(sessionId) {
1125
+ return [...temporaryConversations.values()].some((entry) => (
1126
+ !entry.reasoningSummary && entry.active === true && entry.target?.sessionId === sessionId
1127
+ ));
1128
+ }
1129
+
1130
+ async function reconcileSessions(sessions = [], options = {}, preparation) {
1131
+ const results = [];
1132
+ for (const session of sessions) {
1133
+ const sessionId = text(session?.sessionId || session?.id);
1134
+ try {
1135
+ const prepared = await preparation.session(sessionId, session, options);
1136
+ const resumed = await reconcilePreparedSession(prepared, prepared.activeRun, options);
1137
+ results.push({ ok: true, resumed, sessionId });
1138
+ } catch (error) {
1139
+ results.push({ error: text(error?.message), ok: false, sessionId });
1140
+ }
1141
+ }
1142
+ return {
1143
+ failed: results.filter((result) => result.ok === false),
1144
+ ok: results.every((result) => result.ok),
1145
+ results,
1146
+ sessionCount: results.length
1147
+ };
1148
+ }
1149
+
1150
+ async function reconcilePreparedSession(prepared, activeRun, options) {
1151
+ const target = await acquirePrepared(await prepared.process());
1152
+ const current = await ensureOpenCodeSession(target, prepared.session());
1153
+ return reconcileSessionTurn(prepared.key, current, activeRun, options, prepared.observation).catch(prepared.failure);
1154
+ }
1155
+
1156
+ async function reconcileSessionTurn(key, target, activeRun, options, application) {
1157
+ if (activeRun?.observationError) {
1158
+ await stopUnobservedSession(target, activeRun.threadId);
1159
+ await application.writeRun({ ...activeRun, id: activeRun.turnId }, "interrupted", activeRun.observationError);
1160
+ } else if (activeRun) {
1161
+ beginMessageMonitor(key, target, {
1162
+ restored: true,
1163
+ eventStartedAt: Date.parse(text(activeRun.startedAt)) || Date.now(),
1164
+ id: text(activeRun.turnId) || application.fallbackTurnId,
1165
+ startedAt: text(activeRun.startedAt)
1166
+ }, options, application.monitor);
1167
+ }
1168
+ return Boolean(activeRun && !activeRun.observationError);
1169
+ }
1170
+
1171
+ function beginMessageMonitor(key, target, admitted, options, projection) {
1172
+ target = { ...target };
1173
+ const existing = monitors.get(key);
1174
+ if (existing) {
1175
+ return existing;
1176
+ }
1177
+ const metadata = projection.prepare(admitted, options);
1178
+ const eventStartedAt = Number(admitted.eventStartedAt) || Date.now();
1179
+ const startedAt = text(admitted.startedAt) || new Date(eventStartedAt).toISOString();
1180
+ const turn = {
1181
+ abortController: new AbortController(),
1182
+ admission: options.admission,
1183
+ active: true,
1184
+ error: "",
1185
+ eventStartedAt,
1186
+ id: text(admitted.id),
1187
+ inputMessageId: text(admitted.id),
1188
+ startedAt,
1189
+ state: options.admission ? "starting" : "active",
1190
+ ...metadata.fields,
1191
+ threadId: target.upstreamSessionId,
1192
+ updatedAt: startedAt
1193
+ };
1194
+ const monitor = beginMonitor(key, target, turn, projection.create(target, turn, metadata, options));
1195
+ void monitor.catch(error => projection.onError(error));
1196
+ return monitor;
1197
+ }
1198
+
1199
+ function beginMonitor(key, target, turn, {
1200
+ observe, observation, eventReady, readiness, finalResponse,
1201
+ writeRun, projectMessages, completeResult, onRetired
1202
+ }) {
1203
+ turns.set(key, turn);
1204
+ const signal = AbortSignal.any([target.abortController.signal, turn.abortController.signal]);
1205
+ const monitor = Promise.resolve().then(async () => {
1206
+ const events = observation
1207
+ ? observePreparedEvents(target, turn, turn.abortController, signal, observation())
1208
+ : observe(turn, signal);
1209
+ if (eventReady) {
1210
+ void events.waitUntilReady(readiness).then(eventReady.resolve, eventReady.reject);
1211
+ }
1212
+ let finalState = "completed";
1213
+ let failure = "";
1214
+ let credentialFailure = false;
1215
+ let providerApiFailure = false;
1216
+ try {
1217
+ await turn.admission?.promise;
1218
+ signal.throwIfAborted();
1219
+ turn.state = "active";
1220
+ await writeRun(turn, "active");
1221
+ const completion = await waitForOpenCodeFinalResponse(
1222
+ target.server.client,
1223
+ target.upstreamSessionId,
1224
+ turn,
1225
+ {
1226
+ ...finalResponse,
1227
+ // Preserve the reasoning-only completion before the native recovery
1228
+ // input replaces the projection boundary. Replayed writes are no-ops.
1229
+ beforeRecovery: async completion => {
1230
+ await projectMessages(completion.messages, { inputMessageId: turn.inputMessageId });
1231
+ await finalResponse.beforeRecovery?.(completion);
1232
+ },
1233
+ onMessages: async (messages, inputMessageId) => {
1234
+ const latest = openCodeLastAssistantResult(messages, { readError: finalResponse.readError }).message;
1235
+ const phase = latest?.summary === true && Number(latest.time?.created) >= turn.eventStartedAt &&
1236
+ !latest.time?.completed && !latest.finish && !latest.error ? "compacting" : "";
1237
+ if (turn.active && !signal.aborted && text(turn.phase) !== phase) {
1238
+ turn.phase = phase;
1239
+ turn.updatedAt = new Date().toISOString();
1240
+ await writeRun(turn, turn.state);
1241
+ }
1242
+ return projectMessages(messages, { inputMessageId, streaming: true });
1243
+ },
1244
+ readFailure: events.readFailure,
1245
+ signal
1246
+ }
1247
+ );
1248
+ const projection = await projectMessages(completion.messages, {
1249
+ inputMessageId: turn.inputMessageId
1250
+ });
1251
+ signal.throwIfAborted();
1252
+ failure = projection.failure || text(events.readFailure()?.message);
1253
+ providerApiFailure = projection.providerApiFailure || openCodeProviderApiFailure(events.readFailure());
1254
+ if (!failure && !turn.interruptRequested && !text(completion.result?.text)) {
1255
+ failure = "OpenCode finished without a user-facing final response. Please send your message again.";
1256
+ finalState = "failed";
1257
+ } else if (failure) {
1258
+ credentialFailure = openCodeCredentialFailure(failure);
1259
+ finalState = "failed";
1260
+ } else if (turn.interruptRequested) {
1261
+ finalState = "interrupted";
1262
+ }
1263
+ } catch (error) {
1264
+ if (turn.interruptAcknowledged) {
1265
+ finalState = "interrupted";
1266
+ } else {
1267
+ const cause = signal.aborted ? signal.reason : error;
1268
+ failure = text(cause?.message) || "OpenCode turn failed.";
1269
+ credentialFailure = openCodeCredentialFailure(failure);
1270
+ providerApiFailure = openCodeProviderApiFailure(cause);
1271
+ finalState = target.abortController.signal.aborted
1272
+ ? "cancelled"
1273
+ : "failed";
1274
+ if (!target.abortController.signal.aborted) {
1275
+ turn.observationError = failure;
1276
+ try {
1277
+ await writeRun(turn, turn.state, failure);
1278
+ } catch {
1279
+ // A failed store must not prevent stopping native work.
1280
+ }
1281
+ try {
1282
+ await stopUnobservedSession(target, turn.threadId);
1283
+ } catch (stopError) {
1284
+ failure = `${failure} ${stopError.message}`;
1285
+ finalState = "active";
1286
+ }
1287
+ }
1288
+ }
1289
+ } finally {
1290
+ await events.close();
1291
+ if (credentialFailure || finalState === "failed") {
1292
+ failure = await completeResult(turn, { credentialFailure, providerApiFailure, finalState, failure });
1293
+ }
1294
+ turn.active = finalState === "active";
1295
+ turn.error = failure;
1296
+ turn.state = finalState;
1297
+ turn.updatedAt = new Date().toISOString();
1298
+ await writeRun(turn, finalState, failure).catch(() => null);
1299
+ }
1300
+ return openCodeTurnSnapshot(turn, target.upstreamSessionId);
1301
+ }).finally(() => {
1302
+ if (monitors.get(key) === monitor) {
1303
+ monitors.delete(key);
1304
+ }
1305
+ onRetired();
1306
+ });
1307
+ monitors.set(key, monitor);
1308
+ return monitor;
1309
+ }
1310
+
1311
+ async function ensurePreparedSessionReadiness(prepared) {
1312
+ if (Object.hasOwn(prepared, "value")) return prepared.value;
1313
+ let target = processes.get(prepared.key);
1314
+ const connection = await prepared.connection();
1315
+ const { ready, unhealthyProcess, missingUpstream } = await inspectSessionReadiness(target, {
1316
+ selection: prepared.selection, workdir: prepared.workdir, connection,
1317
+ changed: prepared.changed, refresh: prepared.refresh
1318
+ });
1319
+ if (!ready) {
1320
+ const { value: recovered } = await prepared.prepare(recovery => recoverSessionReadiness(target, {
1321
+ unhealthyProcess, missingUpstream,
1322
+ get key() { return recovery.key; }
1323
+ }, async () => {
1324
+ const configuration = recovery.configuration();
1325
+ async function ensureProcess() {
1326
+ return acquirePrepared(await configuration.process());
1327
+ }
1328
+ const current = await ensureProcess();
1329
+ return ensureOpenCodeSession(current, configuration.session);
1330
+ }));
1331
+ if (recovered?.ok === false) return recovered;
1332
+ target = recovered;
1333
+ }
1334
+ const savedRun = prepared.observation.run;
1335
+ if (needsObservationRecovery(prepared.key, savedRun)) {
1336
+ await prepared.observation.write(async (run, commit) => {
1337
+ if (!needsObservationRecovery(prepared.key, run)) return;
1338
+ const threadId = text(run.threadId) || target.upstreamSessionId;
1339
+ await recoverObservation(target, threadId, () => commit(writeRun => (
1340
+ recordStoppedObservation(prepared.key, run, threadId, writeRun)
1341
+ )));
1342
+ });
1343
+ }
1344
+ return {
1345
+ ok: true,
1346
+ thread: { id: target.upstreamSessionId },
1347
+ turn: openCodeTurnSnapshot(turns.get(prepared.key), target.upstreamSessionId),
1348
+ workdir: prepared.workdir
1349
+ };
1350
+ }
1351
+
1352
+ async function inspectSessionReadiness(target, { selection, workdir, connection, refresh, changed }) {
1353
+ let ready = false;
1354
+ let unhealthyProcess = null;
1355
+ let missingUpstream = false;
1356
+ if (
1357
+ target?.upstream &&
1358
+ sameOpenCodeSelection(target.upstreamSelection, selection) &&
1359
+ target.workdir === workdir &&
1360
+ text(target.selection?.catalogRevision) === text(selection.catalogRevision) &&
1361
+ target.connectionFingerprint === connection.fingerprint &&
1362
+ target.canonicalUrl === connection.canonicalUrl &&
1363
+ target.endpointCode === connection.endpointCode
1364
+ ) {
1365
+ const observedProcess = sharedProcess;
1366
+ const observedServer = target.server;
1367
+ let healthy = false;
1368
+ try {
1369
+ await target.server.client.health({ signal: AbortSignal.timeout(1_000) });
1370
+ healthy = true;
1371
+ ready = Boolean(await target.server.client.readSession(target.upstreamSessionId));
1372
+ } catch (error) {
1373
+ if (healthy && error?.statusCode !== 404) {
1374
+ throw error;
1375
+ }
1376
+ if (!healthy) {
1377
+ unhealthyProcess = observedProcess;
1378
+ }
1379
+ }
1380
+ const current = await refresh();
1381
+ if (
1382
+ processes.get(current.key) !== target ||
1383
+ target.abortController.signal.aborted ||
1384
+ target.server !== observedServer ||
1385
+ target.workdir !== current.workdir ||
1386
+ !sameOpenCodeSelection(target.upstreamSelection, current.selection) ||
1387
+ text(target.selection?.catalogRevision) !== text(current.selection.catalogRevision)
1388
+ ) {
1389
+ throw changed();
1390
+ }
1391
+ missingUpstream = healthy && !ready;
1392
+ }
1393
+ return { ready, unhealthyProcess, missingUpstream };
1394
+ }
1395
+
1396
+ async function recoverSessionReadiness(target, recovery, prepare) {
1397
+ if (recovery.unhealthyProcess && sharedProcess === recovery.unhealthyProcess) {
1398
+ await stop("opencode-health-check-failed");
1399
+ }
1400
+ if (recovery.missingUpstream && processes.get(recovery.key) === target) {
1401
+ target.upstream = null;
1402
+ }
1403
+ return prepare();
1404
+ }
1405
+
1406
+ function needsObservationRecovery(key, run) {
1407
+ return Boolean(run?.active && run.observationError && !monitors.has(key));
1408
+ }
1409
+
1410
+ async function recordStoppedObservation(key, run, threadId, writeRun) {
1411
+ const stopped = {
1412
+ ...turns.get(key),
1413
+ ...run,
1414
+ id: text(run.turnId),
1415
+ threadId,
1416
+ active: false,
1417
+ state: "interrupted",
1418
+ updatedAt: new Date().toISOString()
1419
+ };
1420
+ await writeRun(stopped, "interrupted", run.observationError);
1421
+ turns.set(key, stopped);
1422
+ }
1423
+
1424
+ async function recoverObservation(target, threadId, commit) {
1425
+ if (threadId !== target.upstreamSessionId) return;
1426
+ const status = await target.server.client.sessionStatus(threadId, {
1427
+ signal: AbortSignal.timeout(OPENCODE_INTERRUPT_TIMEOUT_MS)
1428
+ });
1429
+ if (status?.type !== "idle") return;
1430
+ await commit();
1431
+ }
1432
+
1433
+ async function interruptTurn(key, target, { threadId = "", writeRun } = {}) {
1434
+ const turn = turns.get(key);
1435
+ if (!target) {
1436
+ return {
1437
+ interrupted: false,
1438
+ ok: true,
1439
+ thread: { id: threadId },
1440
+ turn: openCodeTurnSnapshot(turn)
1441
+ };
1442
+ }
1443
+ if (turn?.observationError) {
1444
+ await monitors.get(key);
1445
+ if (turn.active) {
1446
+ await stopUnobservedSession(target, turn.threadId);
1447
+ turn.active = false;
1448
+ turn.state = "interrupted";
1449
+ await writeRun(turn, turn.state, turn.observationError);
1450
+ }
1451
+ return { ok: true, interrupted: true, thread: { id: turn.threadId }, turn: openCodeTurnSnapshot(turn) };
1452
+ }
1453
+ if (turn) {
1454
+ turn.interruptRequested = true;
1455
+ }
1456
+ const interrupted = Boolean(turn?.active);
1457
+ try {
1458
+ const confirmed = await target.server.client.interrupt(target.upstreamSessionId, {
1459
+ signal: AbortSignal.timeout(OPENCODE_INTERRUPT_TIMEOUT_MS)
1460
+ });
1461
+ if (confirmed !== true) {
1462
+ throw Object.assign(new Error("OpenCode did not confirm Stop. Try Stop again."), {
1463
+ code: "assistant_opencode_interrupt_unconfirmed", details: {}, statusCode: 502
1464
+ });
1465
+ }
1466
+ } catch (error) {
1467
+ if (turn) turn.interruptRequested = false;
1468
+ if (error?.name === "TimeoutError") {
1469
+ throw Object.assign(new Error("OpenCode did not confirm Stop within 5 seconds. Try Stop again."), {
1470
+ code: "assistant_opencode_interrupt_timeout", details: {}, statusCode: 504
1471
+ });
1472
+ }
1473
+ throw error;
1474
+ }
1475
+ if (turn?.active && turn.abortController) {
1476
+ turn.interruptAcknowledged = true;
1477
+ turn.abortController.abort();
1478
+ turn.admission?.resolve();
1479
+ await monitors.get(key);
1480
+ }
1481
+ return {
1482
+ interrupted,
1483
+ ok: true,
1484
+ thread: { id: target.upstreamSessionId },
1485
+ turn: openCodeTurnSnapshot(turn, target.upstreamSessionId)
1486
+ };
1487
+ }
1488
+
1489
+ async function readSessionState(current, application) {
1490
+ const context = await application.context(current);
1491
+ const target = processes.get(context.key);
1492
+ const threadId = target?.upstreamSessionId ||
1493
+ application.storedThreadId(context);
1494
+ return {
1495
+ ok: true,
1496
+ terminal: application.terminalHost.read(context.sessionId, target?.terminalSessionId),
1497
+ thread: { id: threadId },
1498
+ turn: openCodeTurnSnapshot(turns.get(context.key), threadId),
1499
+ workdir: context.workdir
1500
+ };
1501
+ }
1502
+
1503
+ async function acquirePreparedTerminal(preparation) {
1504
+ return acquirePrepared(await preparation.process());
1505
+ }
1506
+
1507
+ async function ensurePreparedTerminal(preparation) {
1508
+ const target = await acquirePreparedTerminal(preparation);
1509
+ return ensureOpenCodeSession(target, preparation.session());
1510
+ }
1511
+
1512
+ async function startPreparedTerminal(preparation) {
1513
+ const target = await ensurePreparedTerminal(preparation);
1514
+ return attachTerminal(target, preparation.context, preparation.terminalHost);
1515
+ }
1516
+
1517
+ async function attachTerminal(target, context, terminalHost) {
1518
+ const existing = terminalHost.read(context.sessionId, target.terminalSessionId);
1519
+ if (existing?.ok === true && existing.status !== "exited") {
1520
+ return existing;
1521
+ }
1522
+ const terminal = await target.server.startAttachedTerminal({
1523
+ metadata: {
1524
+ engineId: "opencode",
1525
+ sessionId: context.sessionId
1526
+ },
1527
+ namespace: terminalHost.namespace(context.sessionId),
1528
+ session: context.session,
1529
+ upstreamSessionId: target.upstreamSessionId,
1530
+ workdir: context.workdir
1531
+ });
1532
+ if (terminal?.ok === true && text(terminal.id)) {
1533
+ target.terminalSessionId = text(terminal.id);
1534
+ }
1535
+ return terminal;
1536
+ }
1537
+
1538
+ async function closeTerminal(sessionId, terminalSessionId, terminalHost) {
1539
+ const id = text(terminalSessionId);
1540
+ const result = await terminalHost.close(id, {
1541
+ namespace: terminalHost.namespace(sessionId)
1542
+ });
1543
+ for (const target of processes.values()) {
1544
+ if (target.sessionId === sessionId && target.terminalSessionId === id) {
1545
+ target.terminalSessionId = "";
1546
+ }
1547
+ }
1548
+ return result;
1549
+ }
1550
+
1551
+ function allowTerminalAttachments(sessionId, terminalSessionId, input) {
1552
+ const target = [...processes.values()].find((candidate) =>
1553
+ candidate.sessionId === sessionId && candidate.terminalSessionId === terminalSessionId);
1554
+ if (!target) throw new Error("Reopen the OpenCode terminal before attaching files.");
1555
+ return target.server.client.allowConversationAttachments(target.upstreamSessionId, input.attachments);
1556
+ }
1557
+
1558
+ const runtime = Object.freeze({
1559
+ processes, processStarts, monitors, turns, temporaryConversations,
1560
+ get current() { return sharedProcess; },
1561
+ get closed() { return closed; },
1562
+ get cleanupPending() { return Boolean(failedStartup); },
1563
+ acquire, targetFor, ensure, release, releaseTarget, releaseProcessTarget, closeSession, closeProject,
1564
+ startPreparedProcess, acquirePrepared, createPreparedConversation, runPreparedRenewalTurn, selectPreparedConversation, existingPreparedConversation,
1565
+ runPreparedConversationTurn, readPreparedConversation, waitPreparedConversationTurn, stopPreparedConversation, deletePreparedConversation,
1566
+ sendPreparedMessage, inspectPreparedMessage, interruptPreparedTurn,
1567
+ invalidateRuntimes, releaseRenewalPredecessorProcessExitProof, releaseProcessExitProof, stop, stopUnobservedSession, interruptTurn,
1568
+ ensurePreparedSessionReadiness, inspectSessionReadiness, recoverSessionReadiness, needsObservationRecovery, recoverObservation,
1569
+ createConversation, selectConversation, existingConversation, runConversationTurn, readConversation, readPersistentConversation, waitForConversationTurn,
1570
+ stopConversation, deleteConversation, sendMessage, hasActiveTemporaryConversation, reconcileSessions, reconcilePreparedSession, recordStoppedObservation, beginMessageMonitor, beginMonitor, writeBindings,
1571
+ writeConversationProjection,
1572
+ projectConversationMessages: projectOpenCodeConversationMessages,
1573
+ readSessionState, startPreparedTerminal, attachTerminal, closeTerminal, allowTerminalAttachments,
1574
+ turnSnapshot: openCodeTurnSnapshot, providerApiFailure: openCodeProviderApiFailure
1575
+ });
1576
+ if (scope) sharedRuntimes.set(scope, runtime);
1577
+ return runtime;
1578
+ }