@jskit-ai/assistant-core 0.2.6 → 0.2.8

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 (214) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/package.json +18 -7
  3. package/src/client/browser.js +21 -0
  4. package/src/client/conversation/AssistantAttachmentQueue.vue +25 -108
  5. package/src/client/conversation/AssistantComposerActions.vue +1 -1
  6. package/src/client/conversation/AssistantConversationElement.vue +14 -52
  7. package/src/client/conversation/AssistantGoalControl.vue +18 -12
  8. package/src/client/conversation/AssistantPromptInput.vue +6 -91
  9. package/src/client/conversation/AssistantTranscript.css +505 -0
  10. package/src/client/conversation/AssistantTranscript.vue +24 -1197
  11. package/src/client/conversation/attachmentFiles.js +158 -0
  12. package/src/client/conversation/attachmentQueueItems.js +121 -0
  13. package/src/client/conversation/attachmentUploadRows.js +265 -0
  14. package/src/client/conversation/conversationDisplay.js +54 -0
  15. package/src/client/conversation/messageDelivery.js +13 -9
  16. package/src/client/conversation/retainedConversation.js +14 -3
  17. package/src/client/conversation/submitText.js +13 -2
  18. package/src/client/conversation/transcriptEntries.js +225 -0
  19. package/src/client/conversation/useAssistantAttachments.js +86 -388
  20. package/src/client/conversation/useAssistantQuestions.js +16 -3
  21. package/src/client/conversation/useAssistantSuggestions.js +16 -2
  22. package/src/client/conversation/useScrollToBottom.js +17 -19
  23. package/src/client/conversation/useTextareaAutoGrow.js +115 -0
  24. package/src/client/conversation/useTranscriptScrollFollow.js +525 -0
  25. package/src/client/lib/assistantApi.js +192 -161
  26. package/src/client/types.js +110 -0
  27. package/src/server/conversation/attachments.js +21 -6
  28. package/src/server/conversation/boundedToolLoop.js +1 -0
  29. package/src/server/conversation/claudeClient.js +71 -20
  30. package/src/server/conversation/claudeCommandRun.js +191 -0
  31. package/src/server/conversation/claudeHistory.js +7 -0
  32. package/src/server/conversation/claudeNativeTurn.js +708 -0
  33. package/src/server/conversation/claudeProcess.js +186 -112
  34. package/src/server/conversation/claudeProcessStart.js +214 -0
  35. package/src/server/conversation/claudePromptDelivery.js +183 -0
  36. package/src/server/conversation/claudeSessionCleanup.js +248 -0
  37. package/src/server/conversation/claudeTools.js +15 -3
  38. package/src/server/conversation/claudeTurn.js +212 -1388
  39. package/src/server/conversation/codexAccountReader.js +168 -0
  40. package/src/server/conversation/codexAuthPreflight.js +5 -4
  41. package/src/server/conversation/codexClient.js +18 -6
  42. package/src/server/conversation/codexConfiguration.js +8 -844
  43. package/src/server/conversation/codexConversationStops.js +377 -0
  44. package/src/server/conversation/codexConversationStore.js +119 -85
  45. package/src/server/conversation/codexConversationTurns.js +378 -0
  46. package/src/server/conversation/codexConversations.js +134 -866
  47. package/src/server/conversation/codexDetachedConversation.js +203 -0
  48. package/src/server/conversation/codexDetachedTurn.js +145 -104
  49. package/src/server/conversation/codexEvents.js +5 -200
  50. package/src/server/conversation/codexGoalCommands.js +14 -4
  51. package/src/server/conversation/codexHelperExecution.js +57 -57
  52. package/src/server/conversation/codexHelperThreadLedger.js +30 -288
  53. package/src/server/conversation/codexHelperThreadRecord.js +304 -0
  54. package/src/server/conversation/codexHelperThreadRecovery.js +54 -76
  55. package/src/server/conversation/codexHelperThreads.js +94 -93
  56. package/src/server/conversation/codexHistoryAdapter.js +13 -8
  57. package/src/server/conversation/codexIsolation.js +672 -0
  58. package/src/server/conversation/codexLiveItems.js +379 -0
  59. package/src/server/conversation/codexMessageCommands.js +68 -286
  60. package/src/server/conversation/codexMessageDelivery.js +46 -37
  61. package/src/server/conversation/codexModelCatalog.js +86 -7
  62. package/src/server/conversation/codexNotificationObserver.js +81 -88
  63. package/src/server/conversation/codexNotificationQueue.js +21 -10
  64. package/src/server/conversation/codexObservationControl.js +105 -89
  65. package/src/server/conversation/codexOutput.js +84 -419
  66. package/src/server/conversation/codexProcess.js +29 -1053
  67. package/src/server/conversation/codexProcessLeader.js +1 -0
  68. package/src/server/conversation/codexPromptDispatch.js +241 -0
  69. package/src/server/conversation/codexProvider.js +147 -1807
  70. package/src/server/conversation/codexProviderAuth.js +348 -0
  71. package/src/server/conversation/codexProviderInvalidation.js +371 -0
  72. package/src/server/conversation/codexProviderInventories.js +437 -0
  73. package/src/server/conversation/codexProviderOwner.js +149 -957
  74. package/src/server/conversation/codexProviderRelease.js +465 -0
  75. package/src/server/conversation/codexProviderSelection.js +26 -15
  76. package/src/server/conversation/codexProviderThreads.js +698 -0
  77. package/src/server/conversation/codexProviderTurns.js +453 -0
  78. package/src/server/conversation/codexReasoning.js +32 -23
  79. package/src/server/conversation/codexRenewalCommands.js +18 -6
  80. package/src/server/conversation/codexRenewalThreads.js +7 -22
  81. package/src/server/conversation/codexResultSettlement.js +70 -88
  82. package/src/server/conversation/codexRunJournal.js +91 -97
  83. package/src/server/conversation/codexRuntime.js +426 -0
  84. package/src/server/conversation/codexRuntimeInventory.js +113 -0
  85. package/src/server/conversation/codexRuntimeLifecycle.js +39 -13
  86. package/src/server/conversation/codexRuntimeStart.js +431 -0
  87. package/src/server/conversation/codexRuntimeStop.js +324 -0
  88. package/src/server/conversation/codexSessionCleanup.js +25 -10
  89. package/src/server/conversation/codexSessionReconciliation.js +21 -7
  90. package/src/server/conversation/codexThreadReadiness.js +49 -52
  91. package/src/server/conversation/codexThreadSnapshot.js +210 -0
  92. package/src/server/conversation/codexThreadStatus.js +48 -62
  93. package/src/server/conversation/codexTurn.js +171 -55
  94. package/src/server/conversation/codexTurnActivity.js +210 -0
  95. package/src/server/conversation/codexTurnControl.js +44 -48
  96. package/src/server/conversation/codexTurnEndings.js +610 -0
  97. package/src/server/conversation/codexTurnRecovery.js +97 -848
  98. package/src/server/conversation/codexTurnState.js +138 -111
  99. package/src/server/conversation/completedResponses.js +227 -0
  100. package/src/server/conversation/configuration.js +2 -2
  101. package/src/server/conversation/continuity.js +43 -6
  102. package/src/server/conversation/conversationDelivery.js +394 -0
  103. package/src/server/conversation/conversationEntries.js +365 -0
  104. package/src/server/conversation/conversationEvents.js +192 -0
  105. package/src/server/conversation/conversationGoals.js +187 -0
  106. package/src/server/conversation/conversationHandles.js +237 -0
  107. package/src/server/conversation/conversationReads.js +206 -0
  108. package/src/server/conversation/conversationRefusals.js +58 -0
  109. package/src/server/conversation/conversationReplacement.js +254 -0
  110. package/src/server/conversation/conversationSending.js +273 -0
  111. package/src/server/conversation/conversationStopping.js +121 -0
  112. package/src/server/conversation/conversationTurnDelivery.js +413 -0
  113. package/src/server/conversation/conversationTurns.js +511 -0
  114. package/src/server/conversation/deliveryTiming.js +25 -3
  115. package/src/server/conversation/fileStorage.js +11 -6
  116. package/src/server/conversation/hookBridge.js +14 -3
  117. package/src/server/conversation/index.js +1 -1
  118. package/src/server/conversation/localExecution.js +82 -625
  119. package/src/server/conversation/nativeCommandCallbacks.js +10 -0
  120. package/src/server/conversation/nativeConversationLifecycle.js +379 -0
  121. package/src/server/conversation/nativeHistoryExport.js +27 -18
  122. package/src/server/conversation/normalize.js +9 -0
  123. package/src/server/conversation/openCodeClient.js +295 -366
  124. package/src/server/conversation/openCodeNativeHistory.js +141 -0
  125. package/src/server/conversation/openCodeNativeSession.js +79 -0
  126. package/src/server/conversation/openCodeProcess.js +109 -53
  127. package/src/server/conversation/openCodeProcessOwner.js +565 -0
  128. package/src/server/conversation/openCodeRuntime.js +111 -1814
  129. package/src/server/conversation/openCodeSessionTurns.js +547 -0
  130. package/src/server/conversation/openCodeTemporaryConversations.js +394 -0
  131. package/src/server/conversation/openCodeTools.js +28 -14
  132. package/src/server/conversation/openCodeTurn.js +80 -74
  133. package/src/server/conversation/openCodeTurnMonitor.js +388 -0
  134. package/src/server/conversation/processIdentity.js +582 -0
  135. package/src/server/conversation/provider.js +21 -6
  136. package/src/server/conversation/providers/api.js +268 -107
  137. package/src/server/conversation/providers/claude.js +29 -6
  138. package/src/server/conversation/providers/claudeDriver.js +325 -250
  139. package/src/server/conversation/providers/codex.js +69 -33
  140. package/src/server/conversation/providers/codexDriver.js +503 -1081
  141. package/src/server/conversation/providers/codexDriverConnection.js +331 -0
  142. package/src/server/conversation/providers/codexDriverTurns.js +464 -0
  143. package/src/server/conversation/providers/opencode.js +30 -4
  144. package/src/server/conversation/providers/opencodeDriver.js +321 -659
  145. package/src/server/conversation/providers/opencodeStandalone.js +475 -0
  146. package/src/server/conversation/providers/scoped.js +64 -19
  147. package/src/server/conversation/runtime.js +171 -2863
  148. package/src/server/conversation/storage.js +91 -50
  149. package/src/server/conversation/streams.js +15 -2
  150. package/src/server/conversation/structuredOutput.js +7 -11
  151. package/src/server/conversation/systemPrompt.js +13 -1
  152. package/src/server/conversation/tools.js +47 -15
  153. package/src/server/conversation/transcript.js +49 -20
  154. package/src/server/hosts/claudeTurn.js +2 -4
  155. package/src/server/hosts/codexConfiguration.js +6 -8
  156. package/src/server/hosts/codexEvents.js +4 -2
  157. package/src/server/hosts/codexProcess.js +1 -1
  158. package/src/server/hosts/codexProvider.js +11 -9
  159. package/src/server/hosts/codexTurn.js +3 -3
  160. package/src/server/hosts/openCodeClient.js +4 -2
  161. package/src/server/index.js +0 -2
  162. package/src/server/lib/aiConnectionClient.js +35 -25
  163. package/src/server/lib/ndjson.js +1 -0
  164. package/src/server/lib/providers/anthropicClient.js +15 -6
  165. package/src/server/lib/providers/common.js +10 -6
  166. package/src/server/lib/providers/openAiCompatibleClient.js +40 -23
  167. package/src/server/lib/serviceToolCatalog.js +29 -283
  168. package/src/server/lib/serviceToolEntries.js +270 -0
  169. package/src/server/types.js +78 -0
  170. package/src/shared/assistantResource.js +0 -23
  171. package/src/shared/conversation/answerChoiceSugar.js +2 -14
  172. package/src/shared/conversation/messageInputSugar.js +18 -0
  173. package/src/shared/conversation/numberedQuestionSugar.js +6 -16
  174. package/src/shared/conversation/pagination.js +1 -1
  175. package/src/shared/conversation/questions.js +1 -0
  176. package/src/shared/conversation/streaming.js +1 -0
  177. package/src/shared/index.js +0 -1
  178. package/src/testing/conversationStorageContract.js +20 -0
  179. package/test/aiConnectionClient.test.js +42 -1
  180. package/test/apiConversationDriver.test.js +217 -38
  181. package/test/assistantBoundedToolLoop.test.js +1 -1
  182. package/test/claudeConversationSession.test.js +15 -11
  183. package/test/claudeInstructionResume.test.js +1 -1
  184. package/test/claudeProcess.test.js +9 -9
  185. package/test/claudeTurn.test.js +21 -8
  186. package/test/codexAccountChange.test.js +5 -3
  187. package/test/codexConversationSession.test.js +22 -21
  188. package/test/codexEvents.test.js +3 -1
  189. package/test/codexHelperDeletion.test.js +3 -7
  190. package/test/codexIsolation.test.js +6 -6
  191. package/test/codexModelCatalog.test.js +6 -7
  192. package/test/codexProcess.test.js +3 -3
  193. package/test/codexThreadSelection.test.js +4 -1
  194. package/test/codexTurn.test.js +29 -20
  195. package/test/conversationApiFacilities.test.js +11 -9
  196. package/test/conversationAttachments.test.js +3 -3
  197. package/test/conversationConfiguration.test.js +4 -4
  198. package/test/conversationNativeStorage.test.js +11 -10
  199. package/test/conversationReplacement.test.js +13 -13
  200. package/test/conversationRuntime.test.js +9 -5
  201. package/test/conversationSession.test.js +78 -22
  202. package/test/conversationTools.test.js +47 -26
  203. package/test/fileConversationStorage.test.js +10 -3
  204. package/test/fixtures/codexHelperThreadLedger.js +3 -3
  205. package/test/frameworkActions.test.js +33 -18
  206. package/test/localConversationExecution.test.js +4 -4
  207. package/test/localExecution.test.js +1 -1
  208. package/test/openCodeConnectionChange.test.js +3 -2
  209. package/test/openCodeRuntime.test.js +8 -7
  210. package/test/openCodeTools.test.js +1 -1
  211. package/test/opencodeConversationSession.test.js +4 -3
  212. package/test/serviceToolCatalog.test.js +3 -8
  213. package/src/server/lib/assistantCompletion.js +0 -190
  214. package/src/server/lib/assistantToolLoop.js +0 -259
