@jskit-ai/assistant-core 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/package.json +18 -7
  3. package/src/client/browser.js +21 -0
  4. package/src/client/conversation/AssistantAttachmentQueue.vue +25 -108
  5. package/src/client/conversation/AssistantComposerActions.vue +1 -1
  6. package/src/client/conversation/AssistantConversationElement.vue +14 -52
  7. package/src/client/conversation/AssistantGoalControl.vue +18 -12
  8. package/src/client/conversation/AssistantPromptInput.vue +6 -91
  9. package/src/client/conversation/AssistantTranscript.css +505 -0
  10. package/src/client/conversation/AssistantTranscript.vue +24 -1197
  11. package/src/client/conversation/attachmentFiles.js +158 -0
  12. package/src/client/conversation/attachmentQueueItems.js +121 -0
  13. package/src/client/conversation/attachmentUploadRows.js +265 -0
  14. package/src/client/conversation/conversationDisplay.js +54 -0
  15. package/src/client/conversation/messageDelivery.js +13 -9
  16. package/src/client/conversation/retainedConversation.js +14 -3
  17. package/src/client/conversation/submitText.js +13 -2
  18. package/src/client/conversation/transcriptEntries.js +225 -0
  19. package/src/client/conversation/useAssistantAttachments.js +86 -388
  20. package/src/client/conversation/useAssistantQuestions.js +16 -3
  21. package/src/client/conversation/useAssistantSuggestions.js +16 -2
  22. package/src/client/conversation/useScrollToBottom.js +17 -19
  23. package/src/client/conversation/useTextareaAutoGrow.js +115 -0
  24. package/src/client/conversation/useTranscriptScrollFollow.js +525 -0
  25. package/src/client/lib/assistantApi.js +192 -161
  26. package/src/client/types.js +110 -0
  27. package/src/server/conversation/attachments.js +21 -6
  28. package/src/server/conversation/boundedToolLoop.js +1 -0
  29. package/src/server/conversation/claudeClient.js +71 -20
  30. package/src/server/conversation/claudeCommandRun.js +191 -0
  31. package/src/server/conversation/claudeHistory.js +7 -0
  32. package/src/server/conversation/claudeNativeTurn.js +708 -0
  33. package/src/server/conversation/claudeProcess.js +186 -112
  34. package/src/server/conversation/claudeProcessStart.js +214 -0
  35. package/src/server/conversation/claudePromptDelivery.js +183 -0
  36. package/src/server/conversation/claudeSessionCleanup.js +248 -0
  37. package/src/server/conversation/claudeTools.js +15 -3
  38. package/src/server/conversation/claudeTurn.js +212 -1388
  39. package/src/server/conversation/codexAccountReader.js +168 -0
  40. package/src/server/conversation/codexAuthPreflight.js +5 -4
  41. package/src/server/conversation/codexClient.js +18 -6
  42. package/src/server/conversation/codexConfiguration.js +8 -844
  43. package/src/server/conversation/codexConversationStops.js +377 -0
  44. package/src/server/conversation/codexConversationStore.js +119 -85
  45. package/src/server/conversation/codexConversationTurns.js +378 -0
  46. package/src/server/conversation/codexConversations.js +134 -866
  47. package/src/server/conversation/codexDetachedConversation.js +203 -0
  48. package/src/server/conversation/codexDetachedTurn.js +145 -104
  49. package/src/server/conversation/codexEvents.js +5 -200
  50. package/src/server/conversation/codexGoalCommands.js +14 -4
  51. package/src/server/conversation/codexHelperExecution.js +57 -57
  52. package/src/server/conversation/codexHelperThreadLedger.js +30 -288
  53. package/src/server/conversation/codexHelperThreadRecord.js +304 -0
  54. package/src/server/conversation/codexHelperThreadRecovery.js +54 -76
  55. package/src/server/conversation/codexHelperThreads.js +94 -93
  56. package/src/server/conversation/codexHistoryAdapter.js +13 -8
  57. package/src/server/conversation/codexIsolation.js +672 -0
  58. package/src/server/conversation/codexLiveItems.js +379 -0
  59. package/src/server/conversation/codexMessageCommands.js +68 -286
  60. package/src/server/conversation/codexMessageDelivery.js +46 -37
  61. package/src/server/conversation/codexModelCatalog.js +86 -7
  62. package/src/server/conversation/codexNotificationObserver.js +81 -88
  63. package/src/server/conversation/codexNotificationQueue.js +21 -10
  64. package/src/server/conversation/codexObservationControl.js +105 -89
  65. package/src/server/conversation/codexOutput.js +84 -419
  66. package/src/server/conversation/codexProcess.js +29 -1053
  67. package/src/server/conversation/codexProcessLeader.js +1 -0
  68. package/src/server/conversation/codexPromptDispatch.js +241 -0
  69. package/src/server/conversation/codexProvider.js +147 -1807
  70. package/src/server/conversation/codexProviderAuth.js +348 -0
  71. package/src/server/conversation/codexProviderInvalidation.js +371 -0
  72. package/src/server/conversation/codexProviderInventories.js +437 -0
  73. package/src/server/conversation/codexProviderOwner.js +149 -957
  74. package/src/server/conversation/codexProviderRelease.js +465 -0
  75. package/src/server/conversation/codexProviderSelection.js +26 -15
  76. package/src/server/conversation/codexProviderThreads.js +698 -0
  77. package/src/server/conversation/codexProviderTurns.js +453 -0
  78. package/src/server/conversation/codexReasoning.js +32 -23
  79. package/src/server/conversation/codexRenewalCommands.js +18 -6
  80. package/src/server/conversation/codexRenewalThreads.js +7 -22
  81. package/src/server/conversation/codexResultSettlement.js +70 -88
  82. package/src/server/conversation/codexRunJournal.js +91 -97
  83. package/src/server/conversation/codexRuntime.js +426 -0
  84. package/src/server/conversation/codexRuntimeInventory.js +113 -0
  85. package/src/server/conversation/codexRuntimeLifecycle.js +39 -13
  86. package/src/server/conversation/codexRuntimeStart.js +431 -0
  87. package/src/server/conversation/codexRuntimeStop.js +324 -0
  88. package/src/server/conversation/codexSessionCleanup.js +25 -10
  89. package/src/server/conversation/codexSessionReconciliation.js +21 -7
  90. package/src/server/conversation/codexThreadReadiness.js +49 -52
  91. package/src/server/conversation/codexThreadSnapshot.js +210 -0
  92. package/src/server/conversation/codexThreadStatus.js +48 -62
  93. package/src/server/conversation/codexTurn.js +171 -55
  94. package/src/server/conversation/codexTurnActivity.js +210 -0
  95. package/src/server/conversation/codexTurnControl.js +44 -48
  96. package/src/server/conversation/codexTurnEndings.js +610 -0
  97. package/src/server/conversation/codexTurnRecovery.js +97 -848
  98. package/src/server/conversation/codexTurnState.js +138 -111
  99. package/src/server/conversation/completedResponses.js +227 -0
  100. package/src/server/conversation/configuration.js +2 -2
  101. package/src/server/conversation/continuity.js +43 -6
  102. package/src/server/conversation/conversationDelivery.js +394 -0
  103. package/src/server/conversation/conversationEntries.js +365 -0
  104. package/src/server/conversation/conversationEvents.js +192 -0
  105. package/src/server/conversation/conversationGoals.js +187 -0
  106. package/src/server/conversation/conversationHandles.js +237 -0
  107. package/src/server/conversation/conversationReads.js +206 -0
  108. package/src/server/conversation/conversationRefusals.js +58 -0
  109. package/src/server/conversation/conversationReplacement.js +254 -0
  110. package/src/server/conversation/conversationSending.js +273 -0
  111. package/src/server/conversation/conversationStopping.js +121 -0
  112. package/src/server/conversation/conversationTurnDelivery.js +413 -0
  113. package/src/server/conversation/conversationTurns.js +511 -0
  114. package/src/server/conversation/deliveryTiming.js +25 -3
  115. package/src/server/conversation/fileStorage.js +11 -6
  116. package/src/server/conversation/hookBridge.js +14 -3
  117. package/src/server/conversation/index.js +1 -1
  118. package/src/server/conversation/localExecution.js +82 -625
  119. package/src/server/conversation/nativeCommandCallbacks.js +10 -0
  120. package/src/server/conversation/nativeConversationLifecycle.js +379 -0
  121. package/src/server/conversation/nativeHistoryExport.js +27 -18
  122. package/src/server/conversation/normalize.js +9 -0
  123. package/src/server/conversation/openCodeClient.js +295 -366
  124. package/src/server/conversation/openCodeNativeHistory.js +141 -0
  125. package/src/server/conversation/openCodeNativeSession.js +79 -0
  126. package/src/server/conversation/openCodeProcess.js +109 -53
  127. package/src/server/conversation/openCodeProcessOwner.js +565 -0
  128. package/src/server/conversation/openCodeRuntime.js +111 -1814
  129. package/src/server/conversation/openCodeSessionTurns.js +547 -0
  130. package/src/server/conversation/openCodeTemporaryConversations.js +394 -0
  131. package/src/server/conversation/openCodeTools.js +28 -14
  132. package/src/server/conversation/openCodeTurn.js +80 -74
  133. package/src/server/conversation/openCodeTurnMonitor.js +388 -0
  134. package/src/server/conversation/processIdentity.js +582 -0
  135. package/src/server/conversation/provider.js +21 -6
  136. package/src/server/conversation/providers/api.js +268 -107
  137. package/src/server/conversation/providers/claude.js +29 -6
  138. package/src/server/conversation/providers/claudeDriver.js +325 -250
  139. package/src/server/conversation/providers/codex.js +69 -33
  140. package/src/server/conversation/providers/codexDriver.js +503 -1081
  141. package/src/server/conversation/providers/codexDriverConnection.js +331 -0
  142. package/src/server/conversation/providers/codexDriverTurns.js +464 -0
  143. package/src/server/conversation/providers/opencode.js +30 -4
  144. package/src/server/conversation/providers/opencodeDriver.js +321 -659
  145. package/src/server/conversation/providers/opencodeStandalone.js +475 -0
  146. package/src/server/conversation/providers/scoped.js +64 -19
  147. package/src/server/conversation/runtime.js +171 -2863
  148. package/src/server/conversation/storage.js +91 -50
  149. package/src/server/conversation/streams.js +15 -2
  150. package/src/server/conversation/structuredOutput.js +7 -11
  151. package/src/server/conversation/systemPrompt.js +13 -1
  152. package/src/server/conversation/tools.js +47 -15
  153. package/src/server/conversation/transcript.js +49 -20
  154. package/src/server/hosts/claudeTurn.js +2 -4
  155. package/src/server/hosts/codexConfiguration.js +6 -8
  156. package/src/server/hosts/codexEvents.js +4 -2
  157. package/src/server/hosts/codexProcess.js +1 -1
  158. package/src/server/hosts/codexProvider.js +11 -9
  159. package/src/server/hosts/codexTurn.js +3 -3
  160. package/src/server/hosts/openCodeClient.js +4 -2
  161. package/src/server/index.js +0 -2
  162. package/src/server/lib/aiConnectionClient.js +35 -25
  163. package/src/server/lib/ndjson.js +1 -0
  164. package/src/server/lib/providers/anthropicClient.js +15 -6
  165. package/src/server/lib/providers/common.js +10 -6
  166. package/src/server/lib/providers/openAiCompatibleClient.js +40 -23
  167. package/src/server/lib/serviceToolCatalog.js +29 -283
  168. package/src/server/lib/serviceToolEntries.js +270 -0
  169. package/src/server/types.js +78 -0
  170. package/src/shared/assistantResource.js +0 -23
  171. package/src/shared/conversation/answerChoiceSugar.js +2 -14
  172. package/src/shared/conversation/messageInputSugar.js +18 -0
  173. package/src/shared/conversation/numberedQuestionSugar.js +6 -16
  174. package/src/shared/conversation/pagination.js +1 -1
  175. package/src/shared/conversation/questions.js +1 -0
  176. package/src/shared/conversation/streaming.js +1 -0
  177. package/src/shared/index.js +0 -1
  178. package/src/testing/conversationStorageContract.js +20 -0
  179. package/test/aiConnectionClient.test.js +42 -1
  180. package/test/apiConversationDriver.test.js +217 -38
  181. package/test/assistantBoundedToolLoop.test.js +1 -1
  182. package/test/claudeConversationSession.test.js +15 -11
  183. package/test/claudeInstructionResume.test.js +1 -1
  184. package/test/claudeProcess.test.js +9 -9
  185. package/test/claudeTurn.test.js +21 -8
  186. package/test/codexAccountChange.test.js +5 -3
  187. package/test/codexConversationSession.test.js +22 -21
  188. package/test/codexEvents.test.js +3 -1
  189. package/test/codexHelperDeletion.test.js +3 -7
  190. package/test/codexIsolation.test.js +6 -6
  191. package/test/codexModelCatalog.test.js +6 -7
  192. package/test/codexProcess.test.js +3 -3
  193. package/test/codexThreadSelection.test.js +4 -1
  194. package/test/codexTurn.test.js +29 -20
  195. package/test/conversationApiFacilities.test.js +11 -9
  196. package/test/conversationAttachments.test.js +3 -3
  197. package/test/conversationConfiguration.test.js +4 -4
  198. package/test/conversationNativeStorage.test.js +11 -10
  199. package/test/conversationReplacement.test.js +13 -13
  200. package/test/conversationRuntime.test.js +9 -5
  201. package/test/conversationSession.test.js +78 -22
  202. package/test/conversationTools.test.js +47 -26
  203. package/test/fileConversationStorage.test.js +10 -3
  204. package/test/fixtures/codexHelperThreadLedger.js +3 -3
  205. package/test/frameworkActions.test.js +33 -18
  206. package/test/localConversationExecution.test.js +4 -4
  207. package/test/localExecution.test.js +1 -1
  208. package/test/openCodeConnectionChange.test.js +3 -2
  209. package/test/openCodeRuntime.test.js +8 -7
  210. package/test/openCodeTools.test.js +1 -1
  211. package/test/opencodeConversationSession.test.js +4 -3
  212. package/test/serviceToolCatalog.test.js +3 -8
  213. package/src/server/lib/assistantCompletion.js +0 -190
  214. package/src/server/lib/assistantToolLoop.js +0 -259
