@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.
- package/examples/conversation/package.json +5 -5
- package/package.json +18 -7
- package/src/client/browser.js +21 -0
- package/src/client/conversation/AssistantAttachmentQueue.vue +25 -108
- package/src/client/conversation/AssistantComposerActions.vue +1 -1
- package/src/client/conversation/AssistantConversationElement.vue +14 -52
- package/src/client/conversation/AssistantGoalControl.vue +13 -10
- package/src/client/conversation/AssistantPromptInput.vue +6 -91
- package/src/client/conversation/AssistantTranscript.css +505 -0
- package/src/client/conversation/AssistantTranscript.vue +24 -1197
- package/src/client/conversation/attachmentFiles.js +158 -0
- package/src/client/conversation/attachmentQueueItems.js +121 -0
- package/src/client/conversation/attachmentUploadRows.js +265 -0
- package/src/client/conversation/conversationDisplay.js +54 -0
- package/src/client/conversation/messageDelivery.js +13 -9
- package/src/client/conversation/retainedConversation.js +14 -3
- package/src/client/conversation/submitText.js +13 -2
- package/src/client/conversation/transcriptEntries.js +225 -0
- package/src/client/conversation/useAssistantAttachments.js +86 -388
- package/src/client/conversation/useAssistantQuestions.js +16 -3
- package/src/client/conversation/useAssistantSuggestions.js +16 -2
- package/src/client/conversation/useScrollToBottom.js +17 -19
- package/src/client/conversation/useTextareaAutoGrow.js +115 -0
- package/src/client/conversation/useTranscriptScrollFollow.js +525 -0
- package/src/client/lib/assistantApi.js +192 -161
- package/src/client/types.js +110 -0
- package/src/server/conversation/attachments.js +21 -6
- package/src/server/conversation/boundedToolLoop.js +1 -0
- package/src/server/conversation/claudeClient.js +71 -20
- package/src/server/conversation/claudeCommandRun.js +191 -0
- package/src/server/conversation/claudeHistory.js +7 -0
- package/src/server/conversation/claudeNativeTurn.js +708 -0
- package/src/server/conversation/claudeProcess.js +186 -112
- package/src/server/conversation/claudeProcessStart.js +214 -0
- package/src/server/conversation/claudePromptDelivery.js +183 -0
- package/src/server/conversation/claudeSessionCleanup.js +248 -0
- package/src/server/conversation/claudeTools.js +15 -3
- package/src/server/conversation/claudeTurn.js +212 -1388
- package/src/server/conversation/codexAccountReader.js +168 -0
- package/src/server/conversation/codexAuthPreflight.js +5 -4
- package/src/server/conversation/codexClient.js +18 -6
- package/src/server/conversation/codexConfiguration.js +8 -844
- package/src/server/conversation/codexConversationStops.js +377 -0
- package/src/server/conversation/codexConversationStore.js +119 -85
- package/src/server/conversation/codexConversationTurns.js +378 -0
- package/src/server/conversation/codexConversations.js +134 -866
- package/src/server/conversation/codexDetachedConversation.js +203 -0
- package/src/server/conversation/codexDetachedTurn.js +145 -104
- package/src/server/conversation/codexEvents.js +5 -200
- package/src/server/conversation/codexGoalCommands.js +14 -4
- package/src/server/conversation/codexHelperExecution.js +57 -57
- package/src/server/conversation/codexHelperThreadLedger.js +30 -288
- package/src/server/conversation/codexHelperThreadRecord.js +304 -0
- package/src/server/conversation/codexHelperThreadRecovery.js +54 -76
- package/src/server/conversation/codexHelperThreads.js +94 -93
- package/src/server/conversation/codexHistoryAdapter.js +13 -8
- package/src/server/conversation/codexIsolation.js +672 -0
- package/src/server/conversation/codexLiveItems.js +379 -0
- package/src/server/conversation/codexMessageCommands.js +68 -286
- package/src/server/conversation/codexMessageDelivery.js +46 -37
- package/src/server/conversation/codexModelCatalog.js +86 -7
- package/src/server/conversation/codexNotificationObserver.js +81 -88
- package/src/server/conversation/codexNotificationQueue.js +21 -10
- package/src/server/conversation/codexObservationControl.js +105 -89
- package/src/server/conversation/codexOutput.js +84 -419
- package/src/server/conversation/codexProcess.js +29 -1053
- package/src/server/conversation/codexProcessLeader.js +1 -0
- package/src/server/conversation/codexPromptDispatch.js +241 -0
- package/src/server/conversation/codexProvider.js +147 -1807
- package/src/server/conversation/codexProviderAuth.js +348 -0
- package/src/server/conversation/codexProviderInvalidation.js +371 -0
- package/src/server/conversation/codexProviderInventories.js +437 -0
- package/src/server/conversation/codexProviderOwner.js +149 -957
- package/src/server/conversation/codexProviderRelease.js +465 -0
- package/src/server/conversation/codexProviderSelection.js +26 -15
- package/src/server/conversation/codexProviderThreads.js +698 -0
- package/src/server/conversation/codexProviderTurns.js +453 -0
- package/src/server/conversation/codexReasoning.js +32 -23
- package/src/server/conversation/codexRenewalCommands.js +18 -6
- package/src/server/conversation/codexRenewalThreads.js +7 -22
- package/src/server/conversation/codexResultSettlement.js +70 -88
- package/src/server/conversation/codexRunJournal.js +91 -97
- package/src/server/conversation/codexRuntime.js +426 -0
- package/src/server/conversation/codexRuntimeInventory.js +113 -0
- package/src/server/conversation/codexRuntimeLifecycle.js +39 -13
- package/src/server/conversation/codexRuntimeStart.js +431 -0
- package/src/server/conversation/codexRuntimeStop.js +324 -0
- package/src/server/conversation/codexSessionCleanup.js +25 -10
- package/src/server/conversation/codexSessionReconciliation.js +21 -7
- package/src/server/conversation/codexThreadReadiness.js +49 -52
- package/src/server/conversation/codexThreadSnapshot.js +210 -0
- package/src/server/conversation/codexThreadStatus.js +48 -62
- package/src/server/conversation/codexTurn.js +171 -55
- package/src/server/conversation/codexTurnActivity.js +210 -0
- package/src/server/conversation/codexTurnControl.js +44 -48
- package/src/server/conversation/codexTurnEndings.js +610 -0
- package/src/server/conversation/codexTurnRecovery.js +97 -848
- package/src/server/conversation/codexTurnState.js +138 -111
- package/src/server/conversation/completedResponses.js +227 -0
- package/src/server/conversation/configuration.js +2 -2
- package/src/server/conversation/continuity.js +43 -6
- package/src/server/conversation/conversationDelivery.js +394 -0
- package/src/server/conversation/conversationEntries.js +365 -0
- package/src/server/conversation/conversationEvents.js +192 -0
- package/src/server/conversation/conversationGoals.js +187 -0
- package/src/server/conversation/conversationHandles.js +237 -0
- package/src/server/conversation/conversationReads.js +206 -0
- package/src/server/conversation/conversationRefusals.js +58 -0
- package/src/server/conversation/conversationReplacement.js +254 -0
- package/src/server/conversation/conversationSending.js +273 -0
- package/src/server/conversation/conversationStopping.js +121 -0
- package/src/server/conversation/conversationTurnDelivery.js +413 -0
- package/src/server/conversation/conversationTurns.js +511 -0
- package/src/server/conversation/deliveryTiming.js +25 -3
- package/src/server/conversation/fileStorage.js +11 -6
- package/src/server/conversation/hookBridge.js +14 -3
- package/src/server/conversation/index.js +1 -1
- package/src/server/conversation/localExecution.js +82 -625
- package/src/server/conversation/nativeCommandCallbacks.js +10 -0
- package/src/server/conversation/nativeConversationLifecycle.js +379 -0
- package/src/server/conversation/nativeHistoryExport.js +27 -18
- package/src/server/conversation/normalize.js +9 -0
- package/src/server/conversation/openCodeClient.js +295 -366
- package/src/server/conversation/openCodeNativeHistory.js +141 -0
- package/src/server/conversation/openCodeNativeSession.js +79 -0
- package/src/server/conversation/openCodeProcess.js +109 -53
- package/src/server/conversation/openCodeProcessOwner.js +565 -0
- package/src/server/conversation/openCodeRuntime.js +111 -1814
- package/src/server/conversation/openCodeSessionTurns.js +547 -0
- package/src/server/conversation/openCodeTemporaryConversations.js +394 -0
- package/src/server/conversation/openCodeTools.js +28 -14
- package/src/server/conversation/openCodeTurn.js +80 -74
- package/src/server/conversation/openCodeTurnMonitor.js +388 -0
- package/src/server/conversation/processIdentity.js +582 -0
- package/src/server/conversation/provider.js +21 -6
- package/src/server/conversation/providers/api.js +268 -107
- package/src/server/conversation/providers/claude.js +29 -6
- package/src/server/conversation/providers/claudeDriver.js +325 -250
- package/src/server/conversation/providers/codex.js +69 -33
- package/src/server/conversation/providers/codexDriver.js +503 -1081
- package/src/server/conversation/providers/codexDriverConnection.js +331 -0
- package/src/server/conversation/providers/codexDriverTurns.js +464 -0
- package/src/server/conversation/providers/opencode.js +30 -4
- package/src/server/conversation/providers/opencodeDriver.js +321 -659
- package/src/server/conversation/providers/opencodeStandalone.js +475 -0
- package/src/server/conversation/providers/scoped.js +64 -19
- package/src/server/conversation/runtime.js +171 -2863
- package/src/server/conversation/storage.js +91 -50
- package/src/server/conversation/streams.js +15 -2
- package/src/server/conversation/structuredOutput.js +7 -11
- package/src/server/conversation/systemPrompt.js +13 -1
- package/src/server/conversation/tools.js +47 -15
- package/src/server/conversation/transcript.js +49 -20
- package/src/server/hosts/claudeTurn.js +2 -4
- package/src/server/hosts/codexConfiguration.js +6 -8
- package/src/server/hosts/codexEvents.js +4 -2
- package/src/server/hosts/codexProcess.js +1 -1
- package/src/server/hosts/codexProvider.js +11 -9
- package/src/server/hosts/codexTurn.js +3 -3
- package/src/server/hosts/openCodeClient.js +4 -2
- package/src/server/index.js +0 -2
- package/src/server/lib/aiConnectionClient.js +35 -25
- package/src/server/lib/ndjson.js +1 -0
- package/src/server/lib/providers/anthropicClient.js +15 -6
- package/src/server/lib/providers/common.js +10 -6
- package/src/server/lib/providers/openAiCompatibleClient.js +40 -23
- package/src/server/lib/serviceToolCatalog.js +29 -283
- package/src/server/lib/serviceToolEntries.js +270 -0
- package/src/server/types.js +78 -0
- package/src/shared/assistantResource.js +0 -23
- package/src/shared/conversation/answerChoiceSugar.js +2 -14
- package/src/shared/conversation/messageInputSugar.js +18 -0
- package/src/shared/conversation/numberedQuestionSugar.js +6 -16
- package/src/shared/conversation/pagination.js +1 -1
- package/src/shared/conversation/questions.js +1 -0
- package/src/shared/conversation/streaming.js +1 -0
- package/src/shared/index.js +0 -1
- package/src/testing/conversationStorageContract.js +20 -0
- package/test/aiConnectionClient.test.js +42 -1
- package/test/apiConversationDriver.test.js +217 -38
- package/test/assistantBoundedToolLoop.test.js +1 -1
- package/test/claudeConversationSession.test.js +15 -11
- package/test/claudeInstructionResume.test.js +1 -1
- package/test/claudeProcess.test.js +9 -9
- package/test/claudeTurn.test.js +21 -8
- package/test/codexAccountChange.test.js +5 -3
- package/test/codexConversationSession.test.js +22 -21
- package/test/codexEvents.test.js +3 -1
- package/test/codexHelperDeletion.test.js +3 -7
- package/test/codexIsolation.test.js +6 -6
- package/test/codexModelCatalog.test.js +6 -7
- package/test/codexProcess.test.js +3 -3
- package/test/codexThreadSelection.test.js +4 -1
- package/test/codexTurn.test.js +29 -20
- package/test/conversationApiFacilities.test.js +11 -9
- package/test/conversationAttachments.test.js +3 -3
- package/test/conversationConfiguration.test.js +4 -4
- package/test/conversationNativeStorage.test.js +11 -10
- package/test/conversationReplacement.test.js +13 -13
- package/test/conversationRuntime.test.js +9 -5
- package/test/conversationSession.test.js +78 -22
- package/test/conversationTools.test.js +47 -26
- package/test/fileConversationStorage.test.js +10 -3
- package/test/fixtures/codexHelperThreadLedger.js +3 -3
- package/test/frameworkActions.test.js +33 -18
- package/test/localConversationExecution.test.js +4 -4
- package/test/localExecution.test.js +1 -1
- package/test/openCodeConnectionChange.test.js +3 -2
- package/test/openCodeRuntime.test.js +8 -7
- package/test/openCodeTools.test.js +1 -1
- package/test/opencodeConversationSession.test.js +4 -3
- package/test/serviceToolCatalog.test.js +3 -8
- package/src/server/lib/assistantCompletion.js +0 -190
- 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 */
|