@jskit-ai/assistant-core 0.2.7 → 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 +13 -10
  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
@@ -0,0 +1,475 @@
1
+ import { jsonHash as hash } from "../normalize.js";
2
+ import { realpath, rm } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { isDeepStrictEqual } from "node:util";
5
+ import { createOpenCodeConversationServer, openCodeConversationAgent } from "../openCodeProcess.js";
6
+ import { createOpenCodeSharedRuntime } from "../openCodeRuntime.js";
7
+ import { ensureOpenCodeSession, openCodeServerForDirectory } from "../openCodeNativeSession.js";
8
+ import { openCodeAssistantMessageText } from "../openCodeClient.js";
9
+ import { inspectOpenCodeMessageAdmission, observeOpenCodeEvents, openCodeRowsForInput,
10
+ openCodeDetachedPrompt, openCodeStructuredOutput, readOpenCodeMessagesUntil } from "../openCodeTurn.js";
11
+ import { createOpenCodeToolBridge } from "../openCodeTools.js";
12
+ import { validateConnectionModel } from "../configuration.js";
13
+ import { ignoreReceipt } from "../nativeCommandCallbacks.js";
14
+
15
+ /** A standalone conversation's tool call has no further owner to confirm. */
16
+ function acceptCurrentTurn() {
17
+ return true;
18
+ }
19
+
20
+ const nativeMessageId = id => `msg_jskit_${hash(id).slice(0, 40)}`;
21
+
22
+ /**
23
+ * Standalone native-use validation and authored admission, shared by
24
+ * explicitly supplied bound tools without transferring native process ownership.
25
+ */
26
+ export async function executeOpenCodeApplicationTool({ active, turn, target, sessionId, assertCurrent = acceptCurrentTurn }, input, signal) {
27
+ if (!active?.tools || input.sessionId !== sessionId ||
28
+ [input.id, input.name, input.messageId].some(value => typeof value !== "string" || !value || value.length > 256)) {
29
+ throw new Error("This OpenCode tool call does not belong to the active conversation.");
30
+ }
31
+ if (!turn?.abortController) {
32
+ throw new Error("This OpenCode tool call does not belong to an admitted conversation.");
33
+ }
34
+ assertCurrent();
35
+ const observed = AbortSignal.any([signal, turn.abortController.signal]);
36
+ await turn.admission?.promise;
37
+ observed.throwIfAborted();
38
+ // Read back to an admitted input that precedes the native tool use.
39
+ const messages = await readOpenCodeMessagesUntil(target.server.client, sessionId, rows => {
40
+ const index = rows.findIndex(row => row.id === input.messageId);
41
+ return index >= 0 && rows.slice(0, index).some(row => active.messageIds.has(row.id));
42
+ }, { signal: observed });
43
+ let message;
44
+ let owner;
45
+ for (const [nativeId, authored] of active.messageIds) {
46
+ message = openCodeRowsForInput(messages, nativeId).find(message => message.id === input.messageId && message.type === "assistant");
47
+ if (message) { owner = authored; break; }
48
+ }
49
+ const tool = message?.content.find(part => part.type === "tool" && part.callID === input.id);
50
+ if (!tool || tool.tool !== input.name || !isDeepStrictEqual(tool.state?.input, input.input)) {
51
+ throw new Error("OpenCode's application call does not match an admitted native tool use.");
52
+ }
53
+ if (!owner.committed) throw new Error("This OpenCode tool call has no durable authored admission.");
54
+ await owner.committed;
55
+ observed.throwIfAborted();
56
+ assertCurrent(true, owner);
57
+ try {
58
+ return await active.tools.execute({ id: input.id, name: input.name, arguments: JSON.stringify(input.input) }, { signal: observed, messageId: owner.messageId });
59
+ } catch (error) {
60
+ if (assertCurrent(false, owner)) turn.abortController.abort(error);
61
+ throw error;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Opens a conversation on the driver's own native OpenCode process and saved
67
+ * binding: its preparation, its shared runtime, its messages, its tools and its
68
+ * stop.
69
+ * @param {{
70
+ * connections: { resolve(request: { context: object, integrationId: string }): Promise<object> },
71
+ * host: { env?: import("@jskit-ai/kernel/server/types").RuntimeEnv, opencode?: Function, commands?: { opencode?: string },
72
+ * limits?: object, commandWrapper?: object },
73
+ * limits: Record<string, number>,
74
+ * workdir: string,
75
+ * stateDirectory: string,
76
+ * execution: import("../localExecution.js").ConversationExecution,
77
+ * nativeTools: boolean,
78
+ * maximumOutput: number
79
+ * }} settings
80
+ * @param {{ binding: object, writeBinding(binding: object): Promise<void>, reportFailure(error: Error, options?: object): Promise<void> }} opening
81
+ */
82
+ export async function openStandaloneOpenCodeConversation({
83
+ connections,
84
+ host,
85
+ limits,
86
+ workdir,
87
+ stateDirectory,
88
+ execution,
89
+ nativeTools,
90
+ maximumOutput
91
+ }, { binding, writeBinding, reportFailure }) {
92
+ function prompt(input, configuration) {
93
+ const text = `${input.text || ""}${input.attachmentManifest || ""}`;
94
+ if (!configuration.outputSchema) return { text, content: input.content };
95
+ const prepared = openCodeDetachedPrompt({ prompt: text, outputSchema: configuration.outputSchema });
96
+ if (prepared.length > (limits.maxInputCharacters ?? 32_000)) throw new Error("OpenCode structured input exceeded the configured limit.");
97
+ return { text: prepared, content: input.content };
98
+ }
99
+
100
+ if (!binding || typeof binding.sessionId !== "string" || binding.workdir !== await realpath(workdir) ||
101
+ path.dirname(binding.directory || "") !== stateDirectory) {
102
+ throw new Error("This OpenCode conversation belongs to another working directory or native state directory. Restore its original host configuration.");
103
+ }
104
+ let native;
105
+ let toolBridge;
106
+ let facilities;
107
+ let processVariants = {};
108
+ let current;
109
+ let stopping;
110
+ let disposed = false;
111
+ async function updateBinding(patch) {
112
+ const next = { ...binding, ...patch };
113
+ await writeBinding(next);
114
+ binding = next;
115
+ }
116
+ async function stopRecordedExecution() {
117
+ const proof = await execution.stop(binding.executionId);
118
+ if (!proof?.scopeEmpty) throw new Error("OpenCode process cleanup could not be confirmed. Restore its execution host before continuing.");
119
+ if (binding.processDirectory) {
120
+ if (path.dirname(binding.processDirectory) !== (binding.runtimeDirectory || stateDirectory) || !path.basename(binding.processDirectory).startsWith("process-")) {
121
+ throw new Error("OpenCode's private process directory does not belong to this host.");
122
+ }
123
+ await rm(binding.processDirectory, { recursive: true, force: true });
124
+ }
125
+ await updateBinding({ executionId: "", processDirectory: "" });
126
+ }
127
+ async function stop() {
128
+ if (stopping) return stopping;
129
+ stopping = (async () => {
130
+ await toolBridge?.close();
131
+ toolBridge = null;
132
+ if (facilities?.runtime.cleanupPending) {
133
+ await facilities.runtime.stop("opencode-startup-cleanup");
134
+ await updateBinding({ executionId: "", processDirectory: "" });
135
+ }
136
+ if (native) {
137
+ await facilities.runtime.releaseTarget(native, {
138
+ onRemoved: () => facilities.runtime.writeBindings(facilities.registryPath, binding.directory, [])
139
+ });
140
+ facilities.runtime.turns.delete(binding.directory);
141
+ await updateBinding({ executionId: "", processDirectory: "" });
142
+ native = null;
143
+ } else if (binding.executionId && facilities && !host.opencode &&
144
+ !facilities.runtime.current && !facilities.runtime.processStarts.size) {
145
+ // Only our unfinished standalone startup belongs to this handle.
146
+ // A saved shared-server reference is not authority to stop a peer's
147
+ // service before this conversation has acquired its owner.
148
+ await stopRecordedExecution();
149
+ }
150
+ if (current?.finished) current = null;
151
+ })().catch(error => {
152
+ error.cleanupFailed = true;
153
+ if (error.code === "assistant_opencode_stop_unverified") {
154
+ error.message = "OpenCode process cleanup could not be confirmed. Restore its execution host before continuing.";
155
+ }
156
+ throw error;
157
+ }).finally(() => { stopping = null; });
158
+ return stopping;
159
+ }
160
+ async function resolvePreparation(configuration, context, signal, requireBound = false, receiptBinding, authoredPreparation = false) {
161
+ const connection = await connections.resolve({ context, integrationId: configuration.integrationId });
162
+ signal.throwIfAborted();
163
+ validateConnectionModel(configuration, connection);
164
+ const identity = hash([connection.providerId, connection.baseURL || "", connection.apiKey]);
165
+ if (requireBound && !receiptBinding?.accountIdentity) throw new Error("The completed OpenCode response has no saved account fingerprint.");
166
+ if (requireBound && (binding.accountIdentity !== receiptBinding.accountIdentity || identity !== receiptBinding.accountIdentity)) {
167
+ throw new Error("The completed OpenCode response belongs to a different account binding.");
168
+ }
169
+ // A managed host reauthorizes its own connection on every preparation.
170
+ // A key change can rotate an authored run only inside the same native storage/runtime scope.
171
+ const preparedFacilities = host.opencode ? await host.opencode({ connection, context }) : facilities;
172
+ signal.throwIfAborted();
173
+ if (host.opencode && (
174
+ binding.databasePath && binding.databasePath !== preparedFacilities.databasePath ||
175
+ binding.runtimeDirectory && binding.runtimeDirectory !== preparedFacilities.runtimeDirectory ||
176
+ facilities && facilities.runtime !== preparedFacilities.runtime
177
+ )) {
178
+ throw new Error("This OpenCode conversation belongs to another native history store or runtime. Restore its original host configuration.");
179
+ }
180
+ const allowCredentialRefresh = authoredPreparation && host.opencode && preparedFacilities.allowCredentialRefresh === true;
181
+ if (binding.accountIdentity && identity !== binding.accountIdentity && !allowCredentialRefresh) {
182
+ throw new Error("This conversation belongs to another OpenCode account. Restore that connection or start a new conversation.");
183
+ }
184
+ if (binding.accountIdentity !== identity) await updateBinding({ accountIdentity: identity });
185
+ const model = { providerID: connection.providerId, id: connection.model };
186
+ const agent = openCodeConversationAgent({ nativeTools, tools: Boolean(current?.tools) });
187
+ if (current) current.agent = agent;
188
+ if (binding.sessionId && !binding.databasePath) {
189
+ throw new Error("This OpenCode conversation uses the earlier private native history layout. Run the offline native-history upgrade before continuing; no history was changed.");
190
+ }
191
+ if (!facilities || host.opencode) {
192
+ if (host.opencode) facilities = preparedFacilities;
193
+ else {
194
+ const runtimeDirectory = path.join(stateDirectory, "shared", hash([identity, host.commands?.opencode || "opencode"]));
195
+ const selected = { modelProviderId: connection.providerId, canonicalUrl: connection.baseURL || "",
196
+ endpointCode: connection.sdkPackage || "", fingerprint: identity };
197
+ facilities = { runtime: createOpenCodeSharedRuntime({ scope: runtimeDirectory }), runtimeDirectory,
198
+ registryPath: path.join(runtimeDirectory, "session-environments.json"),
199
+ databasePath: path.join(runtimeDirectory, "opencode.db"), selected,
200
+ async start({ signal, connection }) {
201
+ if (binding.executionId && !facilities.runtime.processes.has(binding.directory)) await stopRecordedExecution();
202
+ const starting = new AbortController();
203
+ const abortUnusedStartup = () => {
204
+ if (facilities.runtime.processStarts.size === 1 && facilities.runtime.processes.size === 0) starting.abort(signal.reason);
205
+ };
206
+ signal.addEventListener("abort", abortUnusedStartup, { once: true });
207
+ if (signal.aborted) abortUnusedStartup();
208
+ try {
209
+ const server = await createOpenCodeConversationServer({ command: host.commands?.opencode || "opencode",
210
+ execution, env: host.env, workdir: binding.workdir, stateDirectory: runtimeDirectory,
211
+ databasePath: facilities.databasePath, sessionEnvironmentRegistry: facilities.registryPath, connection,
212
+ maxOutputTokens: limits.maxOutputTokens, executionLimits: host.limits, signal: starting.signal,
213
+ onStarted: (executionId, processDirectory) => updateBinding({ executionId, processDirectory }),
214
+ async onFailure(error) {
215
+ for (const target of facilities.runtime.processes.values()) target.abortController.abort(error);
216
+ try { await facilities.runtime.stop("opencode-process-exited"); }
217
+ catch (cleanupError) { await reportFailure(cleanupError); }
218
+ }
219
+ });
220
+ return { server, connections: [selected] };
221
+ } finally { signal.removeEventListener("abort", abortUnusedStartup); }
222
+ }
223
+ };
224
+ }
225
+ if (binding.databasePath && binding.databasePath !== facilities.databasePath) {
226
+ throw new Error("This OpenCode conversation belongs to another native history store. Restore its original host configuration.");
227
+ }
228
+ await updateBinding({ databasePath: facilities.databasePath, runtimeDirectory: facilities.runtimeDirectory });
229
+ }
230
+ if (!toolBridge && current?.tools) {
231
+ toolBridge = await createOpenCodeToolBridge({
232
+ schemas: current.tools.schemas, maxArgumentBytes: current.tools.maximumArgumentBytes,
233
+ async execute(input, signal) {
234
+ return executeOpenCodeApplicationTool({ active: current,
235
+ turn: facilities.runtime.turns.get(binding.directory), target: native, sessionId: binding.sessionId }, input, signal);
236
+ }
237
+ });
238
+ }
239
+ return { configuration, signal, model, agent, connection };
240
+ }
241
+
242
+ async function prepare({ configuration, signal, model, agent, connection }) {
243
+ try {
244
+ const start = host.opencode
245
+ ? async () => facilities.runtime.startPreparedProcess(await facilities.prepareServer())
246
+ : () => facilities.start({ signal, connection });
247
+ // A managed host's saved connections reach its running server in place.
248
+ const savedConnections = host.opencode ? async () => (await facilities.prepareServer()).connections : null;
249
+ if (!native) {
250
+ native = await facilities.runtime.acquire(binding.directory, async () => {
251
+ const shared = await facilities.runtime.ensure(facilities.selected, start, savedConnections);
252
+ return { key: binding.directory, workdir: binding.workdir, upstreamSessionId: binding.sessionId,
253
+ abortController: new AbortController(), server: openCodeServerForDirectory(shared.server, binding.workdir) };
254
+ });
255
+ } else await facilities.runtime.ensure(facilities.selected, start, savedConnections);
256
+ signal.throwIfAborted();
257
+ await updateBinding({ executionId: native.server.executionId, processDirectory: native.server.privateRoot || "" });
258
+ const catalogue = await native.server.client.providers({ signal });
259
+ const selected = catalogue.all.find(provider => provider.id === connection.providerId)?.models?.[connection.model];
260
+ if (selected?.id !== connection.model) throw new Error("OpenCode could not load the configured model definition. Check the selected integration before sending.");
261
+ processVariants = selected.variants || {};
262
+ } catch (error) {
263
+ if (error.stopProof?.scopeEmpty) await updateBinding({ executionId: "", processDirectory: "" });
264
+ throw error;
265
+ }
266
+ if (configuration.effort) {
267
+ if (!Object.hasOwn(processVariants, configuration.effort)) throw new Error("The selected OpenCode model does not support this reasoning effort.");
268
+ model.variant = configuration.effort;
269
+ }
270
+ const retainedId = binding.sessionId;
271
+ if (retainedId && (await native.server.client.sessionStatus(retainedId, { signal })).type !== "idle") {
272
+ throw new Error("The native OpenCode conversation is still working. Stop or inspect it before sending another message.");
273
+ }
274
+ const session = {
275
+ selection: { agentId: agent, modelId: model.id, modelProviderId: model.providerID, variantId: model.variant || "" },
276
+ model, workdir: binding.workdir, signal,
277
+ invalidIdentity: () => new Error("OpenCode did not acknowledge a native conversation."),
278
+ identity: {
279
+ async write(nativeId) {
280
+ if (retainedId && nativeId !== retainedId) throw new Error("The saved OpenCode conversation is unavailable. Restore its native history before continuing.");
281
+ if (binding.sessionId !== nativeId) await updateBinding({ sessionId: nativeId });
282
+ },
283
+ publish(nativeId) {
284
+ return facilities.runtime.writeBindings(facilities.registryPath, binding.directory, [{
285
+ upstreamSessionId: nativeId, workdir: binding.workdir, modelProviderId: connection.providerId,
286
+ ...(nativeTools ? { env: host.env } : {}),
287
+ conversation: { systemPrompt: configuration.systemPrompt, nativeTools,
288
+ commandWrapper: host.commandWrapper, tools: current?.tools ? toolBridge?.configuration : undefined }
289
+ }]);
290
+ }
291
+ }
292
+ };
293
+ return { target: native, session, model };
294
+ }
295
+
296
+ function messageMonitor(active, command) {
297
+ return {
298
+ prepare() { return { fields: {} }; },
299
+ create(target, turn, _metadata, options) {
300
+ const abort = () => turn.abortController.abort(command.signal.reason);
301
+ command.signal.addEventListener("abort", abort, { once: true });
302
+ if (command.signal.aborted) abort();
303
+ const recoveryMessageId = nativeMessageId(`${command.input.messageId}:final-response`);
304
+ return {
305
+ observe(_turn, signal) {
306
+ return observeOpenCodeEvents(target.server.client, target.upstreamSessionId, {
307
+ abortController: turn.abortController, signal, eventStartedAt: turn.eventStartedAt,
308
+ async onEvent(summary, event) {
309
+ if (summary.type === "session.status") {
310
+ const status = event.data.properties?.status?.type;
311
+ if (status === "retry" || status === "busy") {
312
+ await turn.admission?.promise;
313
+ await active.deliveryCommitted;
314
+ signal.throwIfAborted();
315
+ await active.onEvent({ type: "phase", phase: status === "retry" ? "retrying" : "working" });
316
+ }
317
+ }
318
+ }
319
+ });
320
+ },
321
+ eventReady: options.eventReady,
322
+ readiness: { timeoutMs: limits.admissionTimeoutMs || 120_000,
323
+ timeoutError: new Error("OpenCode's event connection was not ready before the admission deadline.") },
324
+ finalResponse: {
325
+ agent: active.agent, model: active.prepared.model, recoveryMessageId,
326
+ beforeRecovery() {
327
+ active.messageIds.set(recoveryMessageId, active.messageIds.get(turn.inputMessageId));
328
+ }
329
+ },
330
+ async writeRun(observed, state) {
331
+ if (state === "active") await active.onEvent({ type: "phase", phase: observed.phase || "working" });
332
+ },
333
+ async projectMessages(messages, { inputMessageId }) {
334
+ await active.deliveryCommitted;
335
+ if (inputMessageId !== turn.inputMessageId) return { failure: "", providerApiFailure: false };
336
+ return facilities.runtime.projectConversationMessages(messages, inputMessageId, {
337
+ maximumOutput, outputSchema: active.configuration.outputSchema,
338
+ previous: active.previous, onMessage: active.onMessage
339
+ });
340
+ },
341
+ completeResult(_turn, { failure }) { return failure; },
342
+ onRetired() { command.signal.removeEventListener("abort", abort); }
343
+ };
344
+ },
345
+ onError(error) { active.failure ||= error; }
346
+ };
347
+ }
348
+
349
+ async function send(command) {
350
+ const { onNativeReceipt = ignoreReceipt, onReceiptPersisted = ignoreReceipt } = command;
351
+ const active = current;
352
+ const id = nativeMessageId(command.input.messageId);
353
+ const previousOwner = active.messageIds.get(id);
354
+ let accepted = false;
355
+ let value;
356
+ try {
357
+ value = await facilities.runtime.sendMessage(binding.directory, {
358
+ id, threadId: binding.sessionId, workdir: binding.workdir
359
+ }, { promptTimeoutMs: limits.admissionTimeoutMs || 30_000 }, {
360
+ async writeRun() {},
361
+ async prepare({ overwrite }) {
362
+ const signal = active.prepared
363
+ ? AbortSignal.any([command.signal, AbortSignal.timeout(120_000)])
364
+ : active.resolved.signal;
365
+ if (overwrite || !active.prepared) active.prepared = await prepare({ ...active.resolved, signal });
366
+ return { ...active.prepared, session: { ...active.prepared.session, signal } };
367
+ },
368
+ prompt() {
369
+ native.abortController.signal.throwIfAborted();
370
+ return {
371
+ agent: active.agent, model: active.prepared.model,
372
+ prompt: prompt(command.input, active.configuration), attachments: command.input.localFiles,
373
+ authorizeAttachments: true,
374
+ async beforeDispatch(threadId) {
375
+ await command.beforeDispatch({ threadId });
376
+ command.signal.throwIfAborted();
377
+ active.messageIds.set(id, {
378
+ messageId: command.input.messageId, committed: command.deliveryCommitted
379
+ });
380
+ }
381
+ };
382
+ },
383
+ async commit() {
384
+ onNativeReceipt();
385
+ await command.accept();
386
+ onReceiptPersisted();
387
+ active.deliveryCommitted = command.deliveryCommitted;
388
+ accepted = true;
389
+ },
390
+ monitor: messageMonitor(active, command)
391
+ });
392
+ if (value.failure) throw value.failure.error;
393
+ if (!value.delivered) throw new Error("OpenCode did not acknowledge the message.");
394
+ } catch (error) {
395
+ const turn = facilities.runtime.turns.get(binding.directory);
396
+ const attempted = value?.failure ? value.failure.attempted : turn?.promptAttempted === true;
397
+ if (attempted && !accepted && !(error.statusCode >= 400 && error.statusCode < 500 && error.statusCode !== 408)) {
398
+ error.delivery = "uncertain";
399
+ turn?.abortController?.abort(error);
400
+ // Join the same owner's recovery before releasing its process target.
401
+ await facilities.runtime.monitors.get(binding.directory);
402
+ } else if (previousOwner) active.messageIds.set(id, previousOwner);
403
+ else active.messageIds.delete(id);
404
+ if (turn?.active && turn.observationError) error.cleanupFailed = true;
405
+ throw error;
406
+ }
407
+ active.delivery = { turn: facilities.runtime.turns.get(binding.directory),
408
+ completion: facilities.runtime.monitors.get(binding.directory) };
409
+ return value;
410
+ }
411
+
412
+ return Object.freeze({
413
+ async inspectAdmission({ messageId, threadId, configuration, context, verifyAccount = false, receiptBinding }) {
414
+ if (verifyAccount === true && (!receiptBinding || threadId !== binding.sessionId || receiptBinding.sessionId !== binding.sessionId ||
415
+ receiptBinding.workdir !== binding.workdir || receiptBinding.directory !== binding.directory ||
416
+ receiptBinding.databasePath !== binding.databasePath || receiptBinding.runtimeDirectory !== binding.runtimeDirectory)) {
417
+ throw new Error("The completed OpenCode response belongs to a different native conversation or host scope.");
418
+ }
419
+ if (!binding.sessionId) return { accepted: false, messages: [] };
420
+ const prepared = await prepare(await resolvePreparation(configuration, context, AbortSignal.timeout(120_000), verifyAccount === true, receiptBinding));
421
+ await ensureOpenCodeSession(prepared.target, prepared.session);
422
+ const id = nativeMessageId(messageId);
423
+ const { accepted = false, messages = [] } = await inspectOpenCodeMessageAdmission(native.server.client, binding.sessionId, id) || {};
424
+ return { accepted,
425
+ messages: openCodeRowsForInput(messages, id).filter(message => message.type === "assistant" && !message.summary)
426
+ .map(message => ({ id: message.id, outputId: `${message.id}:assistant`, role: "assistant",
427
+ text: configuration.outputSchema ? openCodeStructuredOutput(openCodeAssistantMessageText(message)) : openCodeAssistantMessageText(message) })) };
428
+ },
429
+ steer(command) {
430
+ if (!current || current.finished) throw new Error("OpenCode has no active turn to steer.");
431
+ return send(command);
432
+ },
433
+ async run(command) {
434
+ if (disposed || current) throw new Error("This OpenCode conversation is closed or already working.");
435
+ const active = current = { tools: command.tools, configuration: command.configuration,
436
+ onMessage: command.onMessage, onEvent: command.onEvent, messageIds: new Map(), previous: new Map() };
437
+ let problem;
438
+ try {
439
+ active.resolved = await resolvePreparation(command.configuration, command.context,
440
+ AbortSignal.any([command.signal, AbortSignal.timeout(120_000)]), false, undefined, true);
441
+ let dispatchFailure;
442
+ try { await send(command); }
443
+ catch (error) { dispatchFailure = error; }
444
+ for (;;) {
445
+ const settling = active.delivery;
446
+ let result;
447
+ let failure = dispatchFailure || active.failure;
448
+ if (!failure && settling?.completion) {
449
+ try { result = await settling.completion; }
450
+ catch (error) { failure = error; }
451
+ } else if (!failure && settling) result = facilities.runtime.turnSnapshot(settling.turn, binding.sessionId);
452
+ if (command.finishIfCurrent && !await command.finishIfCurrent(() => active.delivery === settling)) continue;
453
+ failure = dispatchFailure || active.failure || failure;
454
+ if (!failure && result?.error) {
455
+ // readable-check: allow patched-error - data: status is the native turn's state, not an HTTP status
456
+ failure = Object.assign(new Error(result.error), { status: result.status });
457
+ if (result.active && result.status === "observation_lost") failure.cleanupFailed = true;
458
+ }
459
+ if (failure) throw failure;
460
+ break;
461
+ }
462
+ } catch (error) {
463
+ problem = error.cleanupFailed ? error : command.signal.aborted ? command.signal.reason : error;
464
+ if (problem.cleanupFailed) problem.message = "OpenCode process cleanup could not be confirmed. Restore its execution host before continuing.";
465
+ }
466
+ active.finished = true;
467
+ try { if (!problem?.cleanupFailed && (problem || command.signal.aborted)) await stop(); }
468
+ catch (error) { problem = error; }
469
+ finally { await toolBridge?.whenIdle(); if (!problem?.cleanupFailed) current = null; }
470
+ if (problem) throw problem;
471
+ },
472
+ cancel: stop,
473
+ async dispose() { await stop(); disposed = true; }
474
+ });
475
+ }
@@ -1,11 +1,16 @@
1
1
  import { normalizeNullableText } from "../normalize.js";
2
2
  import { createConversationChangeover } from "../continuity.js";
3
+ import { ignorePromptRejection } from "../nativeCommandCallbacks.js";
3
4
 
4
- // The host retains profile authority and durable parent receipts. This original
5
+ /** A scoped turn whose caller follows no thread or turn events. */
6
+ function ignoreScopedEvent() {}
7
+
8
+ // The host retains profile authority and durable parent receipts. This
5
9
  // coordinator uses its admitted operations before and after native creation.
6
10
  export async function runScopedConversationTurn({ operations, scope = {}, input = {}, context: options = {},
7
11
  executionProfile: profileSnapshot } = {}) {
8
12
  const request = { ...input, ephemeral: true, message: input.prompt || input.message };
13
+ const { onEvent = ignoreScopedEvent } = options;
9
14
  options.signal?.throwIfAborted();
10
15
  let conversationId = normalizeNullableText(input.conversationId || input.threadId);
11
16
  if (!conversationId) {
@@ -16,12 +21,12 @@ export async function runScopedConversationTurn({ operations, scope = {}, input
16
21
  }
17
22
  // Await ownership before starting: Stop or a failed parent write must not
18
23
  // leave an unidentified native turn running.
19
- await options.onEvent?.({ type: "thread", threadId: conversationId });
24
+ await onEvent({ type: "thread", threadId: conversationId });
20
25
  options.signal?.throwIfAborted();
21
26
  const started = await operations.startEphemeralConversationTurn(scope, { ...request, conversationId }, options);
22
27
  if (started?.ok === false) return { ...started, threadId: conversationId };
23
28
  const runId = normalizeNullableText(started.runId);
24
- await options.onEvent?.({ type: "turn", threadId: conversationId, turnId: runId, status: started.status });
29
+ await onEvent({ type: "turn", threadId: conversationId, turnId: runId, status: started.status });
25
30
  if (options.signal?.aborted) {
26
31
  const stopped = await operations.stopEphemeralConversation(scope, { ...request, conversationId, runId }, options);
27
32
  if (stopped?.ok === false) return { ...stopped, threadId: conversationId, turnId: runId };
@@ -76,27 +81,65 @@ export function unsupportedScopedConversation() {
76
81
  }
77
82
 
78
83
  /**
79
- * Driver handle for an original scoped conversation. Each engine supplies its
84
+ * One engine's native owner call for a scoped conversation.
85
+ * @typedef {(input: Record<string, any>, context: Record<string, any>) => Promise<any>} ScopedNativeCall
86
+ */
87
+
88
+ /**
89
+ * Driver handle for a scoped conversation. Each engine supplies its
80
90
  * native owner calls; every call receives input bound to `conversationId`.
81
91
  * `disposeOpening` releases the opening without deleting the native conversation.
92
+ * @param {object} owner
93
+ * @param {string} owner.conversationId
94
+ * @param {ScopedNativeCall} owner.sendNative
95
+ * @param {ScopedNativeCall} owner.readNative
96
+ * @param {ScopedNativeCall} owner.waitNative
97
+ * @param {ScopedNativeCall} owner.cancel
98
+ * @param {ScopedNativeCall} owner.deleteConversation
99
+ * @param {() => Promise<any>} owner.disposeOpening
82
100
  */
83
101
  export function createScopedConversationHandle({ conversationId, sendNative, readNative, waitNative, cancel,
84
102
  deleteConversation, disposeOpening }) {
85
103
  const inputFor = scopedConversationInput(conversationId);
104
+
105
+ /** Sends to the native conversation. */
106
+ async function sendScoped({ input, context }) {
107
+ return sendNative(inputFor(input), context);
108
+ }
109
+
110
+ /** Reads the native conversation. */
111
+ async function readScoped({ input, context }) {
112
+ return readNative(inputFor(input), context);
113
+ }
114
+
115
+ /** Waits for the native conversation's turn. */
116
+ async function waitScoped({ input, context }) {
117
+ return waitNative(inputFor(input), context);
118
+ }
119
+
120
+ /** Stops the native conversation's turn. */
121
+ async function cancelScoped({ input, context }) {
122
+ return cancel(inputFor(input), context);
123
+ }
124
+
125
+ /** Deletes the conversation when given its input, or releases only the opening. */
126
+ async function dispose(options) {
127
+ if (options) return deleteConversation(inputFor(options.input), options.context);
128
+ const result = await disposeOpening();
129
+ if (result?.ok === false) throw Object.assign(new Error(result.error || "Scoped cleanup could not be confirmed."), result);
130
+ return result;
131
+ }
132
+
86
133
  return Object.freeze({
87
134
  capabilities: SCOPED_CONVERSATION_CAPABILITIES,
88
- sendNative: async ({ input, context }) => sendNative(inputFor(input), context),
89
- readNative: async ({ input, context }) => readNative(inputFor(input), context),
90
- waitNative: async ({ input, context }) => waitNative(inputFor(input), context),
91
- cancel: async ({ input, context }) => cancel(inputFor(input), context),
92
- readGoal: unsupportedScopedConversation, updateGoal: unsupportedScopedConversation,
135
+ sendNative: sendScoped,
136
+ readNative: readScoped,
137
+ waitNative: waitScoped,
138
+ cancel: cancelScoped,
139
+ readGoal: unsupportedScopedConversation,
140
+ updateGoal: unsupportedScopedConversation,
93
141
  inspectAdmission: unsupportedScopedConversation,
94
- async dispose(options) {
95
- if (options) return deleteConversation(inputFor(options.input), options.context);
96
- const result = await disposeOpening();
97
- if (result?.ok === false) throw Object.assign(new Error(result.error || "Scoped cleanup could not be confirmed."), result);
98
- return result;
99
- }
142
+ dispose
100
143
  });
101
144
  }
102
145
 
@@ -188,8 +231,8 @@ export async function projectStoredPersistentConversation(options) {
188
231
  }, turns);
189
232
  }
190
233
 
191
- // These original persistent-conversation operations run inside the host's
192
- // existing write lease. Their record, transcript and native operations are the
234
+ // These persistent-conversation operations run inside the host's
235
+ // write lease. Their record, transcript and native operations are the
193
236
  // same facilities used by its retained conversation; no state is copied here.
194
237
  export async function readPersistentConversation(options) {
195
238
  const { sessionId, operations, store, records, projection, publish } = options;
@@ -283,10 +326,11 @@ export async function startPersistentConversationTurn(options) {
283
326
  runId: steering ? previous.runId : "",
284
327
  error: ""
285
328
  });
329
+ const { onEvent: forwardEvent = ignoreScopedEvent } = context;
286
330
  try {
287
331
  const result = requireSuccess(await operations.startConversationTurn(sessionId,
288
332
  records.input(record, { ...input, ...prepared.input, steer: steering }), { ...context, attachmentsPrepared: true,
289
- onEvent(event) { streamMessage(options, context, record, event); return context.onEvent?.(event); }
333
+ onEvent(event) { streamMessage(options, context, record, event); return forwardEvent(event); }
290
334
  }));
291
335
  if (receipts.afterAdmission(input)) await writeReceipt();
292
336
  await records.bind(context, record, { runId: result.runId, status: result.status || "inProgress", draft: "", attachments: [] },
@@ -299,7 +343,8 @@ export async function startPersistentConversationTurn(options) {
299
343
  // its authored row, but restore the admitted identity before reconciliation.
300
344
  if (notSent) {
301
345
  record = await records.save(context, record, priorInput);
302
- await input.onPromptRejected?.();
346
+ const { onPromptRejected = ignorePromptRejection } = input;
347
+ await onPromptRejected();
303
348
  }
304
349
  const observed = await readPersistentConversation({ ...options, context, record });
305
350
  if (!notSent && observed.admitted) {