@@ -0,0 +1,141 @@
1
+ // Stopped native storage inspection, and retirement of a native family after the host's durable preservation.
2
+ import { Buffer } from "node:buffer";
3
+ import path from "node:path";
4
+ import { createNativeHistoryExport, readStoppedNativeDatabase, retireNativeConversation } from "./nativeHistoryExport.js";
5
+ import { normalizedMessageRows, openCodeAssistantMessageText } from "./openCodeClient.js";
6
+
7
+ /** Inspect stopped native storage without starting OpenCode or changing its files. */
8
+ async function readOpenCodeConversationDatabase({ databasePath, conversationId, onMessage, signal } = {}) {
9
+ if (!path.isAbsolute(databasePath || "") || !/^ses_[a-zA-Z0-9_]{1,256}$/u.test(conversationId || "") ||
10
+ typeof onMessage !== "function") throw new TypeError("OpenCode inspection requires its exact database, conversation and message reader.");
11
+ const unavailable = () => new Error("OpenCode native storage could not be inspected completely. Keep its writers stopped and inspect its original database before retrying.");
12
+ try {
13
+ return await readStoppedNativeDatabase(databasePath, async database => {
14
+ const session = database.prepare("SELECT * FROM session WHERE id = ?").get(conversationId);
15
+ if (!session || session.id !== conversationId || !path.isAbsolute(session.directory || "")) throw unavailable();
16
+ if (database.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'session_input'").get() &&
17
+ database.prepare("SELECT 1 FROM session_input WHERE session_id = ? AND promoted_seq IS NULL LIMIT 1").get(conversationId)) {
18
+ throw new Error("OpenCode has an unpromoted native request. Inspect it before restoring continuity; no request was repeated.");
19
+ }
20
+ const output = createNativeHistoryExport(async ({ message }) => onMessage(normalizedMessageRows([message])[0]), { signal });
21
+ const parts = database.prepare("SELECT id, message_id, session_id, data FROM part WHERE message_id = ? ORDER BY id");
22
+ for (const row of database.prepare("SELECT id, session_id, data FROM message WHERE session_id = ? ORDER BY time_created, id").iterate(conversationId)) {
23
+ signal?.throwIfAborted();
24
+ if (!/^msg_[a-zA-Z0-9_]{1,256}$/u.test(row.id)) throw unavailable();
25
+ let bytes = Buffer.byteLength(row.data);
26
+ if (bytes > 64 * 1024 * 1024) throw unavailable();
27
+ const data = JSON.parse(row.data);
28
+ if (!data || typeof data !== "object" || Array.isArray(data) || !["user", "assistant"].includes(data.role)) throw unavailable();
29
+ const content = [];
30
+ for (const part of parts.iterate(row.id)) {
31
+ signal?.throwIfAborted();
32
+ bytes += Buffer.byteLength(part.data);
33
+ if (bytes > 64 * 1024 * 1024 || part.session_id !== conversationId || part.message_id !== row.id ||
34
+ !/^prt_[a-zA-Z0-9_]{1,256}$/u.test(part.id)) throw unavailable();
35
+ const value = JSON.parse(part.data);
36
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw unavailable();
37
+ content.push({ ...value, id: part.id, sessionID: part.session_id, messageID: part.message_id });
38
+ }
39
+ // OpenCode's MessageV2 storage projection: native columns own IDs.
40
+ await output.emit({ type: "message", message: { info: { ...data, id: row.id, sessionID: row.session_id }, parts: content }, text: [] });
41
+ }
42
+ return { session, ...output.complete() };
43
+ }, { signal });
44
+ } catch (error) {
45
+ if (signal?.aborted) throw signal.reason;
46
+ if (String(error?.message || "").startsWith("OpenCode ") ||
47
+ String(error?.message || "").startsWith("Native storage ")) throw error;
48
+ throw unavailable();
49
+ }
50
+ }
51
+
52
+ // Native preservation uses the storage API independently of the saved worktree.
53
+ // Status still comes from the exact directory-scoped control client.
54
+ async function inspectOpenCodeConversationFamily(storageClient, controlClient, binding, { signal } = {}) {
55
+ const queue = [binding.conversationId];
56
+ const seen = new Set();
57
+ const records = [];
58
+ for (let index = 0; index < queue.length; index += 1) {
59
+ signal?.throwIfAborted();
60
+ const conversationId = queue[index];
61
+ if (seen.has(conversationId) || queue.length > 1000) throw new Error("OpenCode native family is cyclic or exceeds its inventory limit.");
62
+ seen.add(conversationId);
63
+ let native;
64
+ try { native = await storageClient.readConversationStorage(conversationId, { signal }); }
65
+ catch (error) { if (error.statusCode === 404 && index === 0) return []; throw error; }
66
+ if (native?.id !== conversationId || native?.directory !== binding.workdir ||
67
+ (await controlClient.sessionStatus(conversationId, { signal })).type !== "idle") {
68
+ throw new Error("OpenCode retirement requires an idle native family in the exact saved directory.");
69
+ }
70
+ records.push({ conversationId, workdir: native.directory,
71
+ ...(Number.isFinite(native.time?.created) ? { createdAt: new Date(native.time.created).toISOString() } : {}),
72
+ ...(Number.isFinite(native.time?.updated) ? { updatedAt: new Date(native.time.updated).toISOString() } : {}) });
73
+ const children = await storageClient.listConversationChildren(conversationId, { signal });
74
+ for (const child of children) {
75
+ if (child.directory !== binding.workdir) throw new Error("OpenCode child directory differs from its saved owner.");
76
+ queue.push(child.id);
77
+ }
78
+ }
79
+ return records.sort((left, right) => left.conversationId.localeCompare(right.conversationId));
80
+ }
81
+
82
+ async function exportOpenCodeNativeHistory(storageClient, controlClient, id, onRecord, { signal: inputSignal } = {}) {
83
+ const deadline = AbortSignal.timeout(300_000);
84
+ const signal = inputSignal ? AbortSignal.any([inputSignal, deadline]) : deadline;
85
+ const output = createNativeHistoryExport(onRecord, { signal });
86
+ const info = await storageClient.readConversationStorage(id, { signal });
87
+ if (info?.id !== id || (await controlClient.sessionStatus(id, { signal })).type !== "idle") {
88
+ throw new Error("OpenCode native export requires the exact idle conversation.");
89
+ }
90
+ await output.emit({ type: "thread", thread: info, text: [] });
91
+ const messageIds = new Set();
92
+ const cursors = new Set();
93
+ let before = "";
94
+ let pages = 0;
95
+ while (true) {
96
+ signal.throwIfAborted();
97
+ if (++pages > 20_000) throw new Error("OpenCode native export exceeded its page limit; history was not retired.");
98
+ const response = await storageClient.readConversationStoragePage(id, { before, signal });
99
+ if (!Array.isArray(response?.data) || response.data.length > 1 ||
100
+ (response.nextCursor !== null && (typeof response.nextCursor !== "string" || !response.nextCursor || response.nextCursor.length > 8192)) ||
101
+ (response.data.length === 0 && response.nextCursor !== null)) {
102
+ throw new Error("OpenCode returned an incomplete or invalid native message page.");
103
+ }
104
+ for (const message of response.data) {
105
+ const info = message?.info;
106
+ if (!/^msg_[a-zA-Z0-9_]{1,256}$/u.test(info?.id) || info.sessionID !== id ||
107
+ !["user", "assistant"].includes(info.role) || !Array.isArray(message.parts) || messageIds.has(info.id)) {
108
+ throw new Error("OpenCode returned an invalid or duplicate native message.");
109
+ }
110
+ messageIds.add(info.id);
111
+ const content = openCodeAssistantMessageText({ content: message.parts });
112
+ await output.emit({ type: "message", message, text: content ? [{ role: info.role, text: content,
113
+ branchId: id, messageId: info.id,
114
+ ...(info.parentID ? { parentMessageId: info.parentID } : {}),
115
+ ...(Number.isFinite(info.time?.created) ? { createdAt: new Date(info.time.created).toISOString() } : {}),
116
+ ...(Number.isFinite(info.time?.completed) ? { completedAt: new Date(info.time.completed).toISOString() } : {}),
117
+ ...(info.modelID || info.model?.modelID ? { modelId: info.modelID || info.model.modelID } : {}),
118
+ ...(info.providerID || info.model?.providerID ? { modelProviderId: info.providerID || info.model.providerID } : {}),
119
+ ...(info.agent ? { agent: info.agent } : {}) }] : [] });
120
+ }
121
+ if (response.nextCursor === null) break;
122
+ if (cursors.has(response.nextCursor)) throw new Error("OpenCode repeated a native history cursor.");
123
+ cursors.add(response.nextCursor);
124
+ before = response.nextCursor;
125
+ }
126
+ return output.complete();
127
+ }
128
+
129
+ /** Retire the exact native family after the host's durable preservation. */
130
+ async function retireOpenCodeConversationHistory(storageClient, controlClient, binding, options = {}) {
131
+ const inspect = () => inspectOpenCodeConversationFamily(storageClient, controlClient, binding, { signal: options.signal });
132
+ return retireNativeConversation({ binding, inspect, beforeDelete: options.beforeDelete,
133
+ readConversation: async (id) => ({ info: await storageClient.readConversationStorage(id), messages: (await controlClient.messages(id)).data }),
134
+ exportConversation: (id, onRecord) => exportOpenCodeNativeHistory(storageClient, controlClient, id, onRecord, { signal: options.signal }),
135
+ remove: () => controlClient.deleteSession(binding.conversationId) });
136
+ }
137
+
138
+ export {
139
+ readOpenCodeConversationDatabase,
140
+ retireOpenCodeConversationHistory
141
+ };
@@ -0,0 +1,79 @@
1
+ // One native OpenCode session: its model, its selection, the shared server
2
+ // bound to its directory, and the start that creates or resumes it.
3
+ import { normalizeNullableText } from "./normalize.js";
4
+
5
+ /** The shared server with its client bound to one native directory. */
6
+ export function openCodeServerForDirectory(server, workdir) {
7
+ return Object.freeze({
8
+ ...server,
9
+ client: server.client.forDirectory(workdir)
10
+ });
11
+ }
12
+
13
+ /** The native model a selection names, or the one an execution profile overrides it with. */
14
+ export function openCodeModel(selection = {}, executionProfile = null) {
15
+ const modelId = normalizeNullableText(executionProfile?.model) || normalizeNullableText(selection.modelId);
16
+ const variantId = executionProfile
17
+ ? normalizeNullableText(executionProfile.thinking)
18
+ : normalizeNullableText(selection.variantId);
19
+ return {
20
+ id: modelId,
21
+ providerID: normalizeNullableText(selection.modelProviderId),
22
+ ...(variantId ? { variant: variantId } : {})
23
+ };
24
+ }
25
+
26
+ /** Whether two selections name the same agent, model, provider and variant. */
27
+ export function sameOpenCodeSelection(left, right) {
28
+ return ["agentId", "modelId", "modelProviderId", "variantId"]
29
+ .every((name) => normalizeNullableText(left?.[name]) === normalizeNullableText(right?.[name]));
30
+ }
31
+
32
+ /**
33
+ * Native selection and identity publication share the same pending operation.
34
+ * @param {object} target
35
+ * @param {{ selection: object, model: object, workdir?: string, signal?: AbortSignal, invalidIdentity?: () => Error,
36
+ * identity: { write(nativeId: string): unknown, publish(nativeId: string): unknown } }} session
37
+ */
38
+ export async function ensureOpenCodeSession(target, { selection, model, workdir = target.workdir, identity, signal,
39
+ invalidIdentity = () => new Error("OpenCode did not return a native conversation ID.") }) {
40
+ if (target.upstreamStart) {
41
+ return target.upstreamStart;
42
+ }
43
+ const start = Promise.resolve().then(async () => {
44
+ await target.server.client.prepareDirectory({ signal });
45
+ let upstream = (
46
+ target.upstream &&
47
+ sameOpenCodeSelection(target.upstreamSelection, selection)
48
+ ) ? target.upstream : null;
49
+ if (!upstream && target.upstreamSessionId) {
50
+ upstream = await target.server.client.readSession(target.upstreamSessionId, { signal });
51
+ await target.server.client.switchModel(target.upstreamSessionId, model, { signal });
52
+ await target.server.client.switchAgent(target.upstreamSessionId, selection.agentId, { signal });
53
+ } else if (!upstream) {
54
+ upstream = await target.server.client.createSession({
55
+ agent: selection.agentId,
56
+ location: { directory: workdir },
57
+ model
58
+ }, { signal });
59
+ }
60
+ const nativeId = normalizeNullableText(upstream?.id);
61
+ if (!nativeId) {
62
+ throw invalidIdentity();
63
+ }
64
+ await identity.write(nativeId);
65
+ target.upstreamSessionId = nativeId;
66
+ target.upstream = upstream;
67
+ target.upstreamSelection = { ...selection };
68
+ await identity.publish(nativeId);
69
+ return target;
70
+ });
71
+ target.upstreamStart = start;
72
+ try {
73
+ return await start;
74
+ } finally {
75
+ if (target.upstreamStart === start) {
76
+ delete target.upstreamStart;
77
+ }
78
+ }
79
+ }
@@ -1,3 +1,4 @@
1
+ import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
1
2
  import { randomBytes } from "node:crypto";