@@ -1,50 +1,22 @@
1
+ import { projectOpenCodeConversationMessages } from "./openCodeTurn.js";
2
+ import { ensureOpenCodeSession } from "./openCodeNativeSession.js";
3
+ import { createOpenCodeProcessOwner } from "./openCodeProcessOwner.js";
4
+ import { createOpenCodeTemporaryConversations } from "./openCodeTemporaryConversations.js";
1
5
  import {
2
- OPENCODE_INTERRUPT_TIMEOUT_MS, waitForOpenCodeFinalResponse, openCodeLastAssistantResult, inspectOpenCodeMessageAdmission, observeOpenCodeEvents,
3
- openCodeMessageRows, openCodeRowsForInput, readOpenCodeMessagesUntil, openCodePersistentConversationMessages, openCodeStructuredOutput, projectOpenCodeConversationMessages, waitForOpenCodeMessages, steerOpenCodeTurn, dispatchOpenCodeTurn, observeOpenCodeTurnCompletion, runOpenCodeConversationTurn
4
- } from "./openCodeTurn.js";
5
- import { openCodeAssistantMessageText as assistantMessageText } from "./openCodeClient.js";
6
- import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
6
+ createOpenCodeTurnMonitor, openCodeProviderApiFailure, openCodeTurnSnapshot, writeConversationProjection
7
+ } from "./openCodeTurnMonitor.js";
8
+ import { createOpenCodeSessionTurns } from "./openCodeSessionTurns.js";
7
9
  import { normalizeNullableText } from "./normalize.js";
8
10
  import { randomUUID } from "node:crypto";
9
11
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
10
12
  import path from "node:path";
11
13
 
12
- const text = normalizeNullableText;
14
+ // One runtime per scope directory: the native server it owns is shared by the
15
+ // whole process.
13
16
  const sharedRuntimes = new Map();
14
17
 
15
- function openCodeTurnSnapshot(turn = null, threadId = "") {
16
- const source = isRecord(turn) ? turn : {};
17
- const active = source.active === true;
18
- return active || text(source.id)
19
- ? {
20
- active,
21
- error: text(source.error),
22
- id: text(source.id),
23
- phase: active && !source.observationError ? text(source.phase) : "",
24
- startedAt: text(source.startedAt),
25
- state: text(source.state) || (active ? "active" : "completed"),
26
- status: source.observationError ? "observation_lost" : text(source.state) || (active ? "active" : "completed"),
27
- threadId: text(source.threadId || threadId),
28
- updatedAt: text(source.updatedAt)
29
- }
30
- : null;
31
- }
32
-
33
- function openCodeCredentialFailure(value = "") {
34
- const failure = text(value);
35
- return (
36
- /\b(?:401|403)\b/u.test(failure) ||
37
- (
38
- /(?:api[-_\s]?key|authentication|authorization|credential|access[-_\s]?token|unauthori[sz]ed|forbidden)/iu.test(failure) &&
39
- /(?:denied|expired|failed|forbidden|incorrect|invalid|missing|rejected|revoked|unauthori[sz]ed)/iu.test(failure)
40
- )
41
- );
42
- }
43
-
44
- function openCodeProviderApiFailure(error = {}) {
45
- return text(error?.name) === "APIError";
46
- }
47
-
18
+ /** A stop of the shared server that no one observes. */
19
+ function ignoreRuntimeStop() {}
48
20
 
49
21
  export function assertOpenCodeModelProvider(selected, input = {}) {
50
22
  if (selected?.modelProviderId && input.model?.providerID !== selected.modelProviderId) {
@@ -74,17 +46,17 @@ function pathContains(root = "", candidate = "") {
74
46
  }
75
47
 
76
48
  export function openCodeEnvironmentForDirectory(environments, cwd = "") {
77
- const normalizedCwd = path.resolve(text(cwd) || process.cwd());
49
+ const normalizedCwd = path.resolve(normalizeNullableText(cwd) || process.cwd());
78
50
  return environments
79
- .filter(entry => text(entry?.workdir) && pathContains(path.resolve(entry.workdir), normalizedCwd))
51
+ .filter(entry => normalizeNullableText(entry?.workdir) && pathContains(path.resolve(entry.workdir), normalizedCwd))
80
52
  .sort((left, right) => path.resolve(right.workdir).length - path.resolve(left.workdir).length)[0] || null;
81
53
  }
82
54
 
83
55
  export async function openCodeEnvironmentForSession(environments, sessionId = "", client = null) {
84
56
  const visited = new Set();
85
- let id = text(sessionId);
57
+ let id = normalizeNullableText(sessionId);
86
58
  while (id && !visited.has(id) && visited.size < 32) {
87
- const selected = environments.find(entry => text(entry?.upstreamSessionId) === id);
59
+ const selected = environments.find(entry => normalizeNullableText(entry?.upstreamSessionId) === id);
88
60
  if (selected) return selected;
89
61
  if (!client) return null;
90
62
  visited.add(id);
@@ -92,84 +64,21 @@ export async function openCodeEnvironmentForSession(environments, sessionId = ""
92
64
  if (result.error) throw Object.assign(new Error("The subagent's parent session could not be verified."), {
93
65
  code: "assistant_opencode_parent_unverified"
94
66
  });
95
- id = text(result.data?.parentID);
67
+ id = normalizeNullableText(result.data?.parentID);
96
68
  }
97
69
  return null;
98
70
  }
99
71
 
100
- export function openCodeServerForDirectory(server = {}, workdir = "") {
101
- return Object.freeze({
102
- ...server,
103
- client: typeof server.client?.forDirectory === "function"
104
- ? server.client.forDirectory(workdir)
105
- : server.client
106
- });
107
- }
108
-
109
- export function openCodeModel(selection = {}, executionProfile = null) {
110
- const modelId = text(executionProfile?.model) || text(selection.modelId);
111
- const variantId = executionProfile
112
- ? text(executionProfile.thinking)
113
- : text(selection.variantId);
114
- return {
115
- id: modelId,
116
- providerID: text(selection.modelProviderId),
117
- ...(variantId ? { variant: variantId } : {})
118
- };
119
- }
120
-
121
- export function sameOpenCodeSelection(left = {}, right = {}) {
122
- return ["agentId", "modelId", "modelProviderId", "variantId"]
123
- .every((name) => text(left?.[name]) === text(right?.[name]));
124
- }
125
-
126
- /** Native selection and identity publication share the same pending operation. */
127
- export async function ensureOpenCodeSession(target, { selection, model, workdir = target.workdir, identity = {}, signal,
128
- invalidIdentity = () => new Error("OpenCode did not return a native conversation ID.") }) {
129
- if (target.upstreamStart) {
130
- return target.upstreamStart;
131
- }
132
- const start = Promise.resolve().then(async () => {
133
- await target.server.client.prepareDirectory({ signal });
134
- let upstream = (
135
- target.upstream &&
136
- sameOpenCodeSelection(target.upstreamSelection, selection)
137
- ) ? target.upstream : null;
138
- if (!upstream && target.upstreamSessionId) {
139
- upstream = await target.server.client.readSession(target.upstreamSessionId, { signal });
140
- await target.server.client.switchModel(target.upstreamSessionId, model, { signal });
141
- await target.server.client.switchAgent(target.upstreamSessionId, selection.agentId, { signal });
142
- } else if (!upstream) {
143
- upstream = await target.server.client.createSession({
144
- agent: selection.agentId,
145
- location: { directory: workdir },
146
- model
147
- }, { signal });
148
- }
149
- const nativeId = text(upstream?.id);
150
- if (!nativeId) {
151
- throw invalidIdentity();
152
- }
153
- await identity.write?.(nativeId);
154
- target.upstreamSessionId = nativeId;
155
- target.upstream = upstream;
156
- target.upstreamSelection = { ...selection };
157
- await identity.publish?.(nativeId);
158
- return target;
159
- });
160
- target.upstreamStart = start;
161
- try {
162
- return await start;
163
- } finally {
164
- if (target.upstreamStart === start) {
165
- delete target.upstreamStart;
166
- }
167
- }
168
- }
169
-
170
- // The production provider's users and pending acquisitions share this owner.
171
- // Hosts supply authorized startup inputs and their own publication/cleanup work.
172
- export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageIdleMs = 60_000 } = {}) {
72
+ /**
73
+ * The production provider's users and pending acquisitions share this owner.
74
+ * Hosts supply authorized startup inputs and their own publication/cleanup work.
75
+ * @param {{
76
+ * onStop?: (stop: { reason: string, sessions: number, startingSessions: number }) => void,
77
+ * scope?: string,
78
+ * storageIdleMs?: number
79
+ * }} options
80
+ */
81
+ export function createOpenCodeSharedRuntime({ onStop = ignoreRuntimeStop, scope, storageIdleMs = 60_000 }) {
173
82
  if (scope !== undefined && !path.isAbsolute(scope)) throw new TypeError("OpenCode runtime scopes require an absolute directory.");
174
83
  if (scope && sharedRuntimes.has(scope)) return sharedRuntimes.get(scope);
175
84
  const processes = new Map();
@@ -177,18 +86,21 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
177
86
  const monitors = new Map();
178
87
  const turns = new Map();
179
88
  const temporaryConversations = new Map();
180
- const processExitProofs = new Map();
181
- let sharedProcess = null;
182
- let sharedProcessStart = null;
183
- let sharedProcessStop = null;
184
- let failedStartup = null;
185
- let closed = false;
186
- let connectionUpdate = Promise.resolve();
187
- let storageReads = 0;
188
- let storageIdle = null;
189
89
  const bindingScopes = new Map();
190
90
  let registryPath = "";
191
91
  let bindingWrite = Promise.resolve();
92
+ const processOwner = createOpenCodeProcessOwner({
93
+ processes,
94
+ processStarts,
95
+ monitors,
96
+ turns,
97
+ temporaryConversations,
98
+ onStop,
99
+ storageIdleMs
100
+ });
101
+ const conversations = createOpenCodeTemporaryConversations({ processes, temporaryConversations, processOwner });
102
+ const turnMonitor = createOpenCodeTurnMonitor({ processes, monitors, turns, processOwner });
103
+ const sessionTurns = createOpenCodeSessionTurns({ processes, monitors, turns, processOwner, turnMonitor });
192
104
 
193
105
  function writeBindings(filename, scope, entries) {
194
106
  if (!path.isAbsolute(filename || "") || registryPath && registryPath !== filename) {
@@ -209,1674 +121,6 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
209
121
  return writing;
210
122
  }
211
123
 
212
- async function stop(reason = "opencode-last-session-closed") {
213
- if (sharedProcessStop) return sharedProcessStop;
214
- const target = sharedProcess;
215
- const startup = failedStartup;
216
- if (!target && !startup) return { exited: true, reason };
217
- onStop({ reason, sessions: processes.size, startingSessions: processStarts.size });
218
- const stopping = (async () => {
219
- const proof = startup ? await startup.retryCleanup() : await target.server.stop();
220
- if (proof?.exited !== true) {
221
- throw Object.assign(new Error("OpenCode process exit could not be verified."), {
222
- code: "assistant_opencode_stop_unverified", details: {}, statusCode: 503
223
- });
224
- }
225
- if (sharedProcess === target) sharedProcess = null;
226
- if (failedStartup === startup) failedStartup = null;
227
- return proof;
228
- })();
229
- sharedProcessStop = stopping;
230
- try {
231
- return await stopping;
232
- } finally {
233
- if (sharedProcessStop === stopping) sharedProcessStop = null;
234
- }
235
- }
236
-
237
- // A credential change reaches a native directory when OpenCode reloads that
238
- // directory's instance. An idle directory reloads at once; one with a turn in
239
- // flight reloads when its last turn there ends. New work in a directory waits
240
- // for its reload, so it never starts on credentials the server no longer holds.
241
- function directoryState(shared, workdir) {
242
- const directory = path.resolve(workdir);
243
- let state = shared.directories.get(directory);
244
- if (!state) {
245
- state = { directory, generation: shared.generation, turns: 0, reload: null, idle: null };
246
- shared.directories.set(directory, state);
247
- }
248
- return state;
249
- }
250
-
251
- function directoryCurrent(workdir) {
252
- const state = sharedProcess && text(workdir) ? sharedProcess.directories.get(path.resolve(workdir)) : null;
253
- return !state || (!state.reload && state.generation === sharedProcess.generation);
254
- }
255
-
256
- function reloadDirectory(shared, state) {
257
- if (state.reload || state.generation === shared.generation) return state.reload;
258
- state.reload = (async () => {
259
- try {
260
- while (state.generation < shared.generation) {
261
- const generation = shared.generation;
262
- await shared.server.client.forDirectory(state.directory).disposeInstance();
263
- state.generation = generation;
264
- }
265
- } finally {
266
- state.reload = null;
267
- }
268
- })();
269
- return state.reload;
270
- }
271
-
272
- async function waitForDirectory(workdir) {
273
- const shared = sharedProcess;
274
- const state = directoryState(shared, workdir);
275
- try {
276
- if (state.reload || !state.turns) await reloadDirectory(shared, state);
277
- else await (state.idle ||= Promise.withResolvers()).promise;
278
- } catch (error) {
279
- if (sharedProcess !== shared) return;
280
- throw Object.assign(new Error("OpenCode could not load the changed AI connection for this work. Send your message again."), {
281
- cause: error, code: "assistant_opencode_connection_reload_failed", details: {}, statusCode: 503
282
- });
283
- }
284
- }
285
-
286
- async function currentDirectory(workdir) {
287
- while (!directoryCurrent(workdir)) await waitForDirectory(workdir);
288
- }
289
-
290
- // Holds a directory's credentials for a turn in flight there.
291
- function holdDirectory(workdir) {
292
- const shared = sharedProcess;
293
- if (!shared || !text(workdir)) return () => {};
294
- const state = directoryState(shared, workdir);
295
- state.turns += 1;
296
- let held = true;
297
- return () => {
298
- if (!held) return;
299
- held = false;
300
- state.turns -= 1;
301
- if (state.turns) return;
302
- state.idle?.resolve();
303
- state.idle = null;
304
- if (sharedProcess === shared) reloadDirectory(shared, state)?.catch(() => {});
305
- };
306
- }
307
-
308
- async function admitDirectory(workdir) {
309
- while (!directoryCurrent(workdir)) await waitForDirectory(workdir);
310
- return holdDirectory(workdir);
311
- }
312
-
313
- function reloadChangedCredentials(shared) {
314
- for (const directory of shared.server.client.instanceDirectories?.() || []) directoryState(shared, directory);
315
- shared.generation += 1;
316
- for (const state of shared.directories.values()) {
317
- if (!state.turns) reloadDirectory(shared, state)?.catch(() => {});
318
- }
319
- }
320
-
321
- function connectionRecord(connection) {
322
- return { canonicalUrl: connection.canonicalUrl, endpointCode: connection.endpointCode, fingerprint: connection.fingerprint };
323
- }
324
-
325
- function sameConnection(recorded, connection) {
326
- return recorded?.canonicalUrl === connection.canonicalUrl && recorded.fingerprint === connection.fingerprint &&
327
- recorded.endpointCode === connection.endpointCode;
328
- }
329
-
330
- // A key changes in place; a provider route belongs to the server's configuration.
331
- function inPlaceConnection(recorded, connection) {
332
- return Boolean(connection?.apiKey) && (recorded
333
- ? recorded.canonicalUrl === connection.canonicalUrl && recorded.endpointCode === connection.endpointCode
334
- : !text(connection.canonicalUrl));
335
- }
336
-
337
- // The running server takes the host's saved connections without stopping its
338
- // turns. Reading them inside this queue applies the newest saved state last.
339
- function updateConnections(readConnections) {
340
- const update = connectionUpdate.then(async () => {
341
- const shared = sharedProcess;
342
- if (!shared) return;
343
- shared.connectionsCurrent = false;
344
- let changed = false;
345
- try {
346
- const saved = new Map((await readConnections()).map(connection => [connection.modelProviderId, connection]));
347
- for (const [providerId, connection] of saved) {
348
- const recorded = shared.connections.get(providerId);
349
- if (sameConnection(recorded, connection) || !inPlaceConnection(recorded, connection)) continue;
350
- await shared.server.client.authenticateApiKey(providerId, connection.apiKey);
351
- shared.connections.set(providerId, connectionRecord(connection));
352
- changed = true;
353
- }
354
- for (const providerId of [...shared.connections.keys()]) {
355
- if (saved.has(providerId)) continue;
356
- await shared.server.client.removeAuthentication(providerId);
357
- shared.connections.delete(providerId);
358
- changed = true;
359
- }
360
- shared.connectionsCurrent = true;
361
- } finally {
362
- if (changed) reloadChangedCredentials(shared);
363
- }
364
- });
365
- connectionUpdate = update.catch(() => {});
366
- return update;
367
- }
368
-
369
- // `readConnections` returns the host's saved connections. Without it, a changed
370
- // account needs a new server.
371
- async function ensure(selected, start, readConnections = null) {
372
- await sharedProcessStop;
373
- if (failedStartup) await stop("opencode-startup-cleanup");
374
- if (sharedProcess) {
375
- const recorded = sharedProcess.connections.get(selected?.modelProviderId);
376
- const current = !selected || sameConnection(recorded, selected);
377
- if (current || (readConnections && inPlaceConnection(recorded, selected))) {
378
- const probe = AbortSignal.timeout(1_000);
379
- let healthy = false;
380
- try {
381
- await sharedProcess.server.client.health({ signal: probe });
382
- healthy = true;
383
- } catch (error) {
384
- // A slow probe is not proof that the shared server stopped working.
385
- if (probe.aborted) throw error;
386
- await stop("opencode-health-check-failed");
387
- }
388
- if (healthy && sharedProcess) {
389
- if (readConnections && (!current || !sharedProcess.connectionsCurrent)) await updateConnections(readConnections);
390
- return sharedProcess;
391
- }
392
- } else {
393
- await stop("opencode-connection-changed");
394
- }
395
- }
396
- if (sharedProcessStart) return sharedProcessStart;
397
- sharedProcessStart = Promise.resolve().then(async () => {
398
- const { connections, server } = await start();
399
- const started = {
400
- connections: new Map(connections.map(connection => [connection.modelProviderId, connectionRecord(connection)])),
401
- connectionsCurrent: true, directories: new Map(), generation: 0, server
402
- };
403
- sharedProcess = started;
404
- // An unexpected exit retires the server's leftovers at once.
405
- void server.unexpectedExit?.then(() => sharedProcess === started ? stop("opencode-server-exited") : null)
406
- .catch(() => {});
407
- for (const target of processes.values()) {
408
- target.server = openCodeServerForDirectory(server, target.workdir);
409
- target.upstream = null;
410
- target.upstreamSelection = null;
411
- }
412
- return sharedProcess;
413
- }).catch(error => {
414
- if (error.cleanupFailed && typeof error.retryCleanup === "function") failedStartup = error;
415
- throw error;
416
- });
417
- try {
418
- return await sharedProcessStart;
419
- } finally {
420
- sharedProcessStart = null;
421
- }
422
- }
423
-
424
- // Native storage inventory and retirement borrow the shared server; they are
425
- // not users that keep it running. A short idle after the last read lets one
426
- // maintenance pass share it, then it stops unless a conversation uses it.
427
- async function withStorageProcess(start, operation, failure = error => { throw error; }) {
428
- clearTimeout(storageIdle);
429
- storageIdle = null;
430
- storageReads += 1;
431
- try {
432
- return await operation(await ensure(null, start).catch(failure));
433
- } finally {
434
- storageReads -= 1;
435
- if (storageReads === 0 && !closed) {
436
- storageIdle = setTimeout(() => {
437
- storageIdle = null;
438
- if (storageReads === 0 && processes.size === 0 && processStarts.size === 0) {
439
- stop("opencode-storage-read-finished").catch(() => null);
440
- }
441
- }, storageIdleMs);
442
- storageIdle.unref?.();
443
- }
444
- }
445
- }
446
-
447
- function targetFor(key, prepared) {
448
- const current = processes.get(key);
449
- const nativeId = prepared.upstreamSessionId;
450
- if (current && current.upstreamSessionId !== nativeId) {
451
- current.upstreamSessionId = nativeId;
452
- current.upstream = null;
453
- }
454
- if (
455
- current && !current.abortController.signal.aborted &&
456
- current.canonicalUrl === prepared.connection.canonicalUrl &&
457
- current.connectionFingerprint === prepared.connection.fingerprint &&
458
- current.endpointCode === prepared.connection.endpointCode &&
459
- current.modelProviderId === prepared.connection.modelProviderId &&
460
- current.projectContextRoot === prepared.projectContextRoot &&
461
- current.workdir === prepared.workdir &&
462
- text(current.selection?.catalogRevision) === text(prepared.selection.catalogRevision) &&
463
- sameOpenCodeSelection(current.selection, prepared.selection)
464
- ) {
465
- current.helperModelId = prepared.helperModelId;
466
- current.server = openCodeServerForDirectory(prepared.server, prepared.workdir);
467
- return current;
468
- }
469
- const server = openCodeServerForDirectory(prepared.server, prepared.workdir);
470
- const created = (current && !current.abortController.signal.aborted ? current : null) || {
471
- abortController: new AbortController(),
472
- key,
473
- sessionId: prepared.sessionId,
474
- upstreamSessionId: nativeId
475
- };
476
- Object.assign(created, {
477
- helperModelId: prepared.helperModelId,
478
- canonicalUrl: prepared.connection.canonicalUrl,
479
- connectionFingerprint: prepared.connection.fingerprint,
480
- endpointCode: prepared.connection.endpointCode,
481
- modelProviderId: prepared.connection.modelProviderId,
482
- projectContextRoot: prepared.projectContextRoot,
483
- selection: prepared.selection,
484
- server,
485
- workdir: prepared.workdir
486
- });
487
- return created;
488
- }
489
-
490
- async function acquire(key, prepare, onFailure = async () => {}) {
491
- const pending = processStarts.get(key);
492
- if (pending) return pending;
493
- const start = Promise.resolve().then(async () => {
494
- const target = await prepare();
495
- processes.set(key, target);
496
- return target;
497
- }).catch(async error => {
498
- if (processStarts.get(key) === start) processStarts.delete(key);
499
- if (!processes.has(key)) {
500
- await onFailure();
501
- if (processes.size === 0 && processStarts.size === 0 && storageReads === 0 && !failedStartup) {
502
- await stop("opencode-session-start-failed").catch(() => null);
503
- }
504
- }
505
- throw error;
506
- });
507
- processStarts.set(key, start);
508
- try {
509
- return await start;
510
- } finally {
511
- if (processStarts.get(key) === start) processStarts.delete(key);
512
- }
513
- }
514
-
515
- async function release(target, { retainSharedProcess = false, onRemoved } = {}) {
516
- if (processes.get(target.key) === target) {
517
- processes.delete(target.key);
518
- await onRemoved?.();
519
- }
520
- return !retainSharedProcess && processes.size === 0 && processStarts.size === 0 && storageReads === 0
521
- ? stop()
522
- : { exited: true, sharedProcessRetained: true };
523
- }
524
-
525
- async function releaseTarget(target, { retainSharedProcess = false, onRemoved } = {}) {
526
- const activeThreads = new Set();
527
- if (turns.get(target.key)?.active) activeThreads.add(target.upstreamSessionId);
528
- for (const entry of temporaryConversations.values()) {
529
- if (entry.active && entry.target?.abortController === target.abortController) {
530
- activeThreads.add(entry.conversationId);
531
- }
532
- }
533
- for (const threadId of activeThreads) await stopUnobservedSession(target, threadId);
534
- target.abortController.abort();
535
- turns.get(target.key)?.admission?.resolve();
536
- await monitors.get(target.key)?.catch(() => null);
537
- await Promise.all([...temporaryConversations.values()]
538
- .filter((entry) => entry.target?.abortController === target.abortController)
539
- .map((entry) => entry.completion?.catch(() => null)));
540
- return release(target, { retainSharedProcess, onRemoved });
541
- }
542
-
543
- async function releaseProcessTarget(target = null, { retainSharedProcess = false } = {}, application) {
544
- if (!target) {
545
- return { exited: true };
546
- }
547
- await application.beforeRelease(target);
548
- const proof = await releaseTarget(target, {
549
- retainSharedProcess,
550
- onRemoved: () => application.onRemoved(target)
551
- }).catch(application.failure);
552
- if (target.sessionId) {
553
- processExitProofs.set(target.sessionId, proof);
554
- }
555
- return proof;
556
- }
557
-
558
- async function closeSession(id, options, application) {
559
- const terminalClose = await application.closeTerminals();
560
- const pending = [...processStarts.entries()]
561
- .filter(([key]) => key.endsWith(`\0${id}`))
562
- .map(([, start]) => start.catch(() => null));
563
- await Promise.all(pending);
564
- const targets = [...processes.values()].filter((target) => target.sessionId === id);
565
- const proofs = await Promise.all(targets.map((target) => releaseProcessTarget(target, options, application)));
566
- await application.afterRelease();
567
- for (const key of [...turns.keys()]) {
568
- if (key.endsWith(`\0${id}`)) {
569
- turns.delete(key);
570
- }
571
- }
572
- for (const key of [...temporaryConversations.keys()]) {
573
- if (temporaryConversations.get(key)?.target?.sessionId === id) {
574
- temporaryConversations.delete(key);
575
- }
576
- }
577
- const result = {
578
- closed: targets.length,
579
- ok: proofs.every((proof) => proof?.exited !== false),
580
- processExitProof: proofs.at(-1) || processExitProofs.get(id) || { exited: true },
581
- processExitProofs: proofs
582
- };
583
- return {
584
- ...result,
585
- closed: result.closed + Number(terminalClose?.closed || 0),
586
- ok: terminalClose?.ok !== false && result.ok
587
- };
588
- }
589
-
590
- async function closeProject(input = {}, application) {
591
- await Promise.all([...processStarts.entries()]
592
- .filter(([key]) => key.includes("\0"))
593
- .map(([, start]) => start.catch(() => null)));
594
- const projectContextRoot = text(input.projectContextRoot);
595
- const targets = [...processes.values()].filter((target) => (
596
- application.ownsTarget(target) && (!projectContextRoot || target.projectContextRoot === path.resolve(projectContextRoot))
597
- ));
598
- const results = await Promise.all(targets.map((target) => releaseProcessTarget(target, {}, application)));
599
- if (processes.size === 0 && storageReads === 0 && (sharedProcess || failedStartup)) {
600
- results.push(await stop(text(input.reason) || "opencode-project-close").catch(application.failure));
601
- }
602
- return {
603
- closed: results.length,
604
- ok: results.every((result) => result?.exited !== false)
605
- };
606
- }
607
-
608
- async function invalidateRuntimes(input = {}, preparation) {
609
- if (text(input.reason) === "server-shutdown") {
610
- closed = true;
611
- clearTimeout(storageIdle);
612
- storageIdle = null;
613
- }
614
- const application = preparation.release;
615
- if (typeof input.publish === "function") {
616
- if (typeof preparation.connections !== "function") {
617
- throw new TypeError("OpenCode connection changes require the host's saved connections.");
618
- }
619
- // Running turns, retained conversations and the shared server continue.
620
- // The server takes the saved connections at once, and each directory
621
- // reloads them as soon as it has no turn in flight.
622
- await application.assertConnectionChange?.();
623
- await input.publish();
624
- const update = () => updateConnections(preparation.connections).catch(() => null);
625
- void sharedProcessStart?.then(update, () => null);
626
- await update();
627
- return { closed: 0, ok: true };
628
- }
629
- await Promise.all([...processStarts.entries()]
630
- .filter(([key]) => key.includes("\0"))
631
- .map(([, start]) => start.catch(() => null)));
632
- const targets = [...processes.values()].filter(target => application.ownsTarget(target));
633
- const results = await Promise.all(targets.map((target) => releaseProcessTarget(target, {}, application)));
634
- if (processes.size === 0 && processStarts.size === 0 && (sharedProcess || failedStartup)) {
635
- results.push(await stop(text(input.reason) || "opencode-runtime-invalidation").catch(application.failure));
636
- }
637
- return {
638
- closed: results.length,
639
- ok: results.every((result) => result?.exited !== false)
640
- };
641
- }
642
-
643
- async function closePreparedSession(prepared) {
644
- return closeSession(prepared.sessionId, prepared.options, prepared.application);
645
- }
646
-
647
- async function releaseRenewalPredecessorProcessExitProof(preparation) {
648
- const closed = await closePreparedSession(preparation.cleanup);
649
- const released = releaseProcessExitProof(preparation.sessionId);
650
- return {
651
- ...closed,
652
- ...released,
653
- ok: closed.ok !== false && released.ok !== false
654
- };
655
- }
656
-
657
- function releaseProcessExitProof(id) {
658
- const proof = processExitProofs.get(id) || null;
659
- processExitProofs.delete(id);
660
- return {
661
- ok: Boolean(proof?.exited),
662
- processExitProof: proof,
663
- released: Boolean(proof?.exited)
664
- };
665
- }
666
-
667
- async function startPreparedProcess(prepared) {
668
- const server = await prepared.create(prepared.options);
669
- return { connections: prepared.connections, server };
670
- }
671
-
672
- async function acquirePrepared(prepared) {
673
- return acquire(prepared.key, async () => {
674
- const configured = await prepared.configure();
675
- const shared = await ensure(prepared.connection,
676
- async () => startPreparedProcess(await configured.prepareServer()),
677
- async () => (await configured.prepareServer()).connections).catch(prepared.failure);
678
- configured.target.server = shared.server;
679
- return targetFor(prepared.key, configured.target);
680
- }, prepared.onFailure).catch(prepared.failure);
681
- }
682
-
683
- function scopedTarget(target, prepared) {
684
- const workdir = prepared.workdir;
685
- return workdir ? { ...target, server: openCodeServerForDirectory(target.server, workdir), workdir } : target;
686
- }
687
-
688
- async function runPreparedRenewalTurn(preparation) {
689
- const acquired = await acquirePrepared(await preparation.process());
690
- const releaseDirectory = await admitDirectory(acquired.workdir);
691
- try {
692
- const target = await ensureOpenCodeSession(acquired, preparation.session());
693
- const turn = preparation.turn(target);
694
- const result = await runOpenCodeConversationTurn(target.server.client, target.upstreamSessionId, turn.input, {
695
- ...turn.options,
696
- ...(turn.release ? { close: () => releaseProcessTarget(target, {}, turn.release) } : {})
697
- });
698
- return turn.completeResult(result);
699
- } finally {
700
- releaseDirectory();
701
- }
702
- }
703
-
704
- async function createPreparedConversation(prepared) {
705
- const target = scopedTarget(await acquirePrepared(prepared.process), prepared);
706
- const conversation = await createConversation(prepared.key, target, prepared.selection(), prepared.executionProfile);
707
- return { conversationId: conversation.id, ephemeral: prepared.ephemeral, ok: true, status: "ready" };
708
- }
709
-
710
- async function selectPreparedConversation(prepared) {
711
- const target = scopedTarget(await acquirePrepared(prepared.process), prepared);
712
- const selection = prepared.selection();
713
- const conversationId = selection.conversationId;
714
- const previous = conversationId ? temporaryConversations.get(`${prepared.key}\0${conversationId}`) : null;
715
- if (selection.scoped && previous && JSON.stringify(previous.executionProfile || null) !== JSON.stringify(prepared.executionProfile)) {
716
- throw selection.profileChanged();
717
- }
718
- if (!conversationId && !selection.createIfMissing) throw selection.identityRequired();
719
- if (!previous?.active) await currentDirectory(target.workdir);
720
- const selected = await selectConversation(prepared.key, target, conversationId, selection.input, selection.registration);
721
- return { ...selected, executionProfile: prepared.executionProfile, target };
722
- }
723
-
724
- async function existingPreparedConversation(prepared) {
725
- return { conversationId: prepared.conversationId, ...await existingConversation(
726
- prepared.key, prepared.conversationId, prepared.persistent,
727
- async () => acquirePrepared(await prepared.process())
728
- ) };
729
- }
730
-
731
- async function runPreparedConversationTurn(prepared) {
732
- const selected = await selectPreparedConversation(prepared);
733
- const turn = prepared.turn(selected);
734
- return runConversationTurn(selected.target, selected.conversationId, selected.tracked,
735
- turn.input, turn.options).catch(prepared.failure);
736
- }
737
-
738
- async function readPreparedConversation(prepared, input) {
739
- const { conversationId, target, tracked } = await existingPreparedConversation(prepared);
740
- if (!target) throw prepared.readUnavailable();
741
- if (!input.persistent) return prepared.projectResult(
742
- await readConversation(target, conversationId, tracked, {}, prepared.readError), tracked?.executionProfile);
743
- return readPersistentConversation(target, conversationId, tracked, prepared.messages).catch(prepared.failure);
744
- }
745
-
746
- async function waitPreparedConversationTurn(prepared, input) {
747
- const { conversationId, target, tracked } = await existingPreparedConversation(prepared);
748
- if (!target) throw prepared.readUnavailable();
749
- return waitForConversationTurn(target, conversationId, tracked, input, prepared.readError);
750
- }
751
-
752
- async function stopPreparedConversation(prepared, input) {
753
- const { conversationId, target, tracked } = await existingPreparedConversation(prepared);
754
- return stopConversation(target, conversationId, tracked, { persistent: input.persistent }).catch(prepared.failure);
755
- }
756
-
757
- async function deletePreparedConversation(prepared, input) {
758
- const { conversationId, key, target, tracked } = await existingPreparedConversation(prepared);
759
- const result = await deleteConversation(key, target, conversationId, tracked, {
760
- allowMissing: Boolean(input.persistent || prepared.scoped), onRemoved: prepared.onRemoved
761
- });
762
- if (!result.deleted) return result;
763
- const cleanup = prepared.cleanupAfterDelete;
764
- let providerExit = null;
765
- if (cleanup) {
766
- providerExit = await closeSession(cleanup.sessionId, cleanup.options, cleanup.application);
767
- // The result carries the deleted scope's exit proof. Only renewal releases
768
- // a kept proof, and a scope is never renewed, so none is kept.
769
- processExitProofs.delete(cleanup.sessionId);
770
- }
771
- return { conversationId, deleted: true, ok: providerExit?.ok !== false,
772
- ...(providerExit ? { providerExit } : {}) };
773
- }
774
-
775
- async function sendPreparedMessage(prepared, options) {
776
- if (Object.hasOwn(prepared, "value")) return prepared.value;
777
- return prepared.project(await sendMessage(prepared.key, prepared.input, options, prepared.application));
778
- }
779
-
780
- async function inspectPreparedMessage(prepared) {
781
- const target = await acquirePrepared(prepared.process);
782
- const observation = await inspectOpenCodeMessageAdmission(target.server.client, prepared.threadId, prepared.inputMessageId);
783
- return prepared.project(observation);
784
- }
785
-
786
- async function interruptPreparedTurn(prepared) {
787
- const target = processes.get(prepared.key) || null;
788
- return interruptTurn(prepared.key, target, {
789
- threadId: target ? "" : prepared.threadId, writeRun: prepared.writeRun
790
- }).catch(prepared.failure);
791
- }
792
-
793
- function observePreparedEvents(target, turn, abortController, signal, projection) {
794
- const observation = observeOpenCodeEvents(target.server.client, target.upstreamSessionId, {
795
- abortController, signal,
796
- eventStartedAt: turn ? turn.eventStartedAt : null,
797
- readError: projection.readError,
798
- onEvent: summary => projection.onEvent(summary, target)
799
- });
800
- void observation.completion.then(() => {
801
- projection.failure(observation.readFailure());
802
- projection.closed();
803
- });
804
- return {
805
- close: observation.close,
806
- readFailure: observation.readFailure,
807
- waitUntilReady(options) {
808
- const ready = observation.waitUntilReady(options);
809
- void ready.catch(projection.failure);
810
- return ready;
811
- }
812
- };
813
- }
814
-
815
- async function stopUnobservedSession(target, threadId) {
816
- const signal = AbortSignal.timeout(OPENCODE_INTERRUPT_TIMEOUT_MS);
817
- try {
818
- const confirmed = await target.server.client.interrupt(threadId, { signal });
819
- const status = await target.server.client.sessionStatus(threadId, { signal });
820
- if (confirmed !== true || status?.type !== "idle") {
821
- throw new Error("OpenCode did not confirm that the session stopped.");
822
- }
823
- } catch (error) {
824
- // The process owns the final stop proof when its control channel cannot.
825
- // Stop this exact server, even if another connection has since replaced it.
826
- const server = target.server;
827
- const proof = sharedProcess?.server.stop === server.stop
828
- ? await stop("opencode-observation-lost")
829
- : await server.stop();
830
- if (proof?.exited !== true) {
831
- throw new Error("OpenCode observation was lost and process exit could not be verified.", { cause: error });
832
- }
833
- for (const affected of processes.values()) {
834
- if (affected.server.stop === server.stop) {
835
- affected.abortController.abort(new Error("OpenCode's shared service was stopped after observation failed. Send a message to continue."));
836
- }
837
- }
838
- }
839
- }
840
-
841
- async function createConversation(key, target, input, executionProfile = null) {
842
- await target.server.client.prepareDirectory({ signal: target.abortController?.signal });
843
- const conversation = await target.server.client.createSession({
844
- agent: input.agent,
845
- location: { directory: target.workdir },
846
- model: input.model
847
- });
848
- temporaryConversations.set(`${key}\0${conversation.id}`, {
849
- active: false,
850
- executionProfile,
851
- target
852
- });
853
- return conversation;
854
- }
855
-
856
- async function selectConversation(parentKey, target, conversationId, input, registration) {
857
- await target.server.client.prepareDirectory({ signal: target.abortController?.signal });
858
- if (!conversationId) {
859
- const created = await target.server.client.createSession({
860
- agent: input.agent,
861
- location: { directory: target.workdir },
862
- model: input.model
863
- });
864
- conversationId = created.id;
865
- } else {
866
- await target.server.client.switchModel(conversationId, input.model);
867
- await target.server.client.switchAgent(conversationId, input.agent);
868
- }
869
- const key = `${parentKey}\0${conversationId}`;
870
- const tracked = temporaryConversations.get(key) || { active: false, target };
871
- tracked.target = target;
872
- tracked.executionProfile = registration.executionProfile;
873
- tracked.conversationId = conversationId;
874
- tracked.promptContext = registration.promptContext;
875
- temporaryConversations.set(key, tracked);
876
- await registration.onSelected();
877
- return { conversationId, key, tracked };
878
- }
879
-
880
- async function existingConversation(parentKey, conversationId, persistent, prepareTarget) {
881
- const key = `${parentKey}\0${conversationId}`;
882
- let tracked = temporaryConversations.get(key) || null;
883
- let target = tracked?.target || processes.get(parentKey) || null;
884
- if (persistent && (!target || target.abortController.signal.aborted)) {
885
- temporaryConversations.delete(key);
886
- tracked = null;
887
- target = await prepareTarget();
888
- }
889
- return { key, tracked, target };
890
- }
891
-
892
- async function readConversation(target = {}, conversationId = "", tracked = null, options = {}, readError) {
893
- const messages = await readOpenCodeMessagesUntil(target.server.client, conversationId,
894
- rows => rows.some(row => row?.type === "assistant"), options);
895
- const result = openCodeLastAssistantResult(messages, { readError });
896
- return {
897
- conversationId,
898
- error: text(tracked?.error?.message) || result.error,
899
- ok: !tracked?.error && !result.error,
900
- runId: tracked?.inputMessageId || "",
901
- status: tracked?.active ? "inProgress"
902
- : tracked?.interrupted ? "interrupted"
903
- : tracked?.error || result.error ? "failed" : "completed",
904
- text: result.text
905
- };
906
- }
907
-
908
- async function readPersistentConversation(target, conversationId, tracked, projection) {
909
- const { readError } = projection;
910
- let status = await target.server.client.sessionStatus(conversationId);
911
- if (!tracked && status.type !== "idle") {
912
- await stopUnobservedSession(target, conversationId);
913
- status = { type: "idle" };
914
- }
915
- const rows = openCodeMessageRows(await target.server.client.messages(conversationId));
916
- const result = openCodeLastAssistantResult(rows, { readError });
917
- const messages = openCodePersistentConversationMessages(rows, projection.messageId);
918
- let conversationStatus = "completed";
919
- if (status.type !== "idle" || tracked?.active) conversationStatus = "inProgress";
920
- else if (tracked?.interrupted) conversationStatus = "interrupted";
921
- else if (tracked?.error || result.error) conversationStatus = "failed";
922
- return {
923
- conversationId,
924
- ok: true,
925
- messages,
926
- text: result.text,
927
- admitted: Boolean(projection.inputMessageId && rows.some((row) => row.id === projection.inputMessageId)),
928
- error: text(tracked?.error?.message) || result.error,
929
- runId: tracked?.inputMessageId || [...rows].reverse().find((row) => row.type === "user")?.id || "",
930
- status: conversationStatus
931
- };
932
- }
933
-
934
- async function waitForConversationTurn(target, conversationId, tracked, input = {}, readError) {
935
- if (tracked?.completion && (tracked.executionProfile || !input.timeoutMs)) {
936
- return tracked.completion;
937
- }
938
- await waitForOpenCodeMessages(target.server.client, conversationId, tracked?.inputMessageId || "", {
939
- signal: input.timeoutMs ? AbortSignal.timeout(Number(input.timeoutMs)) : undefined,
940
- readError
941
- });
942
- return readConversation(target, conversationId, null, {}, readError);
943
- }
944
-
945
- async function runConversationTurn(target, conversationId, tracked, input, options) {
946
- if (tracked.active && tracked.error) {
947
- await stopUnobservedSession(tracked.target, conversationId);
948
- tracked.active = false;
949
- }
950
- if (tracked.active && input.steer === true) {
951
- const messageId = input.steeringId;
952
- await steerOpenCodeTurn(target.server.client, conversationId, tracked, {
953
- agent: input.agent,
954
- id: messageId,
955
- model: input.model,
956
- prompt: input.prompt,
957
- attachments: input.attachments
958
- }, { signal: AbortSignal.any([target.abortController.signal, tracked.abortController.signal]) });
959
- return {
960
- conversationId,
961
- ok: true,
962
- runId: tracked.inputMessageId,
963
- status: "inProgress",
964
- deliveryMode: "steer"
965
- };
966
- }
967
- // A new native turn starts only on its directory's current credentials.
968
- while (!tracked.active && !directoryCurrent(target.workdir)) await waitForDirectory(target.workdir);
969
- if (tracked.active) {
970
- throw Object.assign(new Error("This conversation is still working."), {
971
- code: "assistant_opencode_conversation_busy", details: {}, statusCode: 409
972
- });
973
- }
974
- tracked.active = true;
975
- const releaseDirectory = holdDirectory(target.workdir);
976
- tracked.error = null;
977
- tracked.interrupted = false;
978
- const turnAbort = new AbortController();
979
- tracked.abortController = turnAbort;
980
- const inputMessageId = input.id;
981
- const observedTarget = { ...target, upstreamSessionId: conversationId };
982
- const signal = AbortSignal.any([target.abortController.signal, turnAbort.signal, ...(options.signal ? [options.signal] : [])]);
983
- const events = options.observation
984
- ? observePreparedEvents(observedTarget, null, turnAbort, signal, options.observation())
985
- : options.observe(observedTarget, turnAbort, signal);
986
- let admitted;
987
- try {
988
- admitted = await dispatchOpenCodeTurn(tracked, {
989
- get agent() { return input.agent; },
990
- id: inputMessageId,
991
- get model() { return input.model; },
992
- get prompt() { return input.prompt; },
993
- get attachments() { return input.attachments; }
994
- }, {
995
- get client() { return target.server.client; },
996
- get conversationId() { return conversationId; },
997
- events, signal,
998
- readiness: options.readiness,
999
- beforeDispatch: options.beforeDispatch
1000
- });
1001
- } catch (error) {
1002
- try {
1003
- await events.close();
1004
- tracked.error = error;
1005
- if (tracked.promptAttempted) await stopUnobservedSession(observedTarget, conversationId);
1006
- tracked.active = false;
1007
- } finally {
1008
- releaseDirectory();
1009
- }
1010
- throw error;
1011
- }
1012
- tracked.inputMessageId = text(admitted.id);
1013
- tracked.completion = (async () => {
1014
- const completion = options.completion;
1015
- const onMessages = completion.onMessages;
1016
- const result = await observeOpenCodeTurnCompletion(runtime, observedTarget, conversationId, tracked, {
1017
- ...completion, events, signal,
1018
- ...(onMessages ? {
1019
- onMessages: (messages, inputMessageId) => onMessages(
1020
- openCodePersistentConversationMessages(openCodeRowsForInput(messages, inputMessageId), completion.messageId),
1021
- inputMessageId
1022
- )
1023
- } : {}),
1024
- projectResult: async () => {
1025
- const result = completion.projectResult(
1026
- await readConversation(target, conversationId, null, { signal }, completion.readError)
1027
- );
1028
- return { ...result, text: input.outputSchema ? openCodeStructuredOutput(result.text) : result.text };
1029
- }
1030
- });
1031
- if (result.interrupted) {
1032
- return { conversationId, ok: true, runId: tracked.inputMessageId, status: "interrupted", text: "" };
1033
- }
1034
- return result;
1035
- })();
1036
- void tracked.completion.then(releaseDirectory, releaseDirectory);
1037
- // Keep a failed admitted turn observable to the client's conversation reads.
1038
- void tracked.completion.catch((error) => {
1039
- if (tracked.abortController === turnAbort) tracked.error = error;
1040
- });
1041
- options.onCompletion(tracked.completion);
1042
- return options.waitForCompletion ? tracked.completion : {
1043
- conversationId,
1044
- ok: true,
1045
- runId: tracked.inputMessageId,
1046
- status: "inProgress",
1047
- threadId: conversationId,
1048
- turnId: tracked.inputMessageId
1049
- };
1050
- }
1051
-
1052
- async function stopConversation(target, conversationId, tracked, { persistent = false } = {}) {
1053
- if (!target) {
1054
- return { conversationId, ok: true, stopped: false };
1055
- }
1056
- try {
1057
- if (tracked?.active && tracked.error) {
1058
- await stopUnobservedSession(target, conversationId);
1059
- } else {
1060
- const confirmed = await target.server.client.interrupt(conversationId, {
1061
- signal: AbortSignal.timeout(OPENCODE_INTERRUPT_TIMEOUT_MS)
1062
- });
1063
- const status = persistent ? await target.server.client.sessionStatus(conversationId, {
1064
- signal: AbortSignal.timeout(OPENCODE_INTERRUPT_TIMEOUT_MS)
1065
- }) : null;
1066
- if (confirmed !== true || status && status.type !== "idle") {
1067
- throw Object.assign(new Error("OpenCode did not confirm Stop. Try Stop again."), {
1068
- code: "assistant_opencode_interrupt_unconfirmed", details: {}, statusCode: 502
1069
- });
1070
- }
1071
- }
1072
- } catch (error) {
1073
- if (persistent && error.statusCode === 404) return { conversationId, ok: true, stopped: true };
1074
- if (error?.name === "TimeoutError") {
1075
- throw Object.assign(new Error("OpenCode did not confirm Stop within 5 seconds. Try Stop again."), {
1076
- code: "assistant_opencode_interrupt_timeout", details: {}, statusCode: 504
1077
- });
1078
- }
1079
- throw error;
1080
- }
1081
- if (tracked) {
1082
- tracked.interrupted = true;
1083
- tracked.abortController?.abort();
1084
- await tracked.completion?.catch(() => null);
1085
- tracked.active = false;
1086
- }
1087
- return { conversationId, ok: true, stopped: true };
1088
- }
1089
-
1090
- async function deleteConversation(key, target, conversationId, tracked, { allowMissing, onRemoved }) {
1091
- if (!target) {
1092
- temporaryConversations.delete(key);
1093
- await onRemoved();
1094
- return { conversationId, deleted: false, ok: true };
1095
- }
1096
- try {
1097
- await target.server.client.deleteSession(conversationId);
1098
- } catch (error) {
1099
- if (!allowMissing || error.statusCode !== 404) throw error;
1100
- }
1101
- if (tracked) {
1102
- tracked.interrupted = true;
1103
- tracked.abortController?.abort();
1104
- await tracked.completion?.catch(() => null);
1105
- }
1106
- temporaryConversations.delete(key);
1107
- await onRemoved();
1108
- return { conversationId, deleted: true, ok: true };
1109
- }
1110
-
1111
- async function writeConversationProjection(sessionId, messages = null, {
1112
- inputMessageId = "",
1113
- streaming = false,
1114
- captureFinalAssistantResult = false
1115
- } = {}, projection) {
1116
- let failure = "";
1117
- let finalAssistantResult = null;
1118
- let providerApiFailure = false;
1119
- const rows = (inputMessageId
1120
- ? openCodeRowsForInput(messages, inputMessageId)
1121
- : openCodeMessageRows(messages)).filter((message) => message?.type === "assistant");
1122
- const finalNativeMessage = captureFinalAssistantResult && !streaming
1123
- ? openCodeLastAssistantResult(rows, { readError: projection.readError }).message : null;
1124
- for (const [index, message] of rows.entries()) {
1125
- failure ||= projection.readError(message);
1126
- providerApiFailure ||= openCodeProviderApiFailure(message.error);
1127
- if (message.summary === true) continue;
1128
- const assistantText = assistantMessageText(message);
1129
- const messageId = assistantText ? projection.messageId(message.id, "assistant") : "";
1130
- const outputId = assistantText ? projection.outputId?.(message.id, "assistant") : "";
1131
- const inFlight = streaming && index === rows.length - 1;
1132
- const reasoning = (message.content || []).filter((part) => part.type === "reasoning" && text(part.text))
1133
- .map((part) => ({
1134
- messageId: message.id,
1135
- partId: part.id,
1136
- text: part.text,
1137
- createdAt: message.time?.created,
1138
- complete: Boolean(part.time?.end)
1139
- }));
1140
- await projection.reasoning(reasoning, {
1141
- complete: !inFlight || Boolean(message.time?.completed || message.finish),
1142
- flush: !streaming || Boolean(assistantText && !inFlight)
1143
- });
1144
- if (streaming && assistantText && !inFlight) {
1145
- // A completed round persists as soon as the next one starts, so
1146
- // superseded narration never evaporates from the transcript.
1147
- const turn = await projection.store.writeConversationAssistantMessage(sessionId, {
1148
- messageId,
1149
- ...(outputId ? { outputId } : {}),
1150
- text: assistantText
1151
- });
1152
- projection.store.completeConversationStreamMessage(sessionId, messageId);
1153
- await projection.publishTurn(turn);
1154
- continue;
1155
- }
1156
- if (streaming && assistantText && inFlight) {
1157
- const conversationStream = projection.store.updateConversationStream(sessionId, {
1158
- turnId: inputMessageId,
1159
- messageId,
1160
- ...(outputId ? { outputId } : {}),
1161
- text: assistantText
1162
- });
1163
- if (conversationStream) {
1164
- await projection.publishStream(conversationStream);
1165
- }
1166
- continue;
1167
- }
1168
- if (assistantText && !streaming) {
1169
- const turn = await projection.store.writeConversationAssistantMessage(sessionId, {
1170
- messageId,
1171
- ...(outputId ? { outputId } : {}),
1172
- text: assistantText
1173
- });
1174
- projection.store.completeConversationStreamMessage(sessionId, messageId);
1175
- await projection.publishTurn(turn);
1176
- if (message === finalNativeMessage && !projection.readError(message) &&
1177
- (message.time?.completed || message.finish) && turn) {
1178
- finalAssistantResult = { inputMessageId: text(inputMessageId), itemId: text(message.id),
1179
- text: assistantText, conversationTurn: turn };
1180
- }
1181
- }
1182
- }
1183
- return { failure, providerApiFailure, ...(captureFinalAssistantResult ? { finalAssistantResult } : {}) };
1184
- }
1185
-
1186
- // Evidence belongs to the exact retained native turn. This read never creates,
1187
- // observes, resumes or projects a native conversation.
1188
- function readFinalAssistantResult(key, threadId = "", turnId = "") {
1189
- const turn = turns.get(key);
1190
- const result = turn?.finalAssistantResult;
1191
- const target = turn?.finalAssistantResultTarget;
1192
- if (!target || processes.get(key) !== target || target.abortController.signal.aborted ||
1193
- !turn.finalAssistantResultProcess || sharedProcess !== turn.finalAssistantResultProcess ||
1194
- target.upstreamSessionId !== turn.threadId) return null;
1195
- if (!text(threadId) || !text(turnId) || !result || turn.active || turn.state !== "completed" ||
1196
- turn.error || turn.observationError || turn.interruptRequested || turn.interruptAcknowledged ||
1197
- turn.threadId !== text(threadId) || turn.id !== text(turnId) ||
1198
- result.threadId !== text(threadId) || result.turnId !== text(turnId) ||
1199
- result.inputMessageId !== text(turn.inputMessageId) || !result.conversationTurn) return null;
1200
- return structuredClone(result);
1201
- }
1202
-
1203
- async function sendMessage(key, input, options, application) {
1204
- if (input.duplicate) {
1205
- const currentTurn = openCodeTurnSnapshot(turns.get(key));
1206
- return {
1207
- delivered: true,
1208
- duplicate: true,
1209
- ok: true,
1210
- thread: { id: currentTurn?.threadId || input.threadId },
1211
- turn: currentTurn
1212
- };
1213
- }
1214
- const currentTurn = turns.get(key);
1215
- if (currentTurn?.observationError) {
1216
- await monitors.get(key);
1217
- }
1218
- if (currentTurn?.active && currentTurn.observationError) {
1219
- // An explicit Send may retry stopping uncertain work, but cannot overlap it.
1220
- try {
1221
- await stopUnobservedSession(processes.get(key), currentTurn.threadId);
1222
- } catch (error) {
1223
- return { failure: { error, recovery: true } };
1224
- }
1225
- currentTurn.active = false;
1226
- currentTurn.state = "interrupted";
1227
- await application.writeRun(currentTurn, "interrupted", currentTurn.observationError);
1228
- }
1229
- let currentMonitor = monitors.get(key);
1230
- let currentThreadId = input.threadId;
1231
- const ownershipMatchesTurn = Boolean(
1232
- currentMonitor &&
1233
- options.turnOwnership &&
1234
- text(options.turnOwnership.threadId) === currentThreadId &&
1235
- text(options.turnOwnership.turnId) === text(currentTurn?.id)
1236
- );
1237
- if (ownershipMatchesTurn && options.turnOwnership.reusable !== true) {
1238
- return { ownerConflict: true, threadId: currentThreadId,
1239
- turn: openCodeTurnSnapshot(currentTurn, currentThreadId) };
1240
- }
1241
- const eventStartedAt = Date.now();
1242
- const startedAt = new Date(eventStartedAt).toISOString();
1243
- const providerMessageId = input.id;
1244
- const startingTurn = currentMonitor
1245
- ? null
1246
- : {
1247
- active: true,
1248
- error: "",
1249
- eventStartedAt,
1250
- id: providerMessageId,
1251
- inputMessageId: providerMessageId,
1252
- startedAt,
1253
- state: "starting",
1254
- threadId: currentThreadId,
1255
- updatedAt: startedAt
1256
- };
1257
- if (startingTurn) {
1258
- turns.set(key, startingTurn);
1259
- await application.writeRun(startingTurn, "starting");
1260
- }
1261
- let admitted = null;
1262
- let target = null;
1263
- let admission = null;
1264
- let dispatchTurn = null;
1265
- let dispatchMonitor = null;
1266
- let previousInputMessageId = null;
1267
- let previousAdmission;
1268
- let recoveryReceipt = null;
1269
- let recoveryObservationError = null;
1270
- try {
1271
- const preparation = await application.prepare({ overwrite: !currentMonitor, threadId: currentThreadId });
1272
- const preparedTarget = preparation.process ? await acquirePrepared(preparation.process) : preparation.target;
1273
- if (!monitors.get(key)) await currentDirectory(preparedTarget.workdir);
1274
- target = await ensureOpenCodeSession(preparedTarget, preparation.session);
1275
- currentThreadId = target.upstreamSessionId;
1276
- if (startingTurn) {
1277
- startingTurn.threadId = currentThreadId;
1278
- }
1279
- const prompt = await application.prompt();
1280
- // Preparation can outlive the preceding turn's final projection. Do not
1281
- // attach a new input to a monitor that has already claimed retirement.
1282
- currentMonitor = monitors.get(key);
1283
- if (currentMonitor && !turns.get(key)?.active) {
1284
- await currentMonitor;
1285
- currentMonitor = monitors.get(key);
1286
- }
1287
- const recoveryTurn = currentMonitor ? turns.get(key) : null;
1288
- if (recoveryTurn?.recoveryAdmission) {
1289
- try {
1290
- recoveryReceipt = await recoveryTurn.recoveryAdmission.promise;
1291
- currentMonitor = monitors.get(key);
1292
- if (currentMonitor && !turns.get(key)?.active) {
1293
- await currentMonitor;
1294
- currentMonitor = monitors.get(key);
1295
- }
1296
- // Stop must not turn a waiting, undispatched input into a fresh turn.
1297
- recoveryTurn.abortController.signal.throwIfAborted();
1298
- target.abortController.signal.throwIfAborted();
1299
- } catch (error) {
1300
- // This is R's failure, not a rejected or attempted native B input.
1301
- return { failure: { error, attempted: false, threadId: currentThreadId,
1302
- turn: openCodeTurnSnapshot(turns.get(key), currentThreadId) } };
1303
- }
1304
- }
1305
- // A new native turn starts only on its directory's current credentials;
1306
- // the wait, if any, is for that directory's own running turns.
1307
- while (!currentMonitor && !directoryCurrent(target.workdir)) {
1308
- await waitForDirectory(target.workdir);
1309
- currentMonitor = monitors.get(key);
1310
- if (currentMonitor && !turns.get(key)?.active) {
1311
- await currentMonitor;
1312
- currentMonitor = monitors.get(key);
1313
- }
1314
- }
1315
- admission = Promise.withResolvers();
1316
- // The observer may be between polls when dispatch or persistence fails.
1317
- // Keep the raw promise rejectable without an unhandled rejection.
1318
- void admission.promise.catch(() => {});
1319
- let eventReady = null;
1320
- if (currentMonitor) {
1321
- dispatchTurn = turns.get(key);
1322
- previousInputMessageId = dispatchTurn.inputMessageId;
1323
- previousAdmission = dispatchTurn.admission;
1324
- dispatchTurn.admission = admission;
1325
- dispatchTurn.inputMessageId = providerMessageId;
1326
- dispatchTurn.updatedAt = new Date().toISOString();
1327
- dispatchMonitor = currentMonitor;
1328
- } else {
1329
- eventReady = Promise.withResolvers();
1330
- dispatchMonitor = beginMessageMonitor(key, target, { id: providerMessageId, eventStartedAt, startedAt }, {
1331
- ...options, admission, eventReady
1332
- }, application.monitor);
1333
- dispatchTurn = turns.get(key);
1334
- }
1335
- admitted = await dispatchOpenCodeTurn(dispatchTurn, {
1336
- get agent() { return prompt.agent; },
1337
- delivery: currentMonitor ? "steer" : "queue",
1338
- id: providerMessageId,
1339
- get model() { return prompt.model; },
1340
- get prompt() { return prompt.prompt; },
1341
- get attachments() { return prompt.attachments; }
1342
- }, {
1343
- get client() { return target.server.client; },
1344
- get conversationId() { return target.upstreamSessionId; },
1345
- events: eventReady ? { waitUntilReady: () => eventReady.promise } : null,
1346
- get signal() {
1347
- return AbortSignal.any([
1348
- target.abortController.signal,
1349
- turns.get(key).abortController.signal
1350
- ]);
1351
- },
1352
- beforeDispatch: () => prompt.beforeDispatch(target.upstreamSessionId),
1353
- promptTimeoutMs: options.promptTimeoutMs,
1354
- authorizeAttachments: prompt.authorizeAttachments
1355
- });
1356
- } catch (error) {
1357
- const rejected = error?.statusCode >= 400 && error.statusCode < 500 && error.statusCode !== 408;
1358
- const retainPreviousInput = previousInputMessageId !== null &&
1359
- (dispatchTurn?.promptAttempted !== true || rejected);
1360
- if (retainPreviousInput) {
1361
- if (dispatchTurn.admission === admission && dispatchTurn.inputMessageId === providerMessageId) {
1362
- dispatchTurn.inputMessageId = previousInputMessageId;
1363
- dispatchTurn.admission = previousAdmission;
1364
- }
1365
- admission.resolve();
1366
- } else {
1367
- // Attempted unknown admission must retain the new input and use the
1368
- // same observer's original Stop/error recovery, never roll back to A.
1369
- admission?.reject(error);
1370
- dispatchTurn?.abortController?.abort(error);
1371
- }
1372
- if (rejected) await input.onPromptRejected?.();
1373
- if (admission && !retainPreviousInput) await dispatchMonitor;
1374
- if (startingTurn && !admission && !monitors.has(key)) {
1375
- startingTurn.active = false;
1376
- startingTurn.error = text(error?.message) || "OpenCode prompt delivery failed.";
1377
- startingTurn.state = "failed";
1378
- startingTurn.updatedAt = new Date().toISOString();
1379
- await application.writeRun(
1380
- startingTurn,
1381
- "failed",
1382
- startingTurn.error
1383
- ).catch(() => null);
1384
- }
1385
- return { failure: { error, attempted: dispatchTurn?.promptAttempted === true, threadId: currentThreadId,
1386
- turn: openCodeTurnSnapshot(turns.get(key) || startingTurn, currentThreadId) } };
1387
- }
1388
- if (dispatchTurn.admission === admission && dispatchTurn.inputMessageId === providerMessageId) {
1389
- dispatchTurn.inputMessageId = text(admitted.id);
1390
- dispatchTurn.updatedAt = new Date().toISOString();
1391
- }
1392
- let conversationTurn;
1393
- try {
1394
- conversationTurn = await application.commit(admitted);
1395
- } catch (error) {
1396
- admission.reject(error);
1397
- dispatchTurn.abortController.abort(error);
1398
- // Preserve the persistence error while joining the original recovery.
1399
- await dispatchMonitor.catch(() => null);
1400
- throw error;
1401
- }
1402
- if (recoveryReceipt) {
1403
- const receiptAbort = new AbortController();
1404
- // The original monitor can fail and retire without aborting its turn.
1405
- // Its settlement also ends this read; it never creates a successor turn.
1406
- void dispatchMonitor.then(() => receiptAbort.abort(new Error(
1407
- dispatchTurn.observationError || dispatchTurn.error || "OpenCode observation ended before input custody was confirmed."
1408
- )), error => receiptAbort.abort(error));
1409
- try {
1410
- const observation = await inspectOpenCodeMessageAdmission(target.server.client, currentThreadId,
1411
- text(admitted.id), { wait: true, signal: AbortSignal.any([
1412
- target.abortController.signal, dispatchTurn.abortController.signal, receiptAbort.signal
1413
- ]) });
1414
- const message = openCodeMessageRows(observation.messages).find(message =>
1415
- message.type === "user" && text(message.id) === text(admitted.id));
1416
- const created = message?.time?.created;
1417
- const recoveryCreated = recoveryReceipt.time?.created;
1418
- // Match the native latest-user order, including its ID tie-breaker.
1419
- if (!Number.isFinite(created) || !Number.isFinite(recoveryCreated) ||
1420
- !(created > recoveryCreated || (created === recoveryCreated && message.id > recoveryReceipt.id))) {
1421
- throw Object.assign(new Error(
1422
- "OpenCode could not confirm that the new input follows its final-answer recovery. The admitted message was not resent."
1423
- ), { code: "assistant_opencode_observation_lost", details: {}, statusCode: 503 });
1424
- }
1425
- } catch (error) {
1426
- recoveryObservationError = error;
1427
- } finally {
1428
- receiptAbort.abort();
1429
- }
1430
- }
1431
- if (recoveryObservationError) {
1432
- // Native B was dispatched: keep its authored receipt even when observing
1433
- // its place in native history failed, and use the original cleanup owner.
1434
- admission.reject(recoveryObservationError);
1435
- dispatchTurn.abortController.abort(recoveryObservationError);
1436
- await dispatchMonitor.catch(() => null);
1437
- } else {
1438
- admission.resolve();
1439
- }
1440
- const turn = openCodeTurnSnapshot(turns.get(key), target.upstreamSessionId);
1441
- return {
1442
- conversationTurn,
1443
- delivered: true,
1444
- deliveryMode: currentMonitor ? "steer" : "new_turn",
1445
- ok: true,
1446
- thread: { id: target.upstreamSessionId },
1447
- turn,
1448
- workdir: input.workdir
1449
- };
1450
- }
1451
-
1452
- function hasActiveTemporaryConversation(sessionId) {
1453
- return [...temporaryConversations.values()].some((entry) => (
1454
- !entry.reasoningSummary && entry.active === true && entry.target?.sessionId === sessionId
1455
- ));
1456
- }
1457
-
1458
- async function reconcileSessions(sessions = [], options = {}, preparation) {
1459
- const results = [];
1460
- for (const session of sessions) {
1461
- const sessionId = text(session?.sessionId || session?.id);
1462
- try {
1463
- const prepared = await preparation.session(sessionId, session, options);
1464
- const resumed = await reconcilePreparedSession(prepared, prepared.activeRun, options);
1465
- results.push({ ok: true, resumed, sessionId });
1466
- } catch (error) {
1467
- results.push({ error: text(error?.message), ok: false, sessionId });
1468
- }
1469
- }
1470
- return {
1471
- failed: results.filter((result) => result.ok === false),
1472
- ok: results.every((result) => result.ok),
1473
- results,
1474
- sessionCount: results.length
1475
- };
1476
- }
1477
-
1478
- async function reconcilePreparedSession(prepared, activeRun, options) {
1479
- const target = await acquirePrepared(await prepared.process());
1480
- const current = await ensureOpenCodeSession(target, prepared.session());
1481
- return reconcileSessionTurn(prepared.key, current, activeRun, options, prepared.observation).catch(prepared.failure);
1482
- }
1483
-
1484
- async function reconcileSessionTurn(key, target, activeRun, options, application) {
1485
- if (activeRun?.observationError) {
1486
- await stopUnobservedSession(target, activeRun.threadId);
1487
- await application.writeRun({ ...activeRun, id: activeRun.turnId }, "interrupted", activeRun.observationError);
1488
- } else if (activeRun) {
1489
- beginMessageMonitor(key, target, {
1490
- restored: true,
1491
- eventStartedAt: Date.parse(text(activeRun.startedAt)) || Date.now(),
1492
- id: text(activeRun.turnId) || application.fallbackTurnId,
1493
- startedAt: text(activeRun.startedAt)
1494
- }, options, application.monitor);
1495
- }
1496
- return Boolean(activeRun && !activeRun.observationError);
1497
- }
1498
-
1499
- function beginMessageMonitor(key, target, admitted, options, projection) {
1500
- const start = () => {
1501
- target = { ...target };
1502
- const existing = monitors.get(key);
1503
- if (existing) {
1504
- return existing;
1505
- }
1506
- const metadata = projection.prepare(admitted, options);
1507
- const eventStartedAt = Number(admitted.eventStartedAt) || Date.now();
1508
- const startedAt = text(admitted.startedAt) || new Date(eventStartedAt).toISOString();
1509
- const turn = {
1510
- abortController: new AbortController(),
1511
- admission: options.admission,
1512
- active: true,
1513
- error: "",
1514
- eventStartedAt,
1515
- id: text(admitted.id),
1516
- inputMessageId: text(admitted.id),
1517
- startedAt,
1518
- state: options.admission ? "starting" : "active",
1519
- ...metadata.fields,
1520
- threadId: target.upstreamSessionId,
1521
- updatedAt: startedAt
1522
- };
1523
- const monitor = beginMonitor(key, target, turn, projection.create(target, turn, metadata, options));
1524
- void monitor.catch(error => projection.onError(error));
1525
- return monitor;
1526
- };
1527
- if (typeof projection.runInContext !== "function") return start();
1528
- let monitor, startFailure;
1529
- // Context entry invokes this callback synchronously. Do not return the
1530
- // monitor to an async scope wrapper: retain its identity and error owner.
1531
- const scope = projection.runInContext(() => {
1532
- try { monitor = start(); }
1533
- catch (error) { startFailure = { error }; }
1534
- });
1535
- void Promise.resolve(scope).catch(error => projection.onError(error));
1536
- if (startFailure) throw startFailure.error;
1537
- return monitor;
1538
- }
1539
-
1540
- // A turn's failure and whether it is a credential failure. A removed
1541
- // connection's models are gone from its reloaded directory.
1542
- function turnFailure(failure) {
1543
- const providerId = /\bModel not found: ([^/\s]+)\//u.exec(failure)?.[1];
1544
- if (providerId && sharedProcess && !sharedProcess.connections.has(providerId)) {
1545
- return { failure: `${providerId} isn't connected. Connect it again, then send your message again.`, credentialFailure: true };
1546
- }
1547
- return { failure, credentialFailure: openCodeCredentialFailure(failure) };
1548
- }
1549
-
1550
- function beginMonitor(key, target, turn, {
1551
- observe, observation, eventReady, readiness, finalResponse,
1552
- writeRun, projectMessages, completeResult, onRetired
1553
- }) {
1554
- delete turn.finalAssistantResult;
1555
- delete turn.finalAssistantResultTarget;
1556
- delete turn.finalAssistantResultProcess;
1557
- const registeredTarget = processes.get(key);
1558
- const finalAssistantResultTarget = registeredTarget &&
1559
- registeredTarget.abortController === target.abortController &&
1560
- registeredTarget.server === target.server &&
1561
- registeredTarget.upstreamSessionId === target.upstreamSessionId ? registeredTarget : null;
1562
- const finalAssistantResultProcess = finalAssistantResultTarget ? sharedProcess : null;
1563
- turns.set(key, turn);
1564
- const releaseDirectory = holdDirectory(target.workdir);
1565
- const signal = AbortSignal.any([target.abortController.signal, turn.abortController.signal]);
1566
- const monitor = Promise.resolve().then(async () => {
1567
- const events = observation
1568
- ? observePreparedEvents(target, turn, turn.abortController, signal, observation())
1569
- : observe(turn, signal);
1570
- if (eventReady) {
1571
- void events.waitUntilReady(readiness).then(eventReady.resolve, eventReady.reject);
1572
- }
1573
- let finalState = "completed";
1574
- let failure = "";
1575
- let finalAssistantResult = null;
1576
- let credentialFailure = false;
1577
- let providerApiFailure = false;
1578
- try {
1579
- await turn.admission?.promise;
1580
- signal.throwIfAborted();
1581
- turn.state = "active";
1582
- await writeRun(turn, "active");
1583
- for (;;) {
1584
- const completion = await waitForOpenCodeFinalResponse(
1585
- target.server.client,
1586
- target.upstreamSessionId,
1587
- turn,
1588
- {
1589
- ...finalResponse,
1590
- // Preserve the reasoning-only completion before the native recovery
1591
- // input replaces the projection boundary. Replayed writes are no-ops.
1592
- beforeRecovery: async completion => {
1593
- await projectMessages(completion.messages, { inputMessageId: completion.inputMessageId });
1594
- await finalResponse.beforeRecovery?.(completion);
1595
- },
1596
- onMessages: async (messages, inputMessageId) => {
1597
- const latest = openCodeLastAssistantResult(messages, { readError: finalResponse.readError }).message;
1598
- const phase = latest?.summary === true && Number(latest.time?.created) >= turn.eventStartedAt &&
1599
- !latest.time?.completed && !latest.finish && !latest.error ? "compacting" : "";
1600
- if (turn.active && !signal.aborted && text(turn.phase) !== phase) {
1601
- turn.phase = phase;
1602
- turn.updatedAt = new Date().toISOString();
1603
- await writeRun(turn, turn.state);
1604
- }
1605
- return projectMessages(messages, { inputMessageId, streaming: true });
1606
- },
1607
- readFailure: events.readFailure,
1608
- signal
1609
- }
1610
- );
1611
- const admission = turn.admission;
1612
- await admission?.promise;
1613
- signal.throwIfAborted();
1614
- if (admission !== turn.admission || completion.inputMessageId !== text(turn.inputMessageId)) {
1615
- continue;
1616
- }
1617
- const projection = await projectMessages(completion.messages, {
1618
- inputMessageId: completion.inputMessageId,
1619
- captureFinalAssistantResult: true
1620
- });
1621
- await turn.admission?.promise;
1622
- signal.throwIfAborted();
1623
- if (admission !== turn.admission || completion.inputMessageId !== text(turn.inputMessageId)) {
1624
- continue;
1625
- }
1626
- failure = projection.failure || text(events.readFailure()?.message);
1627
- providerApiFailure = projection.providerApiFailure || openCodeProviderApiFailure(events.readFailure());
1628
- if (!failure && !turn.interruptRequested && !text(completion.result?.text)) {
1629
- failure = "OpenCode finished without a user-facing final response. Please send your message again.";
1630
- finalState = "failed";
1631
- } else if (failure) {
1632
- ({ failure, credentialFailure } = turnFailure(failure));
1633
- finalState = "failed";
1634
- } else if (turn.interruptRequested) {
1635
- finalState = "interrupted";
1636
- }
1637
- const published = projection.finalAssistantResult;
1638
- if (finalState === "completed" && !failure && published &&
1639
- published.inputMessageId === completion.inputMessageId &&
1640
- published.itemId === completion.result?.turnId &&
1641
- text(published.text) === text(completion.result?.text)) {
1642
- finalAssistantResult = { ...published, threadId: turn.threadId, turnId: turn.id };
1643
- }
1644
- break;
1645
- }
1646
- } catch (error) {
1647
- if (turn.interruptAcknowledged) {
1648
- finalState = "interrupted";
1649
- } else {
1650
- const cause = signal.aborted ? signal.reason : error;
1651
- ({ failure, credentialFailure } = turnFailure(text(cause?.message) || "OpenCode turn failed."));
1652
- providerApiFailure = openCodeProviderApiFailure(cause);
1653
- finalState = target.abortController.signal.aborted
1654
- ? "cancelled"
1655
- : "failed";
1656
- if (!target.abortController.signal.aborted) {
1657
- turn.observationError = failure;
1658
- try {
1659
- await writeRun(turn, turn.state, failure);
1660
- } catch {
1661
- // A failed store must not prevent stopping native work.
1662
- }
1663
- try {
1664
- await stopUnobservedSession(target, turn.threadId);
1665
- } catch (stopError) {
1666
- failure = `${failure} ${stopError.message}`;
1667
- finalState = "active";
1668
- }
1669
- }
1670
- }
1671
- } finally {
1672
- // Claim retirement before awaited cleanup/checkpointing. A new Send
1673
- // joins this monitor's cleanup instead of steering an unobserved turn.
1674
- turn.active = finalState === "active";
1675
- await events.close();
1676
- if (credentialFailure || finalState === "failed") {
1677
- failure = await completeResult(turn, { credentialFailure, providerApiFailure, finalState, failure });
1678
- }
1679
- turn.error = failure;
1680
- turn.state = finalState;
1681
- turn.finalAssistantResult = finalState === "completed" && !failure ? finalAssistantResult : null;
1682
- turn.finalAssistantResultTarget = turn.finalAssistantResult ? finalAssistantResultTarget : null;
1683
- turn.finalAssistantResultProcess = turn.finalAssistantResult ? finalAssistantResultProcess : null;
1684
- turn.updatedAt = new Date().toISOString();
1685
- await writeRun(turn, finalState, failure).catch(() => null);
1686
- }
1687
- return openCodeTurnSnapshot(turn, target.upstreamSessionId);
1688
- }).finally(() => {
1689
- if (monitors.get(key) === monitor) {
1690
- monitors.delete(key);
1691
- }
1692
- releaseDirectory();
1693
- onRetired();
1694
- });
1695
- monitors.set(key, monitor);
1696
- return monitor;
1697
- }
1698
-
1699
- async function ensurePreparedSessionReadiness(prepared) {
1700
- if (Object.hasOwn(prepared, "value")) return prepared.value;
1701
- let target = processes.get(prepared.key);
1702
- const connection = await prepared.connection();
1703
- const { ready, unhealthyProcess, missingUpstream } = await inspectSessionReadiness(target, {
1704
- selection: prepared.selection, workdir: prepared.workdir, connection,
1705
- changed: prepared.changed, refresh: prepared.refresh
1706
- });
1707
- if (!ready) {
1708
- const { value: recovered } = await prepared.prepare(recovery => recoverSessionReadiness(target, {
1709
- unhealthyProcess, missingUpstream,
1710
- get key() { return recovery.key; }
1711
- }, async () => {
1712
- const configuration = recovery.configuration();
1713
- async function ensureProcess() {
1714
- return acquirePrepared(await configuration.process());
1715
- }
1716
- const current = await ensureProcess();
1717
- return ensureOpenCodeSession(current, configuration.session);
1718
- }));
1719
- if (recovered?.ok === false) return recovered;
1720
- target = recovered;
1721
- }
1722
- const savedRun = prepared.observation.run;
1723
- if (needsObservationRecovery(prepared.key, savedRun)) {
1724
- await prepared.observation.write(async (run, commit) => {
1725
- if (!needsObservationRecovery(prepared.key, run)) return;
1726
- const threadId = text(run.threadId) || target.upstreamSessionId;
1727
- await recoverObservation(target, threadId, () => commit(writeRun => (
1728
- recordStoppedObservation(prepared.key, run, threadId, writeRun)
1729
- )));
1730
- });
1731
- }
1732
- return {
1733
- ok: true,
1734
- thread: { id: target.upstreamSessionId },
1735
- turn: openCodeTurnSnapshot(turns.get(prepared.key), target.upstreamSessionId),
1736
- workdir: prepared.workdir
1737
- };
1738
- }
1739
-
1740
- async function inspectSessionReadiness(target, { selection, workdir, connection, refresh, changed }) {
1741
- let ready = false;
1742
- let unhealthyProcess = null;
1743
- let missingUpstream = false;
1744
- if (
1745
- target?.upstream &&
1746
- sameOpenCodeSelection(target.upstreamSelection, selection) &&
1747
- target.workdir === workdir &&
1748
- text(target.selection?.catalogRevision) === text(selection.catalogRevision) &&
1749
- target.connectionFingerprint === connection.fingerprint &&
1750
- target.canonicalUrl === connection.canonicalUrl &&
1751
- target.endpointCode === connection.endpointCode
1752
- ) {
1753
- const observedProcess = sharedProcess;
1754
- const observedServer = target.server;
1755
- let healthy = false;
1756
- const probe = AbortSignal.timeout(1_000);
1757
- try {
1758
- await target.server.client.health({ signal: probe });
1759
- healthy = true;
1760
- ready = Boolean(await target.server.client.readSession(target.upstreamSessionId));
1761
- } catch (error) {
1762
- if ((!healthy && probe.aborted) || (healthy && error?.statusCode !== 404)) {
1763
- throw error;
1764
- }
1765
- if (!healthy) {
1766
- unhealthyProcess = observedProcess;
1767
- }
1768
- }
1769
- const current = await refresh();
1770
- if (
1771
- processes.get(current.key) !== target ||
1772
- target.abortController.signal.aborted ||
1773
- target.server !== observedServer ||
1774
- target.workdir !== current.workdir ||
1775
- !sameOpenCodeSelection(target.upstreamSelection, current.selection) ||
1776
- text(target.selection?.catalogRevision) !== text(current.selection.catalogRevision)
1777
- ) {
1778
- throw changed();
1779
- }
1780
- missingUpstream = healthy && !ready;
1781
- }
1782
- return { ready, unhealthyProcess, missingUpstream };
1783
- }
1784
-
1785
- async function recoverSessionReadiness(target, recovery, prepare) {
1786
- if (recovery.unhealthyProcess && sharedProcess === recovery.unhealthyProcess) {
1787
- await stop("opencode-health-check-failed");
1788
- }
1789
- if (recovery.missingUpstream && processes.get(recovery.key) === target) {
1790
- target.upstream = null;
1791
- }
1792
- return prepare();
1793
- }
1794
-
1795
- function needsObservationRecovery(key, run) {
1796
- return Boolean(run?.active && run.observationError && !monitors.has(key));
1797
- }
1798
-
1799
- async function recordStoppedObservation(key, run, threadId, writeRun) {
1800
- const stopped = {
1801
- ...turns.get(key),
1802
- ...run,
1803
- id: text(run.turnId),
1804
- threadId,
1805
- active: false,
1806
- state: "interrupted",
1807
- updatedAt: new Date().toISOString()
1808
- };
1809
- await writeRun(stopped, "interrupted", run.observationError);
1810
- turns.set(key, stopped);
1811
- }
1812
-
1813
- async function recoverObservation(target, threadId, commit) {
1814
- if (threadId !== target.upstreamSessionId) return;
1815
- const status = await target.server.client.sessionStatus(threadId, {
1816
- signal: AbortSignal.timeout(OPENCODE_INTERRUPT_TIMEOUT_MS)
1817
- });
1818
- if (status?.type !== "idle") return;
1819
- await commit();
1820
- }
1821
-
1822
- async function interruptTurn(key, target, { threadId = "", writeRun } = {}) {
1823
- const turn = turns.get(key);
1824
- // Helpers can acquire the shared process before a main conversation exists.
1825
- // Their cleanup is owned by closeSession, not a native main-turn interrupt.
1826
- if (!target || (!text(target.upstreamSessionId) && !turn?.active)) {
1827
- return {
1828
- interrupted: false,
1829
- ok: true,
1830
- thread: { id: threadId },
1831
- turn: openCodeTurnSnapshot(turn)
1832
- };
1833
- }
1834
- if (turn?.observationError) {
1835
- await monitors.get(key);
1836
- if (turn.active) {
1837
- await stopUnobservedSession(target, turn.threadId);
1838
- turn.active = false;
1839
- turn.state = "interrupted";
1840
- await writeRun(turn, turn.state, turn.observationError);
1841
- }
1842
- return { ok: true, interrupted: true, thread: { id: turn.threadId }, turn: openCodeTurnSnapshot(turn) };
1843
- }
1844
- if (turn) {
1845
- turn.interruptRequested = true;
1846
- }
1847
- const interrupted = Boolean(turn?.active);
1848
- try {
1849
- const confirmed = await target.server.client.interrupt(target.upstreamSessionId, {
1850
- signal: AbortSignal.timeout(OPENCODE_INTERRUPT_TIMEOUT_MS)
1851
- });
1852
- if (confirmed !== true) {
1853
- throw Object.assign(new Error("OpenCode did not confirm Stop. Try Stop again."), {
1854
- code: "assistant_opencode_interrupt_unconfirmed", details: {}, statusCode: 502
1855
- });
1856
- }
1857
- } catch (error) {
1858
- if (turn) turn.interruptRequested = false;
1859
- if (error?.name === "TimeoutError") {
1860
- throw Object.assign(new Error("OpenCode did not confirm Stop within 5 seconds. Try Stop again."), {
1861
- code: "assistant_opencode_interrupt_timeout", details: {}, statusCode: 504
1862
- });
1863
- }
1864
- throw error;
1865
- }
1866
- if (turn?.active && turn.abortController) {
1867
- turn.interruptAcknowledged = true;
1868
- turn.abortController.abort();
1869
- turn.admission?.resolve();
1870
- await monitors.get(key);
1871
- }
1872
- return {
1873
- interrupted,
1874
- ok: true,
1875
- thread: { id: target.upstreamSessionId },
1876
- turn: openCodeTurnSnapshot(turn, target.upstreamSessionId)
1877
- };
1878
- }
1879
-
1880
124
  async function readSessionState(current, application) {
1881
125
  const context = await application.context(current);
1882
126
  const target = processes.get(context.key);
@@ -1892,13 +136,13 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
1892
136
  }
1893
137
 
1894
138
  async function acquirePreparedTerminal(preparation) {
1895
- return acquirePrepared(await preparation.process());
139
+ return processOwner.acquirePrepared(await preparation.process());
1896
140
  }
1897
141
 
1898
142
  async function ensurePreparedTerminal(preparation) {
1899
143
  const target = await acquirePreparedTerminal(preparation);
1900
144
  // The native terminal starts its own turns there.
1901
- await currentDirectory(target.workdir);
145
+ await processOwner.currentDirectory(target.workdir);
1902
146
  return ensureOpenCodeSession(target, preparation.session());
1903
147
  }
1904
148
 
@@ -1922,14 +166,14 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
1922
166
  upstreamSessionId: target.upstreamSessionId,
1923
167
  workdir: context.workdir
1924
168
  });
1925
- if (terminal?.ok === true && text(terminal.id)) {
1926
- target.terminalSessionId = text(terminal.id);
169
+ if (terminal?.ok === true && normalizeNullableText(terminal.id)) {
170
+ target.terminalSessionId = normalizeNullableText(terminal.id);
1927
171
  }
1928
172
  return terminal;
1929
173
  }
1930
174
 
1931
175
  async function closeTerminal(sessionId, terminalSessionId, terminalHost) {
1932
- const id = text(terminalSessionId);
176
+ const id = normalizeNullableText(terminalSessionId);
1933
177
  const result = await terminalHost.close(id, {
1934
178
  namespace: terminalHost.namespace(sessionId)
1935
179
  });
@@ -1949,22 +193,75 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
1949
193
  }
1950
194
 
1951
195
  const runtime = Object.freeze({
1952
- processes, processStarts, monitors, turns, temporaryConversations,
1953
- get current() { return sharedProcess; },
1954
- get closed() { return closed; },
1955
- get cleanupPending() { return Boolean(failedStartup); },
1956
- acquire, targetFor, ensure, withStorageProcess, release, releaseTarget, releaseProcessTarget, closeSession, closeProject,
1957
- startPreparedProcess, acquirePrepared, createPreparedConversation, runPreparedRenewalTurn, selectPreparedConversation, existingPreparedConversation,
1958
- runPreparedConversationTurn, readPreparedConversation, waitPreparedConversationTurn, stopPreparedConversation, deletePreparedConversation,
1959
- sendPreparedMessage, inspectPreparedMessage, interruptPreparedTurn,
1960
- invalidateRuntimes, releaseRenewalPredecessorProcessExitProof, releaseProcessExitProof, stop, stopUnobservedSession, interruptTurn,
1961
- ensurePreparedSessionReadiness, inspectSessionReadiness, recoverSessionReadiness, needsObservationRecovery, recoverObservation,
1962
- createConversation, selectConversation, existingConversation, runConversationTurn, readConversation, readPersistentConversation, waitForConversationTurn,
1963
- stopConversation, deleteConversation, sendMessage, hasActiveTemporaryConversation, reconcileSessions, reconcilePreparedSession, recordStoppedObservation, beginMessageMonitor, beginMonitor, writeBindings,
1964
- writeConversationProjection, readFinalAssistantResult,
196
+ processes,
197
+ processStarts,
198
+ monitors,
199
+ turns,
200
+ temporaryConversations,
201
+ get current() { return processOwner.current; },
202
+ get closed() { return processOwner.closed; },
203
+ get cleanupPending() { return processOwner.cleanupPending; },
204
+ acquire: processOwner.acquire,
205
+ targetFor: processOwner.targetFor,
206
+ ensure: processOwner.ensure,
207
+ withStorageProcess: processOwner.withStorageProcess,
208
+ release: processOwner.release,
209
+ releaseTarget: processOwner.releaseTarget,
210
+ releaseProcessTarget: processOwner.releaseProcessTarget,
211
+ closeSession: processOwner.closeSession,
212
+ closeProject: processOwner.closeProject,
213
+ startPreparedProcess: processOwner.startPreparedProcess,
214
+ acquirePrepared: processOwner.acquirePrepared,
215
+ createPreparedConversation: conversations.createPreparedConversation,
216
+ runPreparedRenewalTurn: sessionTurns.runPreparedRenewalTurn,
217
+ selectPreparedConversation: conversations.selectPreparedConversation,
218
+ existingPreparedConversation: conversations.existingPreparedConversation,
219
+ runPreparedConversationTurn: conversations.runPreparedConversationTurn,
220
+ readPreparedConversation: conversations.readPreparedConversation,
221
+ waitPreparedConversationTurn: conversations.waitPreparedConversationTurn,
222
+ stopPreparedConversation: conversations.stopPreparedConversation,
223
+ deletePreparedConversation: conversations.deletePreparedConversation,
224
+ sendPreparedMessage: sessionTurns.sendPreparedMessage,
225
+ inspectPreparedMessage: sessionTurns.inspectPreparedMessage,
226
+ interruptPreparedTurn: sessionTurns.interruptPreparedTurn,
227
+ invalidateRuntimes: processOwner.invalidateRuntimes,
228
+ releaseRenewalPredecessorProcessExitProof: processOwner.releaseRenewalPredecessorProcessExitProof,
229
+ releaseProcessExitProof: processOwner.releaseProcessExitProof,
230
+ stop: processOwner.stop,
231
+ stopUnobservedSession: processOwner.stopUnobservedSession,
232
+ interruptTurn: sessionTurns.interruptTurn,
233
+ ensurePreparedSessionReadiness: sessionTurns.ensurePreparedSessionReadiness,
234
+ inspectSessionReadiness: sessionTurns.inspectSessionReadiness,
235
+ recoverSessionReadiness: sessionTurns.recoverSessionReadiness,
236
+ needsObservationRecovery: sessionTurns.needsObservationRecovery,
237
+ recoverObservation: sessionTurns.recoverObservation,
238
+ createConversation: conversations.createConversation,
239
+ selectConversation: conversations.selectConversation,
240
+ existingConversation: conversations.existingConversation,
241
+ runConversationTurn: conversations.runConversationTurn,
242
+ readConversation: conversations.readConversation,
243
+ readPersistentConversation: conversations.readPersistentConversation,
244
+ waitForConversationTurn: conversations.waitForConversationTurn,
245
+ stopConversation: conversations.stopConversation,
246
+ deleteConversation: conversations.deleteConversation,
247
+ sendMessage: sessionTurns.sendMessage,
248
+ hasActiveTemporaryConversation: conversations.hasActiveTemporaryConversation,
249
+ reconcileSessions: sessionTurns.reconcileSessions,
250
+ reconcilePreparedSession: sessionTurns.reconcilePreparedSession,
251
+ recordStoppedObservation: sessionTurns.recordStoppedObservation,
252
+ beginMessageMonitor: turnMonitor.beginMessageMonitor,
253
+ beginMonitor: turnMonitor.beginMonitor,
254
+ writeBindings,
255
+ writeConversationProjection,
256
+ readFinalAssistantResult: turnMonitor.readFinalAssistantResult,
1965
257
  projectConversationMessages: projectOpenCodeConversationMessages,
1966
- readSessionState, startPreparedTerminal, attachTerminal, closeTerminal, allowTerminalAttachments,
1967
- turnSnapshot: openCodeTurnSnapshot, providerApiFailure: openCodeProviderApiFailure
258
+ readSessionState,
259
+ startPreparedTerminal,
260
+ attachTerminal,
261
+ closeTerminal,
262
+ allowTerminalAttachments,
263
+ turnSnapshot: openCodeTurnSnapshot,
264
+ providerApiFailure: openCodeProviderApiFailure
1968
265
  });
1969
266
  if (scope) sharedRuntimes.set(scope, runtime);
1970
267
  return runtime;