@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,565 @@
1
+ import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
2
+ import path from "node:path";
3
+ import { OPENCODE_INTERRUPT_TIMEOUT_MS } from "./openCodeTurn.js";
4
+ import { openCodeServerForDirectory, sameOpenCodeSelection } from "./openCodeNativeSession.js";
5
+ import { normalizeNullableText } from "./normalize.js";
6
+
7
+ /** A released target whose caller keeps no state about it. */
8
+ function ignoreTargetRemoval() {}
9
+
10
+ /**
11
+ * The one shared OpenCode process and the targets that use it: its start and
12
+ * stop, its directories' credentials, its saved connections, the storage reads
13
+ * that borrow it, and each target's acquisition and release. The process stops
14
+ * when its last user leaves.
15
+ * @param {{
16
+ * processes: Map<string, object>,
17
+ * processStarts: Map<string, Promise<object>>,
18
+ * monitors: Map<string, Promise<object>>,
19
+ * turns: Map<string, object>,
20
+ * temporaryConversations: Map<string, object>,
21
+ * onStop: (stop: { reason: string, sessions: number, startingSessions: number }) => void,
22
+ * storageIdleMs: number
23
+ * }} options
24
+ */
25
+ export function createOpenCodeProcessOwner({ processes, processStarts, monitors, turns, temporaryConversations, onStop, storageIdleMs }) {
26
+ const processExitProofs = new Map();
27
+ let sharedProcess = null;
28
+ let sharedProcessStart = null;
29
+ let sharedProcessStop = null;
30
+ let failedStartup = null;
31
+ let closed = false;
32
+ let connectionUpdate = Promise.resolve();
33
+ let storageReads = 0;
34
+ let storageIdle = null;
35
+
36
+ async function stop(reason = "opencode-last-session-closed") {
37
+ if (sharedProcessStop) return sharedProcessStop;
38
+ const target = sharedProcess;
39
+ const startup = failedStartup;
40
+ if (!target && !startup) return { exited: true, reason };
41
+ onStop({ reason, sessions: processes.size, startingSessions: processStarts.size });
42
+ const stopping = (async () => {
43
+ const proof = startup ? await startup.retryCleanup() : await target.server.stop();
44
+ if (proof?.exited !== true) {
45
+ throw new AppError(503, "OpenCode process exit could not be verified.", {
46
+ code: "assistant_opencode_stop_unverified", details: {}
47
+ });
48
+ }
49
+ if (sharedProcess === target) sharedProcess = null;
50
+ if (failedStartup === startup) failedStartup = null;
51
+ return proof;
52
+ })();
53
+ sharedProcessStop = stopping;
54
+ try {
55
+ return await stopping;
56
+ } finally {
57
+ if (sharedProcessStop === stopping) sharedProcessStop = null;
58
+ }
59
+ }
60
+
61
+ // A credential change reaches a native directory when OpenCode reloads that
62
+ // directory's instance. An idle directory reloads at once; one with a turn in
63
+ // flight reloads when its last turn there ends. New work in a directory waits
64
+ // for its reload, so it never starts on credentials the server no longer holds.
65
+ function directoryState(shared, workdir) {
66
+ const directory = path.resolve(workdir);
67
+ let state = shared.directories.get(directory);
68
+ if (!state) {
69
+ state = { directory, generation: shared.generation, turns: 0, reload: null, idle: null };
70
+ shared.directories.set(directory, state);
71
+ }
72
+ return state;
73
+ }
74
+
75
+ function directoryCurrent(workdir) {
76
+ const state = sharedProcess && normalizeNullableText(workdir) ? sharedProcess.directories.get(path.resolve(workdir)) : null;
77
+ return !state || (!state.reload && state.generation === sharedProcess.generation);
78
+ }
79
+
80
+ function reloadDirectory(shared, state) {
81
+ if (state.reload || state.generation === shared.generation) return state.reload;
82
+ state.reload = (async () => {
83
+ try {
84
+ while (state.generation < shared.generation) {
85
+ const generation = shared.generation;
86
+ await shared.server.client.forDirectory(state.directory).disposeInstance();
87
+ state.generation = generation;
88
+ }
89
+ } finally {
90
+ state.reload = null;
91
+ }
92
+ })();
93
+ return state.reload;
94
+ }
95
+
96
+ async function waitForDirectory(workdir) {
97
+ const shared = sharedProcess;
98
+ const state = directoryState(shared, workdir);
99
+ try {
100
+ if (state.reload || !state.turns) await reloadDirectory(shared, state);
101
+ else await (state.idle ||= Promise.withResolvers()).promise;
102
+ } catch (error) {
103
+ if (sharedProcess !== shared) return;
104
+ throw new AppError(503, "OpenCode could not load the changed AI connection for this work. Send your message again.", {
105
+ cause: error, code: "assistant_opencode_connection_reload_failed", details: {}
106
+ });
107
+ }
108
+ }
109
+
110
+ async function currentDirectory(workdir) {
111
+ while (!directoryCurrent(workdir)) await waitForDirectory(workdir);
112
+ }
113
+
114
+ // Holds a directory's credentials for a turn in flight there.
115
+ function holdDirectory(workdir) {
116
+ const shared = sharedProcess;
117
+ if (!shared || !normalizeNullableText(workdir)) return () => {};
118
+ const state = directoryState(shared, workdir);
119
+ state.turns += 1;
120
+ let held = true;
121
+ return () => {
122
+ if (!held) return;
123
+ held = false;
124
+ state.turns -= 1;
125
+ if (state.turns) return;
126
+ state.idle?.resolve();
127
+ state.idle = null;
128
+ if (sharedProcess === shared) reloadDirectory(shared, state)?.catch(() => {});
129
+ };
130
+ }
131
+
132
+ async function admitDirectory(workdir) {
133
+ while (!directoryCurrent(workdir)) await waitForDirectory(workdir);
134
+ return holdDirectory(workdir);
135
+ }
136
+
137
+ function reloadChangedCredentials(shared) {
138
+ for (const directory of shared.server.client.instanceDirectories()) directoryState(shared, directory);
139
+ shared.generation += 1;
140
+ for (const state of shared.directories.values()) {
141
+ if (!state.turns) reloadDirectory(shared, state)?.catch(() => {});
142
+ }
143
+ }
144
+
145
+ function connectionRecord(connection) {
146
+ return { canonicalUrl: connection.canonicalUrl, endpointCode: connection.endpointCode, fingerprint: connection.fingerprint };
147
+ }
148
+
149
+ function sameConnection(recorded, connection) {
150
+ return recorded?.canonicalUrl === connection.canonicalUrl && recorded.fingerprint === connection.fingerprint &&
151
+ recorded.endpointCode === connection.endpointCode;
152
+ }
153
+
154
+ // A key changes in place; a provider route belongs to the server's configuration.
155
+ function inPlaceConnection(recorded, connection) {
156
+ return Boolean(connection?.apiKey) && (recorded
157
+ ? recorded.canonicalUrl === connection.canonicalUrl && recorded.endpointCode === connection.endpointCode
158
+ : !normalizeNullableText(connection.canonicalUrl));
159
+ }
160
+
161
+ // The running server takes the host's saved connections without stopping its
162
+ // turns. Reading them inside this queue applies the newest saved state last.
163
+ function updateConnections(readConnections) {
164
+ const update = connectionUpdate.then(async () => {
165
+ const shared = sharedProcess;
166
+ if (!shared) return;
167
+ shared.connectionsCurrent = false;
168
+ let changed = false;
169
+ try {
170
+ const saved = new Map((await readConnections()).map(connection => [connection.modelProviderId, connection]));
171
+ for (const [providerId, connection] of saved) {
172
+ const recorded = shared.connections.get(providerId);
173
+ if (sameConnection(recorded, connection) || !inPlaceConnection(recorded, connection)) continue;
174
+ await shared.server.client.authenticateApiKey(providerId, connection.apiKey);
175
+ shared.connections.set(providerId, connectionRecord(connection));
176
+ changed = true;
177
+ }
178
+ for (const providerId of [...shared.connections.keys()]) {
179
+ if (saved.has(providerId)) continue;
180
+ await shared.server.client.removeAuthentication(providerId);
181
+ shared.connections.delete(providerId);
182
+ changed = true;
183
+ }
184
+ shared.connectionsCurrent = true;
185
+ } finally {
186
+ if (changed) reloadChangedCredentials(shared);
187
+ }
188
+ });
189
+ connectionUpdate = update.catch(() => {});
190
+ return update;
191
+ }
192
+
193
+ // `readConnections` returns the host's saved connections. Without it, a changed
194
+ // account needs a new server.
195
+ async function ensure(selected, start, readConnections = null) {
196
+ await sharedProcessStop;
197
+ if (failedStartup) await stop("opencode-startup-cleanup");
198
+ if (sharedProcess) {
199
+ const recorded = sharedProcess.connections.get(selected?.modelProviderId);
200
+ const current = !selected || sameConnection(recorded, selected);
201
+ if (current || (readConnections && inPlaceConnection(recorded, selected))) {
202
+ const probe = AbortSignal.timeout(1_000);
203
+ let healthy = false;
204
+ try {
205
+ await sharedProcess.server.client.health({ signal: probe });
206
+ healthy = true;
207
+ } catch (error) {
208
+ // A slow probe is not proof that the shared server stopped working.
209
+ if (probe.aborted) throw error;
210
+ await stop("opencode-health-check-failed");
211
+ }
212
+ if (healthy && sharedProcess) {
213
+ if (readConnections && (!current || !sharedProcess.connectionsCurrent)) await updateConnections(readConnections);
214
+ return sharedProcess;
215
+ }
216
+ } else {
217
+ await stop("opencode-connection-changed");
218
+ }
219
+ }
220
+ if (sharedProcessStart) return sharedProcessStart;
221
+ sharedProcessStart = Promise.resolve().then(async () => {
222
+ const { connections, server } = await start();
223
+ const started = {
224
+ connections: new Map(connections.map(connection => [connection.modelProviderId, connectionRecord(connection)])),
225
+ connectionsCurrent: true, directories: new Map(), generation: 0, server
226
+ };
227
+ sharedProcess = started;
228
+ // An unexpected exit retires the server's leftovers at once.
229
+ void server.unexpectedExit?.then(() => sharedProcess === started ? stop("opencode-server-exited") : null)
230
+ .catch(() => {});
231
+ for (const target of processes.values()) {
232
+ target.server = openCodeServerForDirectory(server, target.workdir);
233
+ target.upstream = null;
234
+ target.upstreamSelection = null;
235
+ }
236
+ return sharedProcess;
237
+ }).catch(error => {
238
+ // readable-check: allow method-probe - optional operation: a startup failure carries retryCleanup only when it kept a process to stop
239
+ if (error.cleanupFailed && typeof error.retryCleanup === "function") failedStartup = error;
240
+ throw error;
241
+ });
242
+ try {
243
+ return await sharedProcessStart;
244
+ } finally {
245
+ sharedProcessStart = null;
246
+ }
247
+ }
248
+
249
+ // Native storage inventory and retirement borrow the shared server; they are
250
+ // not users that keep it running. A short idle after the last read lets one
251
+ // maintenance pass share it, then it stops unless a conversation uses it.
252
+ async function withStorageProcess(start, operation, failure = error => { throw error; }) {
253
+ clearTimeout(storageIdle);
254
+ storageIdle = null;
255
+ storageReads += 1;
256
+ try {
257
+ return await operation(await ensure(null, start).catch(failure));
258
+ } finally {
259
+ storageReads -= 1;
260
+ if (storageReads === 0 && !closed) {
261
+ storageIdle = setTimeout(() => {
262
+ storageIdle = null;
263
+ if (storageReads === 0 && processes.size === 0 && processStarts.size === 0) {
264
+ stop("opencode-storage-read-finished").catch(() => null);
265
+ }
266
+ }, storageIdleMs);
267
+ storageIdle.unref();
268
+ }
269
+ }
270
+ }
271
+
272
+ function targetFor(key, prepared) {
273
+ const current = processes.get(key);
274
+ const nativeId = prepared.upstreamSessionId;
275
+ if (current && current.upstreamSessionId !== nativeId) {
276
+ current.upstreamSessionId = nativeId;
277
+ current.upstream = null;
278
+ }
279
+ if (
280
+ current && !current.abortController.signal.aborted &&
281
+ current.canonicalUrl === prepared.connection.canonicalUrl &&
282
+ current.connectionFingerprint === prepared.connection.fingerprint &&
283
+ current.endpointCode === prepared.connection.endpointCode &&
284
+ current.modelProviderId === prepared.connection.modelProviderId &&
285
+ current.projectContextRoot === prepared.projectContextRoot &&
286
+ current.workdir === prepared.workdir &&
287
+ normalizeNullableText(current.selection?.catalogRevision) === normalizeNullableText(prepared.selection.catalogRevision) &&
288
+ sameOpenCodeSelection(current.selection, prepared.selection)
289
+ ) {
290
+ current.helperModelId = prepared.helperModelId;
291
+ current.server = openCodeServerForDirectory(prepared.server, prepared.workdir);
292
+ return current;
293
+ }
294
+ const server = openCodeServerForDirectory(prepared.server, prepared.workdir);
295
+ const created = (current && !current.abortController.signal.aborted ? current : null) || {
296
+ abortController: new AbortController(),
297
+ key,
298
+ sessionId: prepared.sessionId,
299
+ upstreamSessionId: nativeId
300
+ };
301
+ Object.assign(created, {
302
+ helperModelId: prepared.helperModelId,
303
+ canonicalUrl: prepared.connection.canonicalUrl,
304
+ connectionFingerprint: prepared.connection.fingerprint,
305
+ endpointCode: prepared.connection.endpointCode,
306
+ modelProviderId: prepared.connection.modelProviderId,
307
+ projectContextRoot: prepared.projectContextRoot,
308
+ selection: prepared.selection,
309
+ server,
310
+ workdir: prepared.workdir
311
+ });
312
+ return created;
313
+ }
314
+
315
+ async function acquire(key, prepare, onFailure = async () => {}) {
316
+ const pending = processStarts.get(key);
317
+ if (pending) return pending;
318
+ const start = Promise.resolve().then(async () => {
319
+ const target = await prepare();
320
+ processes.set(key, target);
321
+ return target;
322
+ }).catch(async error => {
323
+ if (processStarts.get(key) === start) processStarts.delete(key);
324
+ if (!processes.has(key)) {
325
+ await onFailure();
326
+ if (processes.size === 0 && processStarts.size === 0 && storageReads === 0 && !failedStartup) {
327
+ await stop("opencode-session-start-failed").catch(() => null);
328
+ }
329
+ }
330
+ throw error;
331
+ });
332
+ processStarts.set(key, start);
333
+ try {
334
+ return await start;
335
+ } finally {
336
+ if (processStarts.get(key) === start) processStarts.delete(key);
337
+ }
338
+ }
339
+
340
+ async function release(target, { retainSharedProcess = false, onRemoved = ignoreTargetRemoval } = {}) {
341
+ if (processes.get(target.key) === target) {
342
+ processes.delete(target.key);
343
+ await onRemoved();
344
+ }
345
+ return !retainSharedProcess && processes.size === 0 && processStarts.size === 0 && storageReads === 0
346
+ ? stop()
347
+ : { exited: true, sharedProcessRetained: true };
348
+ }
349
+
350
+ async function releaseTarget(target, { retainSharedProcess = false, onRemoved } = {}) {
351
+ const activeThreads = new Set();
352
+ if (turns.get(target.key)?.active) activeThreads.add(target.upstreamSessionId);
353
+ for (const entry of temporaryConversations.values()) {
354
+ if (entry.active && entry.target?.abortController === target.abortController) {
355
+ activeThreads.add(entry.conversationId);
356
+ }
357
+ }
358
+ for (const threadId of activeThreads) await stopUnobservedSession(target, threadId);
359
+ target.abortController.abort();
360
+ turns.get(target.key)?.admission?.resolve();
361
+ await monitors.get(target.key)?.catch(() => null);
362
+ await Promise.all([...temporaryConversations.values()]
363
+ .filter((entry) => entry.target?.abortController === target.abortController)
364
+ .map((entry) => entry.completion?.catch(() => null)));
365
+ return release(target, { retainSharedProcess, onRemoved });
366
+ }
367
+
368
+ async function releaseProcessTarget(target = null, { retainSharedProcess = false } = {}, application) {
369
+ if (!target) {
370
+ return { exited: true };
371
+ }
372
+ await application.beforeRelease(target);
373
+ const proof = await releaseTarget(target, {
374
+ retainSharedProcess,
375
+ onRemoved: () => application.onRemoved(target)
376
+ }).catch(application.failure);
377
+ if (target.sessionId) {
378
+ processExitProofs.set(target.sessionId, proof);
379
+ }
380
+ return proof;
381
+ }
382
+
383
+ async function closeSession(id, options, application) {
384
+ const terminalClose = await application.closeTerminals();
385
+ const pending = [...processStarts.entries()]
386
+ .filter(([key]) => key.endsWith(`\0${id}`))
387
+ .map(([, start]) => start.catch(() => null));
388
+ await Promise.all(pending);
389
+ const targets = [...processes.values()].filter((target) => target.sessionId === id);
390
+ const proofs = await Promise.all(targets.map((target) => releaseProcessTarget(target, options, application)));
391
+ await application.afterRelease();
392
+ for (const key of [...turns.keys()]) {
393
+ if (key.endsWith(`\0${id}`)) {
394
+ turns.delete(key);
395
+ }
396
+ }
397
+ for (const key of [...temporaryConversations.keys()]) {
398
+ if (temporaryConversations.get(key)?.target?.sessionId === id) {
399
+ temporaryConversations.delete(key);
400
+ }
401
+ }
402
+ const result = {
403
+ closed: targets.length,
404
+ ok: proofs.every((proof) => proof?.exited !== false),
405
+ processExitProof: proofs.at(-1) || processExitProofs.get(id) || { exited: true },
406
+ processExitProofs: proofs
407
+ };
408
+ return {
409
+ ...result,
410
+ closed: result.closed + Number(terminalClose?.closed || 0),
411
+ ok: terminalClose?.ok !== false && result.ok
412
+ };
413
+ }
414
+
415
+ async function closeProject(input = {}, application) {
416
+ await Promise.all([...processStarts.entries()]
417
+ .filter(([key]) => key.includes("\0"))
418
+ .map(([, start]) => start.catch(() => null)));
419
+ const projectContextRoot = normalizeNullableText(input.projectContextRoot);
420
+ const targets = [...processes.values()].filter((target) => (
421
+ application.ownsTarget(target) && (!projectContextRoot || target.projectContextRoot === path.resolve(projectContextRoot))
422
+ ));
423
+ const results = await Promise.all(targets.map((target) => releaseProcessTarget(target, {}, application)));
424
+ if (processes.size === 0 && storageReads === 0 && (sharedProcess || failedStartup)) {
425
+ results.push(await stop(normalizeNullableText(input.reason) || "opencode-project-close").catch(application.failure));
426
+ }
427
+ return {
428
+ closed: results.length,
429
+ ok: results.every((result) => result?.exited !== false)
430
+ };
431
+ }
432
+
433
+ async function invalidateRuntimes(input = {}, preparation) {
434
+ if (normalizeNullableText(input.reason) === "server-shutdown") {
435
+ closed = true;
436
+ clearTimeout(storageIdle);
437
+ storageIdle = null;
438
+ }
439
+ const application = preparation.release;
440
+ // readable-check: allow method-probe - input validation: a connection change carries the publication of the application's saved connections
441
+ if (typeof input.publish === "function") {
442
+ // readable-check: allow method-probe - input validation: a connection change requires the application's saved connections
443
+ if (typeof preparation.connections !== "function") {
444
+ throw new TypeError("OpenCode connection changes require the host's saved connections.");
445
+ }
446
+ // Running turns, retained conversations and the shared server continue.
447
+ // The server takes the saved connections at once, and each directory
448
+ // reloads them as soon as it has no turn in flight.
449
+ // readable-check: allow optional-call - optional operation: an application's release owner may refuse a connection change before it is published
450
+ await application.assertConnectionChange?.();
451
+ await input.publish();
452
+ const update = () => updateConnections(preparation.connections).catch(() => null);
453
+ void sharedProcessStart?.then(update, () => null);
454
+ await update();
455
+ return { closed: 0, ok: true };
456
+ }
457
+ await Promise.all([...processStarts.entries()]
458
+ .filter(([key]) => key.includes("\0"))
459
+ .map(([, start]) => start.catch(() => null)));
460
+ const targets = [...processes.values()].filter(target => application.ownsTarget(target));
461
+ const results = await Promise.all(targets.map((target) => releaseProcessTarget(target, {}, application)));
462
+ if (processes.size === 0 && processStarts.size === 0 && (sharedProcess || failedStartup)) {
463
+ results.push(await stop(normalizeNullableText(input.reason) || "opencode-runtime-invalidation").catch(application.failure));
464
+ }
465
+ return {
466
+ closed: results.length,
467
+ ok: results.every((result) => result?.exited !== false)
468
+ };
469
+ }
470
+
471
+ async function closePreparedSession(prepared) {
472
+ return closeSession(prepared.sessionId, prepared.options, prepared.application);
473
+ }
474
+
475
+ async function releaseRenewalPredecessorProcessExitProof(preparation) {
476
+ const closed = await closePreparedSession(preparation.cleanup);
477
+ const released = releaseProcessExitProof(preparation.sessionId);
478
+ return {
479
+ ...closed,
480
+ ...released,
481
+ ok: closed.ok !== false && released.ok !== false
482
+ };
483
+ }
484
+
485
+ function releaseProcessExitProof(id) {
486
+ const proof = processExitProofs.get(id) || null;
487
+ processExitProofs.delete(id);
488
+ return {
489
+ ok: Boolean(proof?.exited),
490
+ processExitProof: proof,
491
+ released: Boolean(proof?.exited)
492
+ };
493
+ }
494
+
495
+ async function startPreparedProcess(prepared) {
496
+ const server = await prepared.create(prepared.options);
497
+ return { connections: prepared.connections, server };
498
+ }
499
+
500
+ async function acquirePrepared(prepared) {
501
+ return acquire(prepared.key, async () => {
502
+ const configured = await prepared.configure();
503
+ const shared = await ensure(prepared.connection,
504
+ async () => startPreparedProcess(await configured.prepareServer()),
505
+ async () => (await configured.prepareServer()).connections).catch(prepared.failure);
506
+ configured.target.server = shared.server;
507
+ return targetFor(prepared.key, configured.target);
508
+ }, prepared.onFailure).catch(prepared.failure);
509
+ }
510
+
511
+ async function stopUnobservedSession(target, threadId) {
512
+ const signal = AbortSignal.timeout(OPENCODE_INTERRUPT_TIMEOUT_MS);
513
+ try {
514
+ const confirmed = await target.server.client.interrupt(threadId, { signal });
515
+ const status = await target.server.client.sessionStatus(threadId, { signal });
516
+ if (confirmed !== true || status?.type !== "idle") {
517
+ throw new Error("OpenCode did not confirm that the session stopped.");
518
+ }
519
+ } catch (error) {
520
+ // The process owns the final stop proof when its control channel cannot.
521
+ // Stop this exact server, even if another connection has since replaced it.
522
+ const server = target.server;
523
+ const proof = sharedProcess?.server.stop === server.stop
524
+ ? await stop("opencode-observation-lost")
525
+ : await server.stop();
526
+ if (proof?.exited !== true) {
527
+ throw new Error("OpenCode observation was lost and process exit could not be verified.", { cause: error });
528
+ }
529
+ for (const affected of processes.values()) {
530
+ if (affected.server.stop === server.stop) {
531
+ affected.abortController.abort(new Error("OpenCode's shared service was stopped after observation failed. Send a message to continue."));
532
+ }
533
+ }
534
+ }
535
+ }
536
+
537
+ return Object.freeze({
538
+ get current() { return sharedProcess; },
539
+ get closed() { return closed; },
540
+ get cleanupPending() { return Boolean(failedStartup); },
541
+ stop,
542
+ ensure,
543
+ withStorageProcess,
544
+ currentDirectory,
545
+ directoryCurrent,
546
+ waitForDirectory,
547
+ holdDirectory,
548
+ admitDirectory,
549
+ stopUnobservedSession,
550
+ targetFor,
551
+ acquire,
552
+ acquirePrepared,
553
+ startPreparedProcess,
554
+ release,
555
+ releaseTarget,
556
+ releaseProcessTarget,
557
+ closeSession,
558
+ closeProject,
559
+ invalidateRuntimes,
560
+ releaseRenewalPredecessorProcessExitProof,
561
+ releaseProcessExitProof
562
+ });
563
+ }
564
+
565
+ /** @typedef {ReturnType<typeof createOpenCodeProcessOwner>} OpenCodeProcessOwner */