2
3
  import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
4
  import net from "node:net";
@@ -7,9 +8,9 @@ import { createOpenCodeServerClient, readBoundedResponse } from "./openCodeClien
7
8
  import { createLocalConversationExecution } from "./localExecution.js";
8
9
  import { normalizeNullableText } from "./normalize.js";
9
10
  import { DISCOVERY_TOOL_DESCRIPTORS } from "../lib/serviceToolCatalog.js";
10
- export { createOpenCodeSharedRuntime, openCodeServerForDirectory, readOpenCodeEnvironments, openCodeEnvironmentForDirectory,
11
- openCodeEnvironmentForSession, assertOpenCodeModelProvider, limitOpenCodeModelOutput,
12
- ensureOpenCodeSession, openCodeModel, sameOpenCodeSelection } from "./openCodeRuntime.js";
11
+ export { createOpenCodeSharedRuntime, readOpenCodeEnvironments, openCodeEnvironmentForDirectory,
12
+ openCodeEnvironmentForSession, assertOpenCodeModelProvider, limitOpenCodeModelOutput } from "./openCodeRuntime.js";
13
+ export { openCodeServerForDirectory, ensureOpenCodeSession, openCodeModel, sameOpenCodeSelection } from "./openCodeNativeSession.js";
13
14
  export { default as createOpenCodeConversationPlugin } from "./openCodePlugin.js";
