@jskit-ai/assistant-core 0.2.7 → 0.2.9

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
@@ -1,81 +1,57 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { jsonHash as hash } from "../normalize.js";
3
- import { mkdir, realpath, rm } from "node:fs/promises";
2
+ import { mkdir, realpath } from "node:fs/promises";
4
3
  import path from "node:path";
5
- import { isDeepStrictEqual } from "node:util";
6
4
  import { createLocalConversationExecution } from "../localExecution.js";
7
- import { createOpenCodeConversationServer, openCodeConversationAgent } from "../openCodeProcess.js";
8
- import { createOpenCodeSharedRuntime, ensureOpenCodeSession, openCodeServerForDirectory } from "../openCodeRuntime.js";
9
- import { openCodeAssistantMessageText, retireOpenCodeConversationHistory } from "../openCodeClient.js";
10
- import { inspectOpenCodeMessageAdmission, observeOpenCodeEvents, openCodeRowsForInput,
11
- openCodeDetachedPrompt, openCodeStructuredOutput, readOpenCodeMessagesUntil } from "../openCodeTurn.js";
5
+ import { ignoreReceipt } from "../nativeCommandCallbacks.js";
6
+ import { openCodeServerForDirectory } from "../openCodeNativeSession.js";
7
+ import { retireOpenCodeConversationHistory } from "../openCodeNativeHistory.js";
8
+ import { recoverWithoutProjection } from "../openCodeTurn.js";
12
9
  import { createOpenCodeToolBridge } from "../openCodeTools.js";
13
- import { validateConversationConfiguration, validateConnectionModel } from "../configuration.js";
10
+ import { validateConversationConfiguration } from "../configuration.js";
14
11
  import { createScopedConversationHandle } from "./scoped.js";
12
+ import { executeOpenCodeApplicationTool, openStandaloneOpenCodeConversation } from "./opencodeStandalone.js";
15
13
 
16
- const nativeMessageId = id => `msg_jskit_${hash(id).slice(0, 40)}`;
14
+ /** A runtime given no host runs OpenCode in the process's working directory, with local execution and the `opencode` command. */
15
+ const LOCAL_HOST = Object.freeze({});
17
16
 
18
- // Original standalone native-use validation and authored admission, shared by
19
- // explicitly supplied bound tools without transferring native process ownership.
20
- async function executeOpenCodeApplicationTool({ active, turn, target, sessionId, assertCurrent }, input, signal) {
21
- if (!active?.tools || input.sessionId !== sessionId ||
22
- [input.id, input.name, input.messageId].some(value => typeof value !== "string" || !value || value.length > 256)) {
23
- throw new Error("This OpenCode tool call does not belong to the active conversation.");
24
- }
25
- if (!turn?.abortController) {
26
- throw new Error("This OpenCode tool call does not belong to an admitted conversation.");
27
- }
28
- assertCurrent?.();
29
- const observed = AbortSignal.any([signal, turn.abortController.signal]);
30
- await turn.admission?.promise;
31
- observed.throwIfAborted();
32
- // Read back to an admitted input that precedes the native tool use.
33
- const messages = await readOpenCodeMessagesUntil(target.server.client, sessionId, rows => {
34
- const index = rows.findIndex(row => row.id === input.messageId);
35
- return index >= 0 && rows.slice(0, index).some(row => active.messageIds.has(row.id));
36
- }, { signal: observed });
37
- let message;
38
- let owner;
39
- for (const [nativeId, authored] of active.messageIds) {
40
- message = openCodeRowsForInput(messages, nativeId).find(message => message.id === input.messageId && message.type === "assistant");
41
- if (message) { owner = authored; break; }
42
- }
43
- const tool = message?.content.find(part => part.type === "tool" && part.callID === input.id);
44
- if (!tool || tool.tool !== input.name || !isDeepStrictEqual(tool.state?.input, input.input)) {
45
- throw new Error("OpenCode's application call does not match an admitted native tool use.");
46
- }
47
- if (!owner.committed) throw new Error("This OpenCode tool call has no durable authored admission.");
48
- await owner.committed;
49
- observed.throwIfAborted();
50
- assertCurrent?.(true, owner);
51
- try {
52
- return await active.tools.execute({ id: input.id, name: input.name, arguments: JSON.stringify(input.input) }, { signal: observed, messageId: owner.messageId });
53
- } catch (error) {
54
- if (!assertCurrent || assertCurrent(false, owner)) turn.abortController.abort(error);
55
- throw error;
56
- }
57
- }
58
-
59
- /** Uses the application's authorized API connection; native state stays server-side. */
60
- export function createOpenCodeConversationDriver({ connections, host = {}, limits = {} } = {}) {
17
+ /**
18
+ * Uses the application's authorized API connection; native state stays server-side.
19
+ * @param {{
20
+ * connections?: { resolve(request: { context: object, integrationId: string }): Promise<object> },
21
+ * host?: {
22
+ * env?: import("@jskit-ai/kernel/server/types").RuntimeEnv,
23
+ * workdir?: string,
24
+ * stateDirectory?: string,
25
+ * execution?: import("../localExecution.js").ConversationExecution,
26
+ * nativeTools?: boolean,
27
+ * conversation?: Function,
28
+ * opencode?: Function,
29
+ * commands?: { opencode?: string },
30
+ * limits?: object,
31
+ * commandWrapper?: object
32
+ * },
33
+ * limits?: Record<string, number>
34
+ * }} options
35
+ */
36
+ export function createOpenCodeConversationDriver({
37
+ connections,
38
+ host = LOCAL_HOST,
39
+ limits = {}
40
+ }) {
41
+ // readable-check: allow method-probe - input validation: a host that does not open its own conversations requires the application's AI connection resolver
61
42
  if (!host.conversation && typeof connections?.resolve !== "function") throw new TypeError("OpenCode conversations require an authorized AI connection resolver.");
62
43
  const workdir = path.resolve(host.workdir || process.cwd());
63
44
  const stateDirectory = path.resolve(host.stateDirectory || path.join(workdir, ".assistant", "native", "opencode"));
64
45
  const execution = host.execution || createLocalConversationExecution();
65
46
  const nativeTools = host.nativeTools === true;
47
+ if (nativeTools && !host.env) {
48
+ throw new TypeError("An OpenCode conversation's host requires the application's environment, such as process.env, for native tools.");
49
+ }
66
50
  const maximumOutput = limits.maxOutputCharacters ?? 64_000;
67
51
  if (!Number.isSafeInteger(maximumOutput) || maximumOutput < 1) throw new TypeError("Invalid OpenCode output limit.");
68
52
  if (limits.maxOutputTokens !== undefined && (!Number.isSafeInteger(limits.maxOutputTokens) || limits.maxOutputTokens < 1)) {
69
53
  throw new TypeError("Invalid OpenCode token limit.");
70
54
  }
71
- function prompt(input, configuration) {
72
- const text = `${input.text || ""}${input.attachmentManifest || ""}`;
73
- if (!configuration.outputSchema) return { text, content: input.content };
74
- const prepared = openCodeDetachedPrompt({ prompt: text, outputSchema: configuration.outputSchema });
75
- if (prepared.length > (limits.maxInputCharacters ?? 32_000)) throw new Error("OpenCode structured input exceeded the configured limit.");
76
- return { text: prepared, content: input.content };
77
- }
78
-
79
55
  function withNativeStorageTarget({ native, context }, operation) {
80
56
  const prepared = native.preparation.storage(context);
81
57
  return native.owner.withStorageProcess(async () =>
@@ -87,635 +63,321 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
87
63
  return native.owner.closeSession(prepared.sessionId, prepared.options, prepared.application);
88
64
  }
89
65
 
90
- return Object.freeze({
91
- toolDiscovery: true,
92
- attachmentTypes: ["text", "image", "localFile"],
93
- capabilities: Object.freeze({ streaming: true, cancellation: true, history: true,
94
- instructions: true, configuration: true, steering: true, goals: false, attachments: true, tools: true, nativeTools, structuredOutput: true }),
95
- validateConfiguration(configuration) {
96
- validateConversationConfiguration(configuration, { engine: "OpenCode", connections, connectionRequired: true,
97
- structuredOutput: true, maxOutputCharacters: maximumOutput });
98
- },
99
- async readState({ conversation, context, representation }) {
100
- const { owner, preparation } = conversation.native;
101
- const nativeResult = await owner.readSessionState(context, preparation.state);
102
- return conversation.read(context, representation, nativeResult);
103
- },
104
- inspectTemporaryActivity({ native }) {
105
- return native.owner.hasActiveTemporaryConversation(native.sessionId);
106
- },
107
- releaseRenewalPredecessorProcessExitProof({ native }) {
108
- return native.owner.releaseRenewalPredecessorProcessExitProof(native.preparation);
109
- },
110
- releaseRenewalSuccessorProcessExitProof({ native }) {
111
- return native.owner.releaseProcessExitProof(native.preparation.sessionId);
112
- },
113
- async generateRenewalHandover({ native, input, context }) {
114
- return native.owner.runPreparedRenewalTurn(await native.preparation.handover(input, context));
115
- },
116
- async seedRenewalHandover({ native, input, context }) {
117
- return native.owner.runPreparedRenewalTurn(await native.preparation.seed(input, context));
118
- },
119
- async ensureConversation({ native, context }) {
120
- return native.owner.ensurePreparedSessionReadiness(await native.preparation.readiness(context));
121
- },
122
- disposeNative,
123
- async interruptDetachedConversation({ native, input, context }) {
124
- return native.owner.stopPreparedConversation(await native.preparation.existing(input, context), input);
125
- },
126
- async deleteDetachedConversation({ native, input, context }) {
127
- const prepared = await native.preparation.existing(input, context, { operation: "delete" });
128
- return native.owner.deletePreparedConversation(prepared, input);
129
- },
130
- listConversationStorage(prepared) {
131
- const { binding, context } = prepared;
132
- return withNativeStorageTarget(prepared, async target =>
133
- (await target.server.client.listConversationsForDirectory(binding.workdir, { signal: context.signal }))
134
- .map((row) => ({ ...binding, conversationId: row.id })));
135
- },
136
- retireConversationHistory(prepared) {
137
- return withNativeStorageTarget(prepared, target => {
138
- // Native status is scoped to the saved project instance, not the control server.
139
- const client = openCodeServerForDirectory(target.server, prepared.binding.workdir).client;
140
- return retireOpenCodeConversationHistory(target.server.client, client, prepared.binding, prepared.context);
66
+ function validateConfiguration(configuration) {
67
+ validateConversationConfiguration(configuration, { engine: "OpenCode", connections, connectionRequired: true,
68
+ structuredOutput: true, maxOutputCharacters: maximumOutput });
69
+ }
70
+
71
+ async function readState({ conversation, context, representation }) {
72
+ const { owner, preparation } = conversation.native;
73
+ const nativeResult = await owner.readSessionState(context, preparation.state);
74
+ return conversation.read(context, representation, nativeResult);
75
+ }
76
+
77
+ function inspectTemporaryActivity({ native }) {
78
+ return native.owner.hasActiveTemporaryConversation(native.sessionId);
79
+ }
80
+
81
+ function releaseRenewalPredecessorProcessExitProof({ native }) {
82
+ return native.owner.releaseRenewalPredecessorProcessExitProof(native.preparation);
83
+ }
84
+
85
+ function releaseRenewalSuccessorProcessExitProof({ native }) {
86
+ return native.owner.releaseProcessExitProof(native.preparation.sessionId);
87
+ }
88
+
89
+ async function generateRenewalHandover({ native, input, context }) {
90
+ return native.owner.runPreparedRenewalTurn(await native.preparation.handover(input, context));
91
+ }
92
+
93
+ async function seedRenewalHandover({ native, input, context }) {
94
+ return native.owner.runPreparedRenewalTurn(await native.preparation.seed(input, context));
95
+ }
96
+
97
+ async function ensureConversation({ native, context }) {
98
+ return native.owner.ensurePreparedSessionReadiness(await native.preparation.readiness(context));
99
+ }
100
+
101
+ async function interruptDetachedConversation({ native, input, context }) {
102
+ return native.owner.stopPreparedConversation(await native.preparation.existing(input, context), input);
103
+ }
104
+
105
+ async function deleteDetachedConversation({ native, input, context }) {
106
+ const prepared = await native.preparation.existing(input, context, { operation: "delete" });
107
+ return native.owner.deletePreparedConversation(prepared, input);
108
+ }
109
+
110
+ function listConversationStorage(prepared) {
111
+ const { binding, context } = prepared;
112
+ return withNativeStorageTarget(prepared, async target =>
113
+ (await target.server.client.listConversationsForDirectory(binding.workdir, { signal: context.signal }))
114
+ .map((row) => ({ ...binding, conversationId: row.id })));
115
+ }
116
+
117
+ function retireConversationHistory(prepared) {
118
+ return withNativeStorageTarget(prepared, target => {
119
+ // Native status is scoped to the saved project instance, not the control server.
120
+ const client = openCodeServerForDirectory(target.server, prepared.binding.workdir).client;
121
+ return retireOpenCodeConversationHistory(target.server.client, client, prepared.binding, prepared.context);
122
+ });
123
+ }
124
+
125
+ function closeProject({ native, input }) {
126
+ return native.owner.closeProject(input, native.preparation.projectCleanup());
127
+ }
128
+
129
+ function invalidateRuntimes({ native, input }) {
130
+ return native.owner.invalidateRuntimes(input, native.preparation);
131
+ }
132
+
133
+ function reconcileSessions({ native, sessions, options }) {
134
+ return native.owner.reconcileSessions(sessions, options, native.preparation);
135
+ }
136
+
137
+ async function createConversation({ native, input, context }) {
138
+ return native.owner.createPreparedConversation(await native.preparation.creation(input, context));
139
+ }
140
+
141
+ async function runDetachedConversation({ native, input, options }) {
142
+ return native.owner.runPreparedConversationTurn(await native.preparation.turn(input, options));
143
+ }
144
+
145
+ async function createBinding() {
146
+ const directory = path.join(stateDirectory, randomUUID());
147
+ await mkdir(directory, { recursive: true, mode: 0o700 });
148
+ return { sessionId: "", workdir: await realpath(workdir), directory, executionId: "", processDirectory: "" };
149
+ }
150
+
151
+ async function open({ binding, writeBinding, onFailure: reportFailure, conversation }) {
152
+ if (conversation?.native?.scoped) {
153
+ const supplied = conversation.native;
154
+ const { owner, preparation } = supplied;
155
+ const { conversationId, context: openingContext } = supplied.scoped;
156
+ return createScopedConversationHandle({ conversationId,
157
+ sendNative: async (input, context) => owner.runPreparedConversationTurn(await preparation.turn(input, context)),
158
+ readNative: async (input, context) => owner.readPreparedConversation(await preparation.existing(input, context), input),
159
+ waitNative: async (input, context) => owner.waitPreparedConversationTurn(await preparation.existing(input, context), input),
160
+ cancel: async (input, context) => owner.stopPreparedConversation(await preparation.existing(input, context), input),
161
+ deleteConversation: async (input, context) => owner.deletePreparedConversation(
162
+ await preparation.existing(input, context, { operation: "delete" }), input),
163
+ disposeOpening: () => disposeNative({ native: supplied, options: openingContext })
141
164
  });
142
- },
143
- closeProject({ native, input }) {
144
- return native.owner.closeProject(input, native.preparation.projectCleanup());
145
- },
146
- invalidateRuntimes({ native, input }) {
147
- return native.owner.invalidateRuntimes(input, native.preparation);
148
- },
149
- reconcileSessions({ native, sessions, options }) {
150
- return native.owner.reconcileSessions(sessions, options, native.preparation);
151
- },
152
- async createConversation({ native, input, context }) {
153
- return native.owner.createPreparedConversation(await native.preparation.creation(input, context));
154
- },
155
- async runDetachedConversation({ native, input, options }) {
156
- return native.owner.runPreparedConversationTurn(await native.preparation.turn(input, options));
157
- },
158
- async createBinding() {
159
- const directory = path.join(stateDirectory, randomUUID());
160
- await mkdir(directory, { recursive: true, mode: 0o700 });
161
- return { sessionId: "", workdir: await realpath(workdir), directory, executionId: "", processDirectory: "" };
162
- },
163
- async open({ binding, writeBinding, onFailure: reportFailure, conversation }) {
164
- if (conversation?.native?.scoped) {
165
- const supplied = conversation.native;
166
- const { owner, preparation } = supplied;
167
- const { conversationId, context: openingContext } = supplied.scoped;
168
- return createScopedConversationHandle({ conversationId,
169
- sendNative: async (input, context) => owner.runPreparedConversationTurn(await preparation.turn(input, context)),
170
- readNative: async (input, context) => owner.readPreparedConversation(await preparation.existing(input, context), input),
171
- waitNative: async (input, context) => owner.waitPreparedConversationTurn(await preparation.existing(input, context), input),
172
- cancel: async (input, context) => owner.stopPreparedConversation(await preparation.existing(input, context), input),
173
- deleteConversation: async (input, context) => owner.deletePreparedConversation(
174
- await preparation.existing(input, context, { operation: "delete" }), input),
175
- disposeOpening: () => disposeNative({ native: supplied, options: openingContext })
176
- });
177
- }
178
- if (conversation?.native) {
179
- const supplied = conversation.native;
180
- let current;
181
- let disposed = false;
182
- let cleanupFailure;
183
- let toolBridge;
184
- async function interrupt(context) {
185
- try {
186
- const acquired = await supplied.acquire(context || current?.context);
187
- const value = await supplied.owner.interruptPreparedTurn(await supplied.preparation.interruption(acquired.options));
188
- if (toolBridge) { await toolBridge.close(); toolBridge = null; }
189
- if (cleanupFailure) {
190
- await reportFailure(cleanupFailure, { recovered: true });
191
- cleanupFailure = null;
192
- }
193
- return value;
194
- } catch (error) {
195
- error.cleanupFailed = true;
196
- cleanupFailure = error;
197
- current?.failure.reject(error);
198
- await reportFailure(error);
199
- throw error;
200
- }
201
- }
202
- async function send(command) {
203
- const { input, context, signal, beforeDispatch, accept } = command;
204
- signal.throwIfAborted();
205
- const acquired = await supplied.acquire(context);
206
- const active = current;
207
- if (active?.tools) signal.throwIfAborted();
208
- if (active?.tools && !toolBridge) {
209
- toolBridge = await createOpenCodeToolBridge({ schemas: active.tools.schemas,
210
- maxArgumentBytes: active.tools.maximumArgumentBytes, execute(input, signal) {
211
- const turn = supplied.owner.turns.get(acquired.key);
212
- const target = supplied.owner.processes.get(acquired.key);
213
- const execute = () => executeOpenCodeApplicationTool({ active: current, turn, target,
214
- sessionId: target?.upstreamSessionId,
215
- assertCurrent(throwFailure = true, authored) {
216
- const owned = !disposed && current === active && active.turn === turn && turn?.active &&
217
- (!authored || active.messageIds.get(turn.inputMessageId) === authored) &&
218
- supplied.owner.turns.get(acquired.key) === turn && supplied.owner.processes.get(acquired.key) === target;
219
- if (!owned && throwFailure) throw new Error("This OpenCode tool call belongs to a retired native turn.");
220
- return owned;
221
- }
222
- }, input, signal);
223
- return typeof acquired.runInContext === "function" ? acquired.runInContext(execute) : execute();
224
- }
225
- });
226
- }
227
- const options = active?.tools ? { ...acquired.options, applicationTools: toolBridge.configuration } : acquired.options;
228
- let prepared;
229
- const dispatch = active?.tools ? async identity => {
230
- await beforeDispatch(identity);
231
- signal.throwIfAborted();
232
- active.turn = supplied.owner.turns.get(acquired.key);
233
- active.messageIds.set(prepared.input.id, { messageId: input.messageId, committed: command.deliveryCommitted });
234
- } : beforeDispatch;
235
- prepared = await supplied.preparation.message({ ...input.nativeMessage, onPromptSending: dispatch }, options);
236
- if (prepared.application?.commit) {
237
- const application = prepared.application;
238
- prepared.application = { ...application, async commit(admitted) {
239
- command.onNativeReceipt?.();
240
- const result = await application.commit(admitted);
241
- command.onReceiptPersisted?.();
242
- return result;
243
- } };
244
- }
245
- if (active?.tools && prepared.application?.monitor) {
246
- const monitor = prepared.application.monitor;
247
- const create = monitor.create;
248
- prepared.application = { ...prepared.application, monitor: { ...monitor,
249
- create(...args) {
250
- const projection = create(...args);
251
- const finalResponse = projection.finalResponse;
252
- return { ...projection, finalResponse: { ...finalResponse,
253
- async beforeRecovery(completion) {
254
- active.messageIds.set(finalResponse.recoveryMessageId, active.messageIds.get(completion.inputMessageId));
255
- await finalResponse.beforeRecovery?.(completion);
256
- }
257
- } };
258
- }
259
- } };
260
- }
261
- const value = await supplied.owner.sendPreparedMessage(prepared, options);
262
- const delivered = { value, completion: value.conversationTurn ? supplied.owner.monitors.get(acquired.key) : null };
263
- if (value.delivered === true && value.conversationTurn) {
264
- if (current) current.delivery = delivered;
265
- await accept({ nativeResult: { value }, conversationTurn: value.conversationTurn, nativeTurnId: value.turn?.id });
266
- } else if (active?.tools) active.messageIds.delete(prepared.input.id);
267
- return delivered;
268
- }
269
- return Object.freeze({
270
- publishNative: event => conversation.publish(event),
271
- async inspectAdmission({ context, representation, ...input }) {
272
- const acquired = await supplied.acquire(context);
273
- const result = await supplied.owner.inspectPreparedMessage(await supplied.preparation.inspection(input, acquired.options));
274
- return representation === "native" ? result : { accepted: result.admission === "accepted" };
275
- },
276
- async run(command) {
277
- if (disposed || current) throw new Error("This OpenCode conversation is closed or already working.");
278
- const active = { context: command.context, failure: Promise.withResolvers(),
279
- ...(command.tools ? { tools: command.tools, messageIds: new Map() } : {}) };
280
- let failure;
281
- active.failure.promise.catch(error => { failure = error; });
282
- current = active;
283
- let cancellation;
284
- const abort = () => {
285
- cancellation ||= interrupt(command.context).then(() => { throw command.signal.reason; });
286
- cancellation.catch(active.failure.reject);
287
- };
288
- command.signal.addEventListener("abort", abort, { once: true });
289
- try {
290
- let delivered;
291
- let dispatchFailure;
292
- try { delivered = await send(command); }
293
- catch (error) { dispatchFailure = error; }
294
- for (;;) {
295
- const settling = active.delivery;
296
- let result;
297
- let problem = dispatchFailure || failure;
298
- if (!problem && settling?.completion) {
299
- try { result = await Promise.race([settling.completion, active.failure.promise]); }
300
- catch (error) { problem = error; }
301
- }
302
- if (command.finishIfCurrent && !await command.finishIfCurrent(() => active.delivery === settling)) {
303
- // The original sender admitted another invocation and returned
304
- // its exact monitor. Keep the same completion and Stop owner.
305
- continue;
306
- }
307
- problem = dispatchFailure || failure || problem;
308
- if (!problem && result?.error) {
309
- problem = Object.assign(new Error(result.error), { status: result.status });
310
- if (result.active && result.status === "observation_lost") {
311
- problem.cleanupFailed = true;
312
- cleanupFailure = problem;
313
- await reportFailure(problem);
314
- }
315
- }
316
- if (problem) throw problem;
317
- return { value: delivered.value };
318
- }
319
- } finally {
320
- command.signal.removeEventListener("abort", abort);
321
- await cancellation?.catch(() => {});
322
- if (toolBridge) { await toolBridge.close(); toolBridge = null; }
323
- if (current === active) current = null;
324
- }
325
- },
326
- async steer(command) { return { value: (await send(command)).value }; },
327
- async cancel({ context, representation } = {}) {
328
- const value = await interrupt(context);
329
- return representation === "native" ? { value } : value;
330
- },
331
- async dispose(options) {
332
- try {
333
- const result = await disposeNative({ native: supplied, options });
334
- if (result?.ok === false) throw Object.assign(new Error(result.error || "OpenCode cleanup could not be confirmed."), result);
335
- if (toolBridge) { await toolBridge.close(); toolBridge = null; }
336
- disposed = true;
337
- return result;
338
- } catch (error) {
339
- error.cleanupFailed = true;
340
- current?.failure.reject(error);
341
- throw error;
342
- }
343
- }
344
- });
345
- }
346
- if (!binding || typeof binding.sessionId !== "string" || binding.workdir !== await realpath(workdir) ||
347
- path.dirname(binding.directory || "") !== stateDirectory) {
348
- throw new Error("This OpenCode conversation belongs to another working directory or native state directory. Restore its original host configuration.");
349
- }
350
- let native;
351
- let toolBridge;
352
- let facilities;
353
- let processVariants = {};
165
+ }
166
+ if (conversation?.native) {
167
+ const supplied = conversation.native;
354
168
  let current;
355
- let stopping;
356
169
  let disposed = false;
357
- async function updateBinding(patch) {
358
- const next = { ...binding, ...patch };
359
- await writeBinding(next);
360
- binding = next;
361
- }
362
- async function stopRecordedExecution() {
363
- const proof = await execution.stop(binding.executionId);
364
- if (!proof?.scopeEmpty) throw new Error("OpenCode process cleanup could not be confirmed. Restore its execution host before continuing.");
365
- if (binding.processDirectory) {
366
- if (path.dirname(binding.processDirectory) !== (binding.runtimeDirectory || stateDirectory) || !path.basename(binding.processDirectory).startsWith("process-")) {
367
- throw new Error("OpenCode's private process directory does not belong to this host.");
368
- }
369
- await rm(binding.processDirectory, { recursive: true, force: true });
370
- }
371
- await updateBinding({ executionId: "", processDirectory: "" });
372
- }
373
- async function stop() {
374
- if (stopping) return stopping;
375
- stopping = (async () => {
376
- await toolBridge?.close();
377
- toolBridge = null;
378
- if (facilities?.runtime.cleanupPending) {
379
- await facilities.runtime.stop("opencode-startup-cleanup");
380
- await updateBinding({ executionId: "", processDirectory: "" });
381
- }
382
- if (native) {
383
- await facilities.runtime.releaseTarget(native, {
384
- onRemoved: () => facilities.runtime.writeBindings(facilities.registryPath, binding.directory, [])
385
- });
386
- facilities.runtime.turns.delete(binding.directory);
387
- await updateBinding({ executionId: "", processDirectory: "" });
388
- native = null;
389
- } else if (binding.executionId && facilities && !host.opencode &&
390
- !facilities.runtime.current && !facilities.runtime.processStarts.size) {
391
- // Only our unfinished standalone startup belongs to this handle.
392
- // A saved shared-server reference is not authority to stop a peer's
393
- // service before this conversation has acquired its owner.
394
- await stopRecordedExecution();
170
+ let cleanupFailure;
171
+ let toolBridge;
172
+ async function interrupt(context) {
173
+ try {
174
+ const acquired = await supplied.acquire(context || current?.context);
175
+ const value = await supplied.owner.interruptPreparedTurn(await supplied.preparation.interruption(acquired.options));
176
+ if (toolBridge) { await toolBridge.close(); toolBridge = null; }
177
+ if (cleanupFailure) {
178
+ await reportFailure(cleanupFailure, { recovered: true });
179
+ cleanupFailure = null;
395
180
  }
396
- if (current?.finished) current = null;
397
- })().catch(error => {
181
+ return value;
182
+ } catch (error) {
398
183
  error.cleanupFailed = true;
399
- if (error.code === "assistant_opencode_stop_unverified") {
400
- error.message = "OpenCode process cleanup could not be confirmed. Restore its execution host before continuing.";
401
- }
184
+ cleanupFailure = error;
185
+ current?.failure.reject(error);
186
+ await reportFailure(error);
402
187
  throw error;
403
- }).finally(() => { stopping = null; });
404
- return stopping;
405
- }
406
- async function resolvePreparation(configuration, context, signal, requireBound = false, receiptBinding, authoredPreparation = false) {
407
- const connection = await connections.resolve({ context, integrationId: configuration.integrationId });
408
- signal.throwIfAborted();
409
- validateConnectionModel(configuration, connection);
410
- const identity = hash([connection.providerId, connection.baseURL || "", connection.apiKey]);
411
- if (requireBound && !receiptBinding?.accountIdentity) throw new Error("The completed OpenCode response has no saved account fingerprint.");
412
- if (requireBound && (binding.accountIdentity !== receiptBinding.accountIdentity || identity !== receiptBinding.accountIdentity)) {
413
- throw new Error("The completed OpenCode response belongs to a different account binding.");
414
188
  }
415
- // The existing managed host reauthorizes its own connection on every preparation.
416
- // A key change can rotate an authored run only inside the same native storage/runtime scope.
417
- const preparedFacilities = host.opencode ? await host.opencode({ connection, context }) : facilities;
189
+ }
190
+ async function send(command) {
191
+ const {
192
+ input,
193
+ context,
194
+ signal,
195
+ beforeDispatch,
196
+ accept,
197
+ onNativeReceipt = ignoreReceipt,
198
+ onReceiptPersisted = ignoreReceipt
199
+ } = command;
418
200
  signal.throwIfAborted();
419
- if (host.opencode && (
420
- binding.databasePath && binding.databasePath !== preparedFacilities.databasePath ||
421
- binding.runtimeDirectory && binding.runtimeDirectory !== preparedFacilities.runtimeDirectory ||
422
- facilities && facilities.runtime !== preparedFacilities.runtime
423
- )) {
424
- throw new Error("This OpenCode conversation belongs to another native history store or runtime. Restore its original host configuration.");
425
- }
426
- const allowCredentialRefresh = authoredPreparation && host.opencode && preparedFacilities.allowCredentialRefresh === true;
427
- if (binding.accountIdentity && identity !== binding.accountIdentity && !allowCredentialRefresh) {
428
- throw new Error("This conversation belongs to another OpenCode account. Restore that connection or start a new conversation.");
429
- }
430
- if (binding.accountIdentity !== identity) await updateBinding({ accountIdentity: identity });
431
- const model = { providerID: connection.providerId, id: connection.model };
432
- const agent = openCodeConversationAgent({ nativeTools, tools: Boolean(current?.tools) });
433
- if (current) current.agent = agent;
434
- if (binding.sessionId && !binding.databasePath) {
435
- 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.");
436
- }
437
- if (!facilities || host.opencode) {
438
- if (host.opencode) facilities = preparedFacilities;
439
- else {
440
- const runtimeDirectory = path.join(stateDirectory, "shared", hash([identity, host.commands?.opencode || "opencode"]));
441
- const selected = { modelProviderId: connection.providerId, canonicalUrl: connection.baseURL || "",
442
- endpointCode: connection.sdkPackage || "", fingerprint: identity };
443
- facilities = { runtime: createOpenCodeSharedRuntime({ scope: runtimeDirectory }), runtimeDirectory,
444
- registryPath: path.join(runtimeDirectory, "session-environments.json"),
445
- databasePath: path.join(runtimeDirectory, "opencode.db"), selected,
446
- async start({ signal, connection }) {
447
- if (binding.executionId && !facilities.runtime.processes.has(binding.directory)) await stopRecordedExecution();
448
- const starting = new AbortController();
449
- const abortUnusedStartup = () => {
450
- if (facilities.runtime.processStarts.size === 1 && facilities.runtime.processes.size === 0) starting.abort(signal.reason);
451
- };
452
- signal.addEventListener("abort", abortUnusedStartup, { once: true });
453
- if (signal.aborted) abortUnusedStartup();
454
- try {
455
- const server = await createOpenCodeConversationServer({ command: host.commands?.opencode || "opencode",
456
- execution, env: host.env || process.env, workdir: binding.workdir, stateDirectory: runtimeDirectory,
457
- databasePath: facilities.databasePath, sessionEnvironmentRegistry: facilities.registryPath, connection,
458
- maxOutputTokens: limits.maxOutputTokens, executionLimits: host.limits, signal: starting.signal,
459
- onStarted: (executionId, processDirectory) => updateBinding({ executionId, processDirectory }),
460
- async onFailure(error) {
461
- for (const target of facilities.runtime.processes.values()) target.abortController.abort(error);
462
- try { await facilities.runtime.stop("opencode-process-exited"); }
463
- catch (cleanupError) { await reportFailure(cleanupError); }
464
- }
465
- });
466
- return { server, connections: [selected] };
467
- } finally { signal.removeEventListener("abort", abortUnusedStartup); }
468
- }
469
- };
470
- }
471
- if (binding.databasePath && binding.databasePath !== facilities.databasePath) {
472
- throw new Error("This OpenCode conversation belongs to another native history store. Restore its original host configuration.");
473
- }
474
- await updateBinding({ databasePath: facilities.databasePath, runtimeDirectory: facilities.runtimeDirectory });
475
- }
476
- if (!toolBridge && current?.tools) {
477
- toolBridge = await createOpenCodeToolBridge({
478
- schemas: current.tools.schemas, maxArgumentBytes: current.tools.maximumArgumentBytes,
479
- async execute(input, signal) {
480
- return executeOpenCodeApplicationTool({ active: current,
481
- turn: facilities.runtime.turns.get(binding.directory), target: native, sessionId: binding.sessionId }, input, signal);
201
+ const acquired = await supplied.acquire(context);
202
+ const active = current;
203
+ if (active?.tools) signal.throwIfAborted();
204
+ if (active?.tools && !toolBridge) {
205
+ toolBridge = await createOpenCodeToolBridge({ schemas: active.tools.schemas,
206
+ maxArgumentBytes: active.tools.maximumArgumentBytes, execute(input, signal) {
207
+ const turn = supplied.owner.turns.get(acquired.key);
208
+ const target = supplied.owner.processes.get(acquired.key);
209
+ const execute = () => executeOpenCodeApplicationTool({ active: current, turn, target,
210
+ sessionId: target?.upstreamSessionId,
211
+ assertCurrent(throwFailure = true, authored) {
212
+ const owned = !disposed && current === active && active.turn === turn && turn?.active &&
213
+ (!authored || active.messageIds.get(turn.inputMessageId) === authored) &&
214
+ supplied.owner.turns.get(acquired.key) === turn && supplied.owner.processes.get(acquired.key) === target;
215
+ if (!owned && throwFailure) throw new Error("This OpenCode tool call belongs to a retired native turn.");
216
+ return owned;
217
+ }
218
+ }, input, signal);
219
+ // readable-check: allow method-probe - optional operation: an acquisition that runs inside its request's context supplies runInContext
220
+ return typeof acquired.runInContext === "function" ? acquired.runInContext(execute) : execute();
482
221
  }
483
222
  });
484
223
  }
485
- return { configuration, signal, model, agent, connection };
486
- }
487
-
488
- async function prepare({ configuration, signal, model, agent, connection }) {
489
- try {
490
- const start = host.opencode
491
- ? async () => facilities.runtime.startPreparedProcess(await facilities.prepareServer())
492
- : () => facilities.start({ signal, connection });
493
- // A managed host's saved connections reach its running server in place.
494
- const savedConnections = host.opencode ? async () => (await facilities.prepareServer()).connections : null;
495
- if (!native) {
496
- native = await facilities.runtime.acquire(binding.directory, async () => {
497
- const shared = await facilities.runtime.ensure(facilities.selected, start, savedConnections);
498
- return { key: binding.directory, workdir: binding.workdir, upstreamSessionId: binding.sessionId,
499
- abortController: new AbortController(), server: openCodeServerForDirectory(shared.server, binding.workdir) };
500
- });
501
- } else await facilities.runtime.ensure(facilities.selected, start, savedConnections);
224
+ const options = active?.tools ? { ...acquired.options, applicationTools: toolBridge.configuration } : acquired.options;
225
+ let prepared;
226
+ const dispatch = active?.tools ? async identity => {
227
+ await beforeDispatch(identity);
502
228
  signal.throwIfAborted();
503
- await updateBinding({ executionId: native.server.executionId, processDirectory: native.server.privateRoot || "" });
504
- const catalogue = await native.server.client.providers({ signal });
505
- const selected = catalogue.all.find(provider => provider.id === connection.providerId)?.models?.[connection.model];
506
- if (selected?.id !== connection.model) throw new Error("OpenCode could not load the configured model definition. Check the selected integration before sending.");
507
- processVariants = selected.variants || {};
508
- } catch (error) {
509
- if (error.stopProof?.scopeEmpty) await updateBinding({ executionId: "", processDirectory: "" });
510
- throw error;
511
- }
512
- if (configuration.effort) {
513
- if (!Object.hasOwn(processVariants, configuration.effort)) throw new Error("The selected OpenCode model does not support this reasoning effort.");
514
- model.variant = configuration.effort;
229
+ active.turn = supplied.owner.turns.get(acquired.key);
230
+ active.messageIds.set(prepared.input.id, { messageId: input.messageId, committed: command.deliveryCommitted });
231
+ } : beforeDispatch;
232
+ prepared = await supplied.preparation.message({ ...input.nativeMessage, onPromptSending: dispatch }, options);
233
+ if (prepared.application?.commit) {
234
+ const application = prepared.application;
235
+ prepared.application = { ...application, async commit(admitted) {
236
+ onNativeReceipt();
237
+ const result = await application.commit(admitted);
238
+ onReceiptPersisted();
239
+ return result;
240
+ } };
515
241
  }
516
- const retainedId = binding.sessionId;
517
- if (retainedId && (await native.server.client.sessionStatus(retainedId, { signal })).type !== "idle") {
518
- throw new Error("The native OpenCode conversation is still working. Stop or inspect it before sending another message.");
519
- }
520
- const session = {
521
- selection: { agentId: agent, modelId: model.id, modelProviderId: model.providerID, variantId: model.variant || "" },
522
- model, workdir: binding.workdir, signal,
523
- invalidIdentity: () => new Error("OpenCode did not acknowledge a native conversation."),
524
- identity: {
525
- async write(nativeId) {
526
- if (retainedId && nativeId !== retainedId) throw new Error("The saved OpenCode conversation is unavailable. Restore its native history before continuing.");
527
- if (binding.sessionId !== nativeId) await updateBinding({ sessionId: nativeId });
528
- },
529
- publish(nativeId) {
530
- return facilities.runtime.writeBindings(facilities.registryPath, binding.directory, [{
531
- upstreamSessionId: nativeId, workdir: binding.workdir, modelProviderId: connection.providerId,
532
- ...(nativeTools ? { env: host.env || process.env } : {}),
533
- conversation: { systemPrompt: configuration.systemPrompt, nativeTools,
534
- commandWrapper: host.commandWrapper, tools: current?.tools ? toolBridge?.configuration : undefined }
535
- }]);
536
- }
537
- }
538
- };
539
- return { target: native, session, model };
540
- }
541
-
542
- function messageMonitor(active, command) {
543
- return {
544
- prepare() { return { fields: {} }; },
545
- create(target, turn, _metadata, options) {
546
- const abort = () => turn.abortController.abort(command.signal.reason);
547
- command.signal.addEventListener("abort", abort, { once: true });
548
- if (command.signal.aborted) abort();
549
- const recoveryMessageId = nativeMessageId(`${command.input.messageId}:final-response`);
550
- return {
551
- observe(_turn, signal) {
552
- return observeOpenCodeEvents(target.server.client, target.upstreamSessionId, {
553
- abortController: turn.abortController, signal, eventStartedAt: turn.eventStartedAt,
554
- async onEvent(summary, event) {
555
- if (summary.type === "session.status") {
556
- const status = event.data.properties?.status?.type;
557
- if (status === "retry" || status === "busy") {
558
- await turn.admission?.promise;
559
- await active.deliveryCommitted;
560
- signal.throwIfAborted();
561
- await active.onEvent({ type: "phase", phase: status === "retry" ? "retrying" : "working" });
562
- }
563
- }
564
- }
565
- });
566
- },
567
- eventReady: options.eventReady,
568
- readiness: { timeoutMs: limits.admissionTimeoutMs || 120_000,
569
- timeoutError: new Error("OpenCode's event connection was not ready before the admission deadline.") },
570
- finalResponse: {
571
- agent: active.agent, model: active.prepared.model, recoveryMessageId,
572
- beforeRecovery() {
573
- active.messageIds.set(recoveryMessageId, active.messageIds.get(turn.inputMessageId));
574
- }
575
- },
576
- async writeRun(observed, state) {
577
- if (state === "active") await active.onEvent({ type: "phase", phase: observed.phase || "working" });
578
- },
579
- async projectMessages(messages, { inputMessageId }) {
580
- await active.deliveryCommitted;
581
- if (inputMessageId !== turn.inputMessageId) return { failure: "", providerApiFailure: false };
582
- return facilities.runtime.projectConversationMessages(messages, inputMessageId, {
583
- maximumOutput, outputSchema: active.configuration.outputSchema,
584
- previous: active.previous, onMessage: active.onMessage
585
- });
586
- },
587
- completeResult(_turn, { failure }) { return failure; },
588
- onRetired() { command.signal.removeEventListener("abort", abort); }
589
- };
590
- },
591
- onError(error) { active.failure ||= error; }
592
- };
593
- }
594
-
595
- async function send(command) {
596
- const active = current;
597
- const id = nativeMessageId(command.input.messageId);
598
- const previousOwner = active.messageIds.get(id);
599
- let accepted = false;
600
- let value;
601
- try {
602
- value = await facilities.runtime.sendMessage(binding.directory, {
603
- id, threadId: binding.sessionId, workdir: binding.workdir
604
- }, { promptTimeoutMs: limits.admissionTimeoutMs || 30_000 }, {
605
- async writeRun() {},
606
- async prepare({ overwrite }) {
607
- const signal = active.prepared
608
- ? AbortSignal.any([command.signal, AbortSignal.timeout(120_000)])
609
- : active.resolved.signal;
610
- if (overwrite || !active.prepared) active.prepared = await prepare({ ...active.resolved, signal });
611
- return { ...active.prepared, session: { ...active.prepared.session, signal } };
612
- },
613
- prompt() {
614
- native.abortController.signal.throwIfAborted();
615
- return {
616
- agent: active.agent, model: active.prepared.model,
617
- prompt: prompt(command.input, active.configuration), attachments: command.input.localFiles,
618
- authorizeAttachments: true,
619
- async beforeDispatch(threadId) {
620
- await command.beforeDispatch({ threadId });
621
- command.signal.throwIfAborted();
622
- active.messageIds.set(id, {
623
- messageId: command.input.messageId, committed: command.deliveryCommitted
624
- });
242
+ if (active?.tools && prepared.application?.monitor) {
243
+ const monitor = prepared.application.monitor;
244
+ const create = monitor.create;
245
+ prepared.application = { ...prepared.application, monitor: { ...monitor,
246
+ create(...args) {
247
+ const projection = create(...args);
248
+ const finalResponse = projection.finalResponse;
249
+ return { ...projection, finalResponse: { ...finalResponse,
250
+ async beforeRecovery(completion) {
251
+ active.messageIds.set(finalResponse.recoveryMessageId, active.messageIds.get(completion.inputMessageId));
252
+ const { beforeRecovery = recoverWithoutProjection } = finalResponse;
253
+ await beforeRecovery(completion);
625
254
  }
626
- };
627
- },
628
- async commit() {
629
- command.onNativeReceipt?.();
630
- await command.accept();
631
- command.onReceiptPersisted?.();
632
- active.deliveryCommitted = command.deliveryCommitted;
633
- accepted = true;
634
- },
635
- monitor: messageMonitor(active, command)
636
- });
637
- if (value.failure) throw value.failure.error;
638
- if (!value.delivered) throw new Error("OpenCode did not acknowledge the message.");
639
- } catch (error) {
640
- const turn = facilities.runtime.turns.get(binding.directory);
641
- const attempted = value?.failure ? value.failure.attempted : turn?.promptAttempted === true;
642
- if (attempted && !accepted && !(error.statusCode >= 400 && error.statusCode < 500 && error.statusCode !== 408)) {
643
- error.delivery = "uncertain";
644
- turn?.abortController?.abort(error);
645
- // Join the same owner's recovery before releasing its process target.
646
- await facilities.runtime.monitors.get(binding.directory);
647
- } else if (previousOwner) active.messageIds.set(id, previousOwner);
648
- else active.messageIds.delete(id);
649
- if (turn?.active && turn.observationError) error.cleanupFailed = true;
650
- throw error;
255
+ } };
256
+ }
257
+ } };
651
258
  }
652
- active.delivery = { turn: facilities.runtime.turns.get(binding.directory),
653
- completion: facilities.runtime.monitors.get(binding.directory) };
654
- return value;
259
+ const value = await supplied.owner.sendPreparedMessage(prepared, options);
260
+ const delivered = { value, completion: value.conversationTurn ? supplied.owner.monitors.get(acquired.key) : null };
261
+ if (value.delivered === true && value.conversationTurn) {
262
+ if (current) current.delivery = delivered;
263
+ await accept({ nativeResult: { value }, conversationTurn: value.conversationTurn, nativeTurnId: value.turn?.id });
264
+ } else if (active?.tools) active.messageIds.delete(prepared.input.id);
265
+ return delivered;
655
266
  }
656
-
657
267
  return Object.freeze({
658
- async inspectAdmission({ messageId, threadId, configuration, context, verifyAccount = false, receiptBinding }) {
659
- if (verifyAccount === true && (!receiptBinding || threadId !== binding.sessionId || receiptBinding.sessionId !== binding.sessionId ||
660
- receiptBinding.workdir !== binding.workdir || receiptBinding.directory !== binding.directory ||
661
- receiptBinding.databasePath !== binding.databasePath || receiptBinding.runtimeDirectory !== binding.runtimeDirectory)) {
662
- throw new Error("The completed OpenCode response belongs to a different native conversation or host scope.");
663
- }
664
- if (!binding.sessionId) return { accepted: false, messages: [] };
665
- const prepared = await prepare(await resolvePreparation(configuration, context, AbortSignal.timeout(120_000), verifyAccount === true, receiptBinding));
666
- await ensureOpenCodeSession(prepared.target, prepared.session);
667
- const id = nativeMessageId(messageId);
668
- const { accepted = false, messages = [] } = await inspectOpenCodeMessageAdmission(native.server.client, binding.sessionId, id) || {};
669
- return { accepted,
670
- messages: openCodeRowsForInput(messages, id).filter(message => message.type === "assistant" && !message.summary)
671
- .map(message => ({ id: message.id, outputId: `${message.id}:assistant`, role: "assistant",
672
- text: configuration.outputSchema ? openCodeStructuredOutput(openCodeAssistantMessageText(message)) : openCodeAssistantMessageText(message) })) };
673
- },
674
- steer(command) {
675
- if (!current || current.finished) throw new Error("OpenCode has no active turn to steer.");
676
- return send(command);
268
+ publishNative: event => conversation.publish(event),
269
+ async inspectAdmission({ context, representation, ...input }) {
270
+ const acquired = await supplied.acquire(context);
271
+ const result = await supplied.owner.inspectPreparedMessage(await supplied.preparation.inspection(input, acquired.options));
272
+ return representation === "native" ? result : { accepted: result.admission === "accepted" };
677
273
  },
678
274
  async run(command) {
679
275
  if (disposed || current) throw new Error("This OpenCode conversation is closed or already working.");
680
- const active = current = { tools: command.tools, configuration: command.configuration,
681
- onMessage: command.onMessage, onEvent: command.onEvent, messageIds: new Map(), previous: new Map() };
682
- let problem;
276
+ const active = { context: command.context, failure: Promise.withResolvers(),
277
+ ...(command.tools ? { tools: command.tools, messageIds: new Map() } : {}) };
278
+ let failure;
279
+ active.failure.promise.catch(error => { failure = error; });
280
+ current = active;
281
+ let cancellation;
282
+ const abort = () => {
283
+ cancellation ||= interrupt(command.context).then(() => { throw command.signal.reason; });
284
+ cancellation.catch(active.failure.reject);
285
+ };
286
+ command.signal.addEventListener("abort", abort, { once: true });
683
287
  try {
684
- active.resolved = await resolvePreparation(command.configuration, command.context,
685
- AbortSignal.any([command.signal, AbortSignal.timeout(120_000)]), false, undefined, true);
288
+ let delivered;
686
289
  let dispatchFailure;
687
- try { await send(command); }
290
+ try { delivered = await send(command); }
688
291
  catch (error) { dispatchFailure = error; }
689
292
  for (;;) {
690
293
  const settling = active.delivery;
691
294
  let result;
692
- let failure = dispatchFailure || active.failure;
693
- if (!failure && settling?.completion) {
694
- try { result = await settling.completion; }
695
- catch (error) { failure = error; }
696
- } else if (!failure && settling) result = facilities.runtime.turnSnapshot(settling.turn, binding.sessionId);
697
- if (command.finishIfCurrent && !await command.finishIfCurrent(() => active.delivery === settling)) continue;
698
- failure = dispatchFailure || active.failure || failure;
699
- if (!failure && result?.error) {
700
- failure = Object.assign(new Error(result.error), { status: result.status });
701
- if (result.active && result.status === "observation_lost") failure.cleanupFailed = true;
295
+ let problem = dispatchFailure || failure;
296
+ if (!problem && settling?.completion) {
297
+ try { result = await Promise.race([settling.completion, active.failure.promise]); }
298
+ catch (error) { problem = error; }
702
299
  }
703
- if (failure) throw failure;
704
- break;
300
+ if (command.finishIfCurrent && !await command.finishIfCurrent(() => active.delivery === settling)) {
301
+ // The original sender admitted another invocation and returned
302
+ // its exact monitor. Keep the same completion and Stop owner.
303
+ continue;
304
+ }
305
+ problem = dispatchFailure || failure || problem;
306
+ if (!problem && result?.error) {
307
+ // readable-check: allow patched-error - data: status is the native turn's state, not an HTTP status
308
+ problem = Object.assign(new Error(result.error), { status: result.status });
309
+ if (result.active && result.status === "observation_lost") {
310
+ problem.cleanupFailed = true;
311
+ cleanupFailure = problem;
312
+ await reportFailure(problem);
313
+ }
314
+ }
315
+ if (problem) throw problem;
316
+ return { value: delivered.value };
705
317
  }
706
- } catch (error) {
707
- problem = error.cleanupFailed ? error : command.signal.aborted ? command.signal.reason : error;
708
- if (problem.cleanupFailed) problem.message = "OpenCode process cleanup could not be confirmed. Restore its execution host before continuing.";
318
+ } finally {
319
+ command.signal.removeEventListener("abort", abort);
320
+ await cancellation?.catch(() => {});
321
+ if (toolBridge) { await toolBridge.close(); toolBridge = null; }
322
+ if (current === active) current = null;
709
323
  }
710
- active.finished = true;
711
- try { if (!problem?.cleanupFailed && (problem || command.signal.aborted)) await stop(); }
712
- catch (error) { problem = error; }
713
- finally { await toolBridge?.whenIdle(); if (!problem?.cleanupFailed) current = null; }
714
- if (problem) throw problem;
715
324
  },
716
- cancel: stop,
717
- async dispose() { await stop(); disposed = true; }
325
+ async steer(command) { return { value: (await send(command)).value }; },
326
+ async cancel({ context, representation } = {}) {
327
+ const value = await interrupt(context);
328
+ return representation === "native" ? { value } : value;
329
+ },
330
+ async dispose(options) {
331
+ try {
332
+ const result = await disposeNative({ native: supplied, options });
333
+ if (result?.ok === false) throw Object.assign(new Error(result.error || "OpenCode cleanup could not be confirmed."), result);
334
+ if (toolBridge) { await toolBridge.close(); toolBridge = null; }
335
+ disposed = true;
336
+ return result;
337
+ } catch (error) {
338
+ error.cleanupFailed = true;
339
+ current?.failure.reject(error);
340
+ throw error;
341
+ }
342
+ }
718
343
  });
719
344
  }
345
+ return openStandaloneOpenCodeConversation({
346
+ connections,
347
+ host,
348
+ limits,
349
+ workdir,
350
+ stateDirectory,
351
+ execution,
352
+ nativeTools,
353
+ maximumOutput
354
+ }, { binding, writeBinding, reportFailure });
355
+ }
356
+
357
+ return Object.freeze({
358
+ toolDiscovery: true,
359
+ attachmentTypes: ["text", "image", "localFile"],
360
+ capabilities: Object.freeze({ streaming: true, cancellation: true, history: true,
361
+ instructions: true, configuration: true, steering: true, goals: false, attachments: true, tools: true, nativeTools, structuredOutput: true }),
362
+ validateConfiguration,
363
+ readState,
364
+ inspectTemporaryActivity,
365
+ releaseRenewalPredecessorProcessExitProof,
366
+ releaseRenewalSuccessorProcessExitProof,
367
+ generateRenewalHandover,
368
+ seedRenewalHandover,
369
+ ensureConversation,
370
+ disposeNative,
371
+ interruptDetachedConversation,
372
+ deleteDetachedConversation,
373
+ listConversationStorage,
374
+ retireConversationHistory,
375
+ closeProject,
376
+ invalidateRuntimes,
377
+ reconcileSessions,
378
+ createConversation,
379
+ runDetachedConversation,
380
+ createBinding,
381
+ open
720
382
  });
721
383
  }