14
15
 
15
16
  export const OPENCODE_EXPECTED_VERSION = "1.18.31";
@@ -26,7 +27,11 @@ const OPENCODE_ZEN_MODELS_URL = "https://opencode.ai/zen/v1/models";
26
27
  const OPENCODE_ZEN_PROVIDER_ID = "opencode";
27
28
  const OPENCODE_ZEN_PUBLIC_API_KEY = "public";
28
29
 
29
- const text = normalizeNullableText;
30
+ /** A server start whose caller records no execution identity. */
31
+ function ignoreServerStart() {}
32
+
33
+ /** A server whose caller keeps no state after an unexpected exit. */
34
+ function ignoreServerFailure() {}
30
35
 
31
36
  export function openCodeProcessEnvironment(baseEnv = {}, {
32
37
  cacheRoot = "", dbPath = "", inlineConfig = "", outputTokenMax = 0, packageInstalls = true, password = ""
@@ -44,7 +49,7 @@ export function openCodeProcessEnvironment(baseEnv = {}, {
44
49
  npm_config_cache: path.join(cacheRoot, "npm-offline"),
45
50
  npm_config_offline: "true"
46
51
  }),
47
- NO_PROXY: [text(baseEnv.NO_PROXY), OPENCODE_HOST, "localhost", "::1"]
52
+ NO_PROXY: [normalizeNullableText(baseEnv.NO_PROXY), OPENCODE_HOST, "localhost", "::1"]
48
53
  .filter(Boolean)
49
54
  .join(","),
50
55
  OPENCODE_DB: dbPath,
@@ -124,9 +129,9 @@ async function waitForOpenCodeReady({
124
129
  });
125
130
  signal?.throwIfAborted();
126
131
  if (health?.healthy === true) {
127
- if (expectedVersion && text(health.version) !== expectedVersion) {
132
+ if (expectedVersion && normalizeNullableText(health.version) !== expectedVersion) {
128
133
  const error = new Error(
129
- `OpenCode ${text(health.version) || "(unknown)"} is installed; this host requires ${expectedVersion}.`
134
+ `OpenCode ${normalizeNullableText(health.version) || "(unknown)"} is installed; this host requires ${expectedVersion}.`
130
135
  );
131
136
  error.code = "assistant_opencode_version_mismatch";
132
137
  throw error;
@@ -161,11 +166,11 @@ export function openCodeConversationAgent({ nativeTools = false, tools = false }
161
166
  }
162
167
 
163
168
  export function openCodeConversationAgents({ sessionEnvironmentRegistry = "" } = {}) {
164
- const guarded = Boolean(text(sessionEnvironmentRegistry));
169
+ const guarded = Boolean(normalizeNullableText(sessionEnvironmentRegistry));
165
170
  return Object.fromEntries([false, true].flatMap(nativeTools => [false, true].map(tools => [
166
171
  openCodeConversationAgent({ nativeTools, tools }), {
167
172
  hidden: true, mode: "primary",
168
- // Keep native definitions behind approval, as in the original host agents.
173
+ // Keep native definitions behind approval.
169
174
  // The registered plugin rejects every ungranted native call before execution.
170
175
  permission: nativeTools || tools || guarded ? agentPermission(!nativeTools && guarded, nativeTools, tools) : "deny"
171
176
  }
@@ -181,12 +186,32 @@ function agentPermission(ask, nativeTools, tools) {
181
186
  ...(tools ? actionPermission("allow") : ask ? actionPermission("deny") : {}), ...(ask ? { doom_loop: "deny" } : {}) };
182
187
  }
183
188
 
184
- /** Shared conversation configuration around the extracted server lifecycle. */
189
+ /** @typedef {import("./localExecution.js").ConversationExecution} ConversationExecution */
190
+
191
+ /**
192
+ * Starts the OpenCode server of one conversation runtime: its private credential home, its configuration for the selected
193
+ * connection, and the environment it passes on from the application's.
194
+ * @param {object} options
195
+ * @param {string} [options.command]
196
+ * @param {ConversationExecution} [options.execution]
197
+ * @param {import("@jskit-ai/kernel/server/types").RuntimeEnv} options.env
198
+ * @param {string} options.workdir
199
+ * @param {string} options.stateDirectory
200
+ * @param {string} options.databasePath
201
+ * @param {string} options.sessionEnvironmentRegistry
202
+ * @param {Record<string, any>} options.connection
203
+ * @param {number} [options.maxOutputTokens]
204
+ * @param {(executionId: string, processDirectory: string) => unknown} [options.onStarted]
205
+ * @param {(error: Error) => unknown} [options.onFailure]
206
+ * @param {object} [options.executionLimits]
207
+ * @param {AbortSignal} [options.signal]
208
+ */
185
209
  export async function createOpenCodeConversationServer({
186
- command = "opencode", execution = createLocalConversationExecution(), env = process.env,
210
+ command = "opencode", execution = createLocalConversationExecution(), env,
187
211
  workdir, stateDirectory, databasePath, sessionEnvironmentRegistry, connection, maxOutputTokens,
188
- onStarted, onFailure, executionLimits, signal
189
- } = {}) {
212
+ onStarted = ignoreServerStart, onFailure = ignoreServerFailure, executionLimits, signal
213
+ }) {
214
+ if (!env) throw new TypeError("An OpenCode conversation's host requires the application's environment, such as process.env, to start OpenCode.");
190
215
  await mkdir(stateDirectory, { recursive: true, mode: 0o700 });
191
216
  const directory = await mkdtemp(path.join(stateDirectory, "process-"));
192
217
  const model = `${connection.providerId}/${connection.model}`;
@@ -221,17 +246,39 @@ export async function createOpenCodeConversationServer({
221
246
  JSKIT_OPENCODE_TOOL_SCHEMAS: JSON.stringify(openCodeApplicationToolSchemas)
222
247
  });
223
248
  return createOpenCodeServerProcess({ command, execution, env: environment, workdir, privateRoot: directory,
224
- directory: workdir, connections: [connection], onStarted: id => onStarted?.(id, directory),
249
+ directory: workdir, connections: [connection], onStarted: id => onStarted(id, directory),
225
250
  onFailure, executionLimits, signal });
226
251
  }
227
252
 
228
- /** Shared server lifecycle for standalone and managed hosts. */
253
+ /**
254
+ * Shared server lifecycle for standalone and managed hosts.
255
+ * @param {object} options
256
+ * @param {string} [options.command]
257
+ * @param {ConversationExecution} [options.execution]
258
+ * @param {import("@jskit-ai/kernel/server/types").RuntimeEnv} options.env
259
+ * @param {string} options.workdir
260
+ * @param {string} options.privateRoot
261
+ * @param {string} [options.directory]
262
+ * @param {Record<string, any>[]} [options.connections]
263
+ * @param {number} [options.port]
264
+ * @param {typeof fetch} [options.fetchImpl]
265
+ * @param {boolean} [options.allowAttachmentDirectories]
266
+ * @param {string} [options.expectedVersion]
267
+ * @param {number} [options.readinessTimeoutMs]
268
+ * @param {(executionId: string) => unknown} [options.onStarted]
269
+ * @param {(error: Error) => unknown} [options.onFailure]
270
+ * @param {object} [options.executionLimits]
271
+ * @param {AbortSignal} [options.signal]
272
+ */
229
273
  export async function createOpenCodeServerProcess({
230
- command = "opencode", execution = createLocalConversationExecution(), env = process.env,
274
+ command = "opencode", execution = createLocalConversationExecution(), env,
231
275
  workdir, privateRoot, directory = "", connections = [], port = 0, fetchImpl = globalThis.fetch,
232
276
  allowAttachmentDirectories = false, expectedVersion = OPENCODE_EXPECTED_VERSION, readinessTimeoutMs = OPENCODE_READY_TIMEOUT_MS,
233
- onStarted, onFailure, executionLimits, signal
234
- } = {}) {
277
+ onStarted = ignoreServerStart, onFailure = ignoreServerFailure, executionLimits, signal
278
+ }) {
279
+ if (!env) {
280
+ throw new TypeError("createOpenCodeServerProcess requires the application's environment, such as process.env.");
281
+ }
235
282
  if (!path.isAbsolute(workdir || "") || !path.isAbsolute(privateRoot || "")) {
236
283
  throw new TypeError("OpenCode server processes require absolute working and private roots.");
237
284
  }
@@ -250,6 +297,7 @@ export async function createOpenCodeServerProcess({
250
297
  const logPath = path.join(privateRoot, "opencode-server.log");
251
298
  const processHandle = {
252
299
  get exited() { return stopped || native?.running === false; },
300
+ // readable-check: allow optional-call - optional operation: an execution host's started process may not keep its logs
253
301
  readLogs: () => native?.readLogs?.() || { stderr: "", stdout: "" },
254
302
  async stop() {
255
303
  const proof = await execution.stop(executionId, {
@@ -293,17 +341,17 @@ export async function createOpenCodeServerProcess({
293
341
  native = await execution.start({ command, cwd: workdir, env: environment, limits: executionLimits,
294
342
  logPath, args: ["serve", "--hostname", OPENCODE_HOST, "--port", String(selectedPort), "--mdns=false"] });
295
343
  executionId = native.id;
296
- await onStarted?.(native.id);
344
+ await onStarted(native.id);
297
345
  const fail = error => {
298
346
  if (closing) return;
299
347
  unexpectedExit.resolve(error);
300
- Promise.resolve().then(() => onFailure?.(error)).catch(() => {});
348
+ Promise.resolve().then(() => onFailure(error)).catch(() => {});
301
349
  };
302
350
  native.exited.then(() => fail(new Error("The owned OpenCode process exited."))).catch(fail);
303
351
  const health = await waitForOpenCodeReady({ client, expectedVersion, processHandle, timeoutMs: readinessTimeoutMs, signal });
304
352
  const authenticated = new Set();
305
353
  for (const connection of connections) {
306
- const providerId = text(connection?.providerId);
354
+ const providerId = normalizeNullableText(connection?.providerId);
307
355
  const key = String(connection?.apiKey || "");
308
356
  if (!providerId || !key || authenticated.has(providerId)) {
309
357
  continue;
@@ -311,22 +359,32 @@ export async function createOpenCodeServerProcess({
311
359
  await client.authenticateApiKey(providerId, key, { signal });
312
360
  authenticated.add(providerId);
313
361
  }
314
- return Object.freeze({ client, executionId: native.id, privateRoot, pid: native.pid, port: selectedPort,
315
- environment, health: Object.freeze({ ...health }), modelProviderIds: Object.freeze([...authenticated]),
316
- readLogs: processHandle.readLogs, stop,
362
+ /** The native terminal's arguments for one conversation in an absolute directory. */
363
+ function attachArguments({ conversationId, directory }) {
364
+ if (!conversationId || !path.isAbsolute(directory || "")) throw new TypeError("An OpenCode terminal requires a conversation and absolute directory.");
365
+ return ["attach", baseUrl, "--dir", directory, "--session", conversationId, "--pure"];
366
+ }
367
+ return Object.freeze({
368
+ client,
369
+ executionId: native.id,
370
+ privateRoot,
371
+ pid: native.pid,
372
+ port: selectedPort,
373
+ environment,
374
+ health: Object.freeze({ ...health }),
375
+ modelProviderIds: Object.freeze([...authenticated]),
376
+ readLogs: processHandle.readLogs,
377
+ stop,
317
378
  // Resolves when the process exits without being stopped.
318
379
  unexpectedExit: unexpectedExit.promise,
319
- attachArguments({ conversationId, directory }) {
320
- if (!conversationId || !path.isAbsolute(directory || "")) throw new TypeError("An OpenCode terminal requires a conversation and absolute directory.");
321
- return ["attach", baseUrl, "--dir", directory, "--session", conversationId, "--pure"];
322
- }
380
+ attachArguments
323
381
  });
324
382
  } catch (error) {
325
383
  error.code ||= "assistant_opencode_start_failed";
326
384
  executionId ||= error.executionId;
327
385
  error.executionId = executionId;
328
386
  if (!native && executionId) {
329
- try { await onStarted?.(executionId); }
387
+ try { await onStarted(executionId); }
330
388
  catch (bindingError) { error.bindingError = bindingError; }
331
389
  }
332
390
  try { error.details = processHandle.readLogs(); }
@@ -342,12 +400,10 @@ export async function createOpenCodeServerProcess({
342
400
  }
343
401
 
344
402
  function openCodeZenCatalogError(cause = null) {
345
- const error = new Error("OpenCode Zen's current model list could not be read. Try again.", {
346
- cause
403
+ const error = new AppError(503, "OpenCode Zen's current model list could not be read. Try again.", {
404
+ cause, code: "assistant_opencode_zen_catalog_unavailable"
347
405
  });
348
- error.code = "assistant_opencode_zen_catalog_unavailable";
349
406
  error.retryable = true;
350
- error.statusCode = 503;
351
407
  return error;
352
408
  }
353
409
 
@@ -374,7 +430,7 @@ export async function readOpenCodeZenModelIds({
374
430
  if (!Array.isArray(payload?.data) || payload.data.length === 0) {
375
431
  throw new Error("OpenCode Zen returned an empty model list.");
376
432
  }
377
- const ids = payload.data.map((model) => text(model?.id));
433
+ const ids = payload.data.map((model) => normalizeNullableText(model?.id));
378
434
  const uniqueIds = new Set(ids);
379
435
  if (ids.some((id) => !id) || uniqueIds.size !== ids.length) {
380
436
  throw new Error("OpenCode Zen returned an invalid model list.");
@@ -398,9 +454,9 @@ export async function readOpenCodeCatalog({
398
454
  privateRoot = "",
399
455
  workdir = ""
400
456
  } = {}) {
401
- const normalizedPrivateRoot = path.resolve(text(privateRoot));
402
- const normalizedWorkdir = path.resolve(text(workdir));
403
- if (!text(privateRoot) || !text(workdir)) {
457
+ const normalizedPrivateRoot = path.resolve(normalizeNullableText(privateRoot));
458
+ const normalizedWorkdir = path.resolve(normalizeNullableText(workdir));
459
+ if (!normalizeNullableText(privateRoot) || !normalizeNullableText(workdir)) {
404
460
  throw new TypeError("OpenCode catalogue reads require private and working roots.");
405
461
  }
406
462
  const authRoot = path.join(normalizedPrivateRoot, "data", "opencode");
@@ -454,11 +510,11 @@ export async function verifyOpenCodeApiKey({
454
510
  workdir = ""
455
511
  } = {}) {
456
512
  const key = String(apiKey || "");
457
- const providerId = text(modelProviderId);
458
- const selectedModelId = text(modelId);
459
- const normalizedPrivateRoot = path.resolve(text(privateRoot));
460
- const normalizedWorkdir = path.resolve(text(workdir));
461
- if (!key || !providerId || !selectedModelId || !text(privateRoot) || !text(workdir)) {
513
+ const providerId = normalizeNullableText(modelProviderId);
514
+ const selectedModelId = normalizeNullableText(modelId);
515
+ const normalizedPrivateRoot = path.resolve(normalizeNullableText(privateRoot));
516
+ const normalizedWorkdir = path.resolve(normalizeNullableText(workdir));
517
+ if (!key || !providerId || !selectedModelId || !normalizeNullableText(privateRoot) || !normalizeNullableText(workdir)) {
462
518
  throw new TypeError("OpenCode API-key verification requires a key, provider, model, and isolated roots.");
463
519
  }
464
520
  try {
@@ -500,26 +556,26 @@ export async function verifyOpenCodeApiKey({
500
556
  timeout: OPENCODE_VERIFY_TIMEOUT_MS
501
557
  });
502
558
  } catch {
503
- const error = new Error("OpenCode API-key verification could not run.");
504
- error.code = "assistant_opencode_key_verification_unavailable";
559
+ const error = new AppError(503, "OpenCode API-key verification could not run.", {
560
+ code: "assistant_opencode_key_verification_unavailable"
561
+ });
505
562
  error.retryable = true;
506
- error.statusCode = 503;
507
563
  throw error;
508
564
  }
509
565
  if (result?.ok !== true) {
510
566
  const exitCode = Number(result?.exitCode);
511
567
  const unavailable = result?.timedOut === true ||
512
568
  result?.retryable === true ||
513
- Boolean(text(result?.code)) ||
569
+ Boolean(normalizeNullableText(result?.code)) ||
514
570
  (Number.isSafeInteger(exitCode) && exitCode !== 1);
515
- const error = new Error(unavailable
516
- ? "OpenCode API-key verification could not run."
517
- : "OpenCode rejected the API-key verification request.");
518
- error.code = unavailable
519
- ? "assistant_opencode_key_verification_unavailable"
520
- : "assistant_opencode_key_verification_failed";
571
+ const error = unavailable
572
+ ? new AppError(503, "OpenCode API-key verification could not run.", {
573
+ code: "assistant_opencode_key_verification_unavailable"
574
+ })
575
+ : new AppError(422, "OpenCode rejected the API-key verification request.", {
576
+ code: "assistant_opencode_key_verification_failed"
577
+ });
521
578
  error.retryable = unavailable;
522
- error.statusCode = unavailable ? 503 : 422;
523
579
  throw error;
524
580
  }
525
581
  return Object.freeze({ ok: true });