@jskit-ai/assistant-core 0.2.1 → 0.2.3
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/fixtures/responsive-assistant/ConversationFixture.vue +2 -2
- package/fixtures/responsive-assistant/DeliveryFixture.vue +15 -2
- package/locales/en.json +4 -2
- package/package.json +5 -5
- package/src/client/conversation/AssistantConversationElement.vue +8 -1
- package/src/client/conversation/AssistantGoalControl.vue +2 -2
- package/src/client/conversation/AssistantTranscript.vue +41 -11
- package/src/client/conversation/messageDelivery.js +23 -6
- package/src/client/lib/assistantApi.js +5 -0
- package/src/server/conversation/claudeProcess.js +171 -53
- package/src/server/conversation/claudeTurn.js +199 -63
- package/src/server/conversation/codexAuthPreflight.js +3 -0
- package/src/server/conversation/codexConfiguration.js +42 -4
- package/src/server/conversation/codexConversations.js +256 -114
- package/src/server/conversation/codexEvents.js +16 -3
- package/src/server/conversation/codexHelperThreadRecovery.js +17 -4
- package/src/server/conversation/codexHelperThreads.js +10 -1
- package/src/server/conversation/codexHomeStorage.js +69 -0
- package/src/server/conversation/codexProcess.js +7 -2
- package/src/server/conversation/codexProvider.js +57 -46
- package/src/server/conversation/codexProviderOwner.js +272 -10
- package/src/server/conversation/codexProviderSelection.js +13 -3
- package/src/server/conversation/codexRuntimeLifecycle.js +56 -4
- package/src/server/conversation/codexSessionCleanup.js +67 -20
- package/src/server/conversation/codexSessionReconciliation.js +18 -4
- package/src/server/conversation/codexTurn.js +9 -2
- package/src/server/conversation/continuity.js +34 -14
- package/src/server/conversation/openCodeClient.js +34 -0
- package/src/server/conversation/openCodeProcess.js +37 -5
- package/src/server/conversation/openCodeRuntime.js +257 -69
- package/src/server/conversation/providers/claudeDriver.js +7 -5
- package/src/server/conversation/providers/codexDriver.js +23 -16
- package/src/server/conversation/providers/opencodeDriver.js +17 -14
- package/src/server/conversation/providers/scoped.js +5 -2
- package/src/server/conversation/runtime.js +93 -6
- package/src/server/hosts/codexConfiguration.js +4 -0
- package/test/assistantScroll.browser.test.js +134 -18
- package/test/claudeConversationSession.test.js +26 -1
- package/test/claudeLifecycle.test.js +293 -0
- package/test/claudeProcess.test.js +364 -33
- package/test/claudeTurn.test.js +192 -3
- package/test/codexAccountChange.test.js +261 -0
- package/test/codexConversationsCheckpoint.test.js +3 -0
- package/test/codexEvents.test.js +23 -1
- package/test/codexHomeStorage.test.js +57 -0
- package/test/codexInteractiveArguments.test.js +9 -0
- package/test/codexLifecycleIsolation.test.js +441 -0
- package/test/codexModelCatalog.test.js +48 -2
- package/test/codexProcess.test.js +21 -0
- package/test/codexTemporaryProviderRelease.test.js +180 -0
- package/test/codexTurn.test.js +52 -1
- package/test/conversationNativeStorage.test.js +284 -0
- package/test/conversationRuntime.test.js +66 -0
- package/test/conversationSession.test.js +4 -8
- package/test/messageDelivery.browser.test.js +18 -2
- package/test/messageDelivery.test.js +34 -1
- package/test/openCodeClient.test.js +31 -0
- package/test/openCodeConnectionChange.test.js +166 -0
- package/test/openCodeProcess.test.js +36 -0
- package/test/openCodeRuntime.test.js +25 -78
|
@@ -169,7 +169,7 @@ export async function ensureOpenCodeSession(target, { selection, model, workdir
|
|
|
169
169
|
|
|
170
170
|
// The production provider's users and pending acquisitions share this owner.
|
|
171
171
|
// Hosts supply authorized startup inputs and their own publication/cleanup work.
|
|
172
|
-
export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
172
|
+
export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageIdleMs = 60_000 } = {}) {
|
|
173
173
|
if (scope !== undefined && !path.isAbsolute(scope)) throw new TypeError("OpenCode runtime scopes require an absolute directory.");
|
|
174
174
|
if (scope && sharedRuntimes.has(scope)) return sharedRuntimes.get(scope);
|
|
175
175
|
const processes = new Map();
|
|
@@ -183,13 +183,9 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
183
183
|
let sharedProcessStop = null;
|
|
184
184
|
let failedStartup = null;
|
|
185
185
|
let closed = false;
|
|
186
|
-
let
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (connectionChanging) throw Object.assign(new Error("OpenCode is changing a connection. Retry after the connection change finishes."), {
|
|
190
|
-
code: "assistant_opencode_connection_changing", statusCode: 409
|
|
191
|
-
});
|
|
192
|
-
}
|
|
186
|
+
let connectionUpdate = Promise.resolve();
|
|
187
|
+
let storageReads = 0;
|
|
188
|
+
let storageIdle = null;
|
|
193
189
|
const bindingScopes = new Map();
|
|
194
190
|
let registryPath = "";
|
|
195
191
|
let bindingWrite = Promise.resolve();
|
|
@@ -238,28 +234,161 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
238
234
|
}
|
|
239
235
|
}
|
|
240
236
|
|
|
241
|
-
|
|
242
|
-
|
|
237
|
+
// A credential change reaches a native directory when OpenCode reloads that
|
|
238
|
+
// directory's instance. An idle directory reloads at once; one with a turn in
|
|
239
|
+
// flight reloads when its last turn there ends. New work in a directory waits
|
|
240
|
+
// for its reload, so it never starts on credentials the server no longer holds.
|
|
241
|
+
function directoryState(shared, workdir) {
|
|
242
|
+
const directory = path.resolve(workdir);
|
|
243
|
+
let state = shared.directories.get(directory);
|
|
244
|
+
if (!state) {
|
|
245
|
+
state = { directory, generation: shared.generation, turns: 0, reload: null, idle: null };
|
|
246
|
+
shared.directories.set(directory, state);
|
|
247
|
+
}
|
|
248
|
+
return state;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function directoryCurrent(workdir) {
|
|
252
|
+
const state = sharedProcess && text(workdir) ? sharedProcess.directories.get(path.resolve(workdir)) : null;
|
|
253
|
+
return !state || (!state.reload && state.generation === sharedProcess.generation);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function reloadDirectory(shared, state) {
|
|
257
|
+
if (state.reload || state.generation === shared.generation) return state.reload;
|
|
258
|
+
state.reload = (async () => {
|
|
259
|
+
try {
|
|
260
|
+
while (state.generation < shared.generation) {
|
|
261
|
+
const generation = shared.generation;
|
|
262
|
+
await shared.server.client.forDirectory(state.directory).disposeInstance();
|
|
263
|
+
state.generation = generation;
|
|
264
|
+
}
|
|
265
|
+
} finally {
|
|
266
|
+
state.reload = null;
|
|
267
|
+
}
|
|
268
|
+
})();
|
|
269
|
+
return state.reload;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function waitForDirectory(workdir) {
|
|
273
|
+
const shared = sharedProcess;
|
|
274
|
+
const state = directoryState(shared, workdir);
|
|
275
|
+
try {
|
|
276
|
+
if (state.reload || !state.turns) await reloadDirectory(shared, state);
|
|
277
|
+
else await (state.idle ||= Promise.withResolvers()).promise;
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (sharedProcess !== shared) return;
|
|
280
|
+
throw Object.assign(new Error("OpenCode could not load the changed AI connection for this work. Send your message again."), {
|
|
281
|
+
cause: error, code: "assistant_opencode_connection_reload_failed", details: {}, statusCode: 503
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function currentDirectory(workdir) {
|
|
287
|
+
while (!directoryCurrent(workdir)) await waitForDirectory(workdir);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Holds a directory's credentials for a turn in flight there.
|
|
291
|
+
function holdDirectory(workdir) {
|
|
292
|
+
const shared = sharedProcess;
|
|
293
|
+
if (!shared || !text(workdir)) return () => {};
|
|
294
|
+
const state = directoryState(shared, workdir);
|
|
295
|
+
state.turns += 1;
|
|
296
|
+
let held = true;
|
|
297
|
+
return () => {
|
|
298
|
+
if (!held) return;
|
|
299
|
+
held = false;
|
|
300
|
+
state.turns -= 1;
|
|
301
|
+
if (state.turns) return;
|
|
302
|
+
state.idle?.resolve();
|
|
303
|
+
state.idle = null;
|
|
304
|
+
if (sharedProcess === shared) reloadDirectory(shared, state)?.catch(() => {});
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function admitDirectory(workdir) {
|
|
309
|
+
while (!directoryCurrent(workdir)) await waitForDirectory(workdir);
|
|
310
|
+
return holdDirectory(workdir);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function reloadChangedCredentials(shared) {
|
|
314
|
+
for (const directory of shared.server.client.instanceDirectories?.() || []) directoryState(shared, directory);
|
|
315
|
+
shared.generation += 1;
|
|
316
|
+
for (const state of shared.directories.values()) {
|
|
317
|
+
if (!state.turns) reloadDirectory(shared, state)?.catch(() => {});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function connectionRecord(connection) {
|
|
322
|
+
return { canonicalUrl: connection.canonicalUrl, endpointCode: connection.endpointCode, fingerprint: connection.fingerprint };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function sameConnection(recorded, connection) {
|
|
326
|
+
return recorded?.canonicalUrl === connection.canonicalUrl && recorded.fingerprint === connection.fingerprint &&
|
|
327
|
+
recorded.endpointCode === connection.endpointCode;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// A key changes in place; a provider route belongs to the server's configuration.
|
|
331
|
+
function inPlaceConnection(recorded, connection) {
|
|
332
|
+
return Boolean(connection?.apiKey) && (recorded
|
|
333
|
+
? recorded.canonicalUrl === connection.canonicalUrl && recorded.endpointCode === connection.endpointCode
|
|
334
|
+
: !text(connection.canonicalUrl));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// The running server takes the host's saved connections without stopping its
|
|
338
|
+
// turns. Reading them inside this queue applies the newest saved state last.
|
|
339
|
+
function updateConnections(readConnections) {
|
|
340
|
+
const update = connectionUpdate.then(async () => {
|
|
341
|
+
const shared = sharedProcess;
|
|
342
|
+
if (!shared) return;
|
|
343
|
+
shared.connectionsCurrent = false;
|
|
344
|
+
let changed = false;
|
|
345
|
+
try {
|
|
346
|
+
const saved = new Map((await readConnections()).map(connection => [connection.modelProviderId, connection]));
|
|
347
|
+
for (const [providerId, connection] of saved) {
|
|
348
|
+
const recorded = shared.connections.get(providerId);
|
|
349
|
+
if (sameConnection(recorded, connection) || !inPlaceConnection(recorded, connection)) continue;
|
|
350
|
+
await shared.server.client.authenticateApiKey(providerId, connection.apiKey);
|
|
351
|
+
shared.connections.set(providerId, connectionRecord(connection));
|
|
352
|
+
changed = true;
|
|
353
|
+
}
|
|
354
|
+
for (const providerId of [...shared.connections.keys()]) {
|
|
355
|
+
if (saved.has(providerId)) continue;
|
|
356
|
+
await shared.server.client.removeAuthentication(providerId);
|
|
357
|
+
shared.connections.delete(providerId);
|
|
358
|
+
changed = true;
|
|
359
|
+
}
|
|
360
|
+
shared.connectionsCurrent = true;
|
|
361
|
+
} finally {
|
|
362
|
+
if (changed) reloadChangedCredentials(shared);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
connectionUpdate = update.catch(() => {});
|
|
366
|
+
return update;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// `readConnections` returns the host's saved connections. Without it, a changed
|
|
370
|
+
// account needs a new server.
|
|
371
|
+
async function ensure(selected, start, readConnections = null) {
|
|
243
372
|
await sharedProcessStop;
|
|
244
|
-
requireConnectionAvailable();
|
|
245
373
|
if (failedStartup) await stop("opencode-startup-cleanup");
|
|
246
374
|
if (sharedProcess) {
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
currentConnection?.fingerprint === selected.fingerprint &&
|
|
251
|
-
currentConnection?.endpointCode === selected.endpointCode
|
|
252
|
-
)) {
|
|
375
|
+
const recorded = sharedProcess.connections.get(selected?.modelProviderId);
|
|
376
|
+
const current = !selected || sameConnection(recorded, selected);
|
|
377
|
+
if (current || (readConnections && inPlaceConnection(recorded, selected))) {
|
|
253
378
|
const probe = AbortSignal.timeout(1_000);
|
|
379
|
+
let healthy = false;
|
|
254
380
|
try {
|
|
255
381
|
await sharedProcess.server.client.health({ signal: probe });
|
|
256
|
-
|
|
382
|
+
healthy = true;
|
|
257
383
|
} catch (error) {
|
|
258
384
|
// A slow probe is not proof that the shared server stopped working.
|
|
259
385
|
if (probe.aborted) throw error;
|
|
260
|
-
requireConnectionAvailable();
|
|
261
386
|
await stop("opencode-health-check-failed");
|
|
262
387
|
}
|
|
388
|
+
if (healthy && sharedProcess) {
|
|
389
|
+
if (readConnections && (!current || !sharedProcess.connectionsCurrent)) await updateConnections(readConnections);
|
|
390
|
+
return sharedProcess;
|
|
391
|
+
}
|
|
263
392
|
} else {
|
|
264
393
|
await stop("opencode-connection-changed");
|
|
265
394
|
}
|
|
@@ -267,13 +396,14 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
267
396
|
if (sharedProcessStart) return sharedProcessStart;
|
|
268
397
|
sharedProcessStart = Promise.resolve().then(async () => {
|
|
269
398
|
const { connections, server } = await start();
|
|
270
|
-
|
|
271
|
-
connections: new Map(connections.map(connection => [
|
|
272
|
-
|
|
273
|
-
{ canonicalUrl: connection.canonicalUrl, endpointCode: connection.endpointCode, fingerprint: connection.fingerprint }
|
|
274
|
-
])),
|
|
275
|
-
server
|
|
399
|
+
const started = {
|
|
400
|
+
connections: new Map(connections.map(connection => [connection.modelProviderId, connectionRecord(connection)])),
|
|
401
|
+
connectionsCurrent: true, directories: new Map(), generation: 0, server
|
|
276
402
|
};
|
|
403
|
+
sharedProcess = started;
|
|
404
|
+
// An unexpected exit retires the server's leftovers at once.
|
|
405
|
+
void server.unexpectedExit?.then(() => sharedProcess === started ? stop("opencode-server-exited") : null)
|
|
406
|
+
.catch(() => {});
|
|
277
407
|
for (const target of processes.values()) {
|
|
278
408
|
target.server = openCodeServerForDirectory(server, target.workdir);
|
|
279
409
|
target.upstream = null;
|
|
@@ -291,6 +421,29 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
291
421
|
}
|
|
292
422
|
}
|
|
293
423
|
|
|
424
|
+
// Native storage inventory and retirement borrow the shared server; they are
|
|
425
|
+
// not users that keep it running. A short idle after the last read lets one
|
|
426
|
+
// maintenance pass share it, then it stops unless a conversation uses it.
|
|
427
|
+
async function withStorageProcess(start, operation, failure = error => { throw error; }) {
|
|
428
|
+
clearTimeout(storageIdle);
|
|
429
|
+
storageIdle = null;
|
|
430
|
+
storageReads += 1;
|
|
431
|
+
try {
|
|
432
|
+
return await operation(await ensure(null, start).catch(failure));
|
|
433
|
+
} finally {
|
|
434
|
+
storageReads -= 1;
|
|
435
|
+
if (storageReads === 0 && !closed) {
|
|
436
|
+
storageIdle = setTimeout(() => {
|
|
437
|
+
storageIdle = null;
|
|
438
|
+
if (storageReads === 0 && processes.size === 0 && processStarts.size === 0) {
|
|
439
|
+
stop("opencode-storage-read-finished").catch(() => null);
|
|
440
|
+
}
|
|
441
|
+
}, storageIdleMs);
|
|
442
|
+
storageIdle.unref?.();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
294
447
|
function targetFor(key, prepared) {
|
|
295
448
|
const current = processes.get(key);
|
|
296
449
|
const nativeId = prepared.upstreamSessionId;
|
|
@@ -335,7 +488,6 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
335
488
|
}
|
|
336
489
|
|
|
337
490
|
async function acquire(key, prepare, onFailure = async () => {}) {
|
|
338
|
-
requireConnectionAvailable();
|
|
339
491
|
const pending = processStarts.get(key);
|
|
340
492
|
if (pending) return pending;
|
|
341
493
|
const start = Promise.resolve().then(async () => {
|
|
@@ -346,7 +498,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
346
498
|
if (processStarts.get(key) === start) processStarts.delete(key);
|
|
347
499
|
if (!processes.has(key)) {
|
|
348
500
|
await onFailure();
|
|
349
|
-
if (processes.size === 0 && processStarts.size === 0 && !failedStartup) {
|
|
501
|
+
if (processes.size === 0 && processStarts.size === 0 && storageReads === 0 && !failedStartup) {
|
|
350
502
|
await stop("opencode-session-start-failed").catch(() => null);
|
|
351
503
|
}
|
|
352
504
|
}
|
|
@@ -365,7 +517,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
365
517
|
processes.delete(target.key);
|
|
366
518
|
await onRemoved?.();
|
|
367
519
|
}
|
|
368
|
-
return !retainSharedProcess && processes.size === 0 && processStarts.size === 0
|
|
520
|
+
return !retainSharedProcess && processes.size === 0 && processStarts.size === 0 && storageReads === 0
|
|
369
521
|
? stop()
|
|
370
522
|
: { exited: true, sharedProcessRetained: true };
|
|
371
523
|
}
|
|
@@ -444,7 +596,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
444
596
|
application.ownsTarget(target) && (!projectContextRoot || target.projectContextRoot === path.resolve(projectContextRoot))
|
|
445
597
|
));
|
|
446
598
|
const results = await Promise.all(targets.map((target) => releaseProcessTarget(target, {}, application)));
|
|
447
|
-
if (processes.size === 0 && (sharedProcess || failedStartup)) {
|
|
599
|
+
if (processes.size === 0 && storageReads === 0 && (sharedProcess || failedStartup)) {
|
|
448
600
|
results.push(await stop(text(input.reason) || "opencode-project-close").catch(application.failure));
|
|
449
601
|
}
|
|
450
602
|
return {
|
|
@@ -456,34 +608,23 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
456
608
|
async function invalidateRuntimes(input = {}, preparation) {
|
|
457
609
|
if (text(input.reason) === "server-shutdown") {
|
|
458
610
|
closed = true;
|
|
611
|
+
clearTimeout(storageIdle);
|
|
612
|
+
storageIdle = null;
|
|
459
613
|
}
|
|
460
614
|
const application = preparation.release;
|
|
461
615
|
if (typeof input.publish === "function") {
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
try {
|
|
465
|
-
await Promise.all([...processStarts.values()].map(start => start.catch(() => null)));
|
|
466
|
-
await sharedProcessStart?.catch(() => null);
|
|
467
|
-
await application.assertConnectionChange?.();
|
|
468
|
-
const targets = [...processes.values()];
|
|
469
|
-
const providerId = text(input.modelProviderId);
|
|
470
|
-
if (!providerId || targets.some(target => text(target.modelProviderId) !== providerId || !application.ownsTarget(target))) {
|
|
471
|
-
throw Object.assign(new Error("Close the other OpenCode conversations that use this connection, then retry this connection change. The saved connection has not changed."), {
|
|
472
|
-
code: "assistant_opencode_connection_in_use", statusCode: 409
|
|
473
|
-
});
|
|
474
|
-
}
|
|
475
|
-
const results = [];
|
|
476
|
-
for (const target of targets) {
|
|
477
|
-
const proof = await releaseProcessTarget(target, { retainSharedProcess: true }, application);
|
|
478
|
-
if (proof?.exited === false || proof?.ok === false) throw new Error("OpenCode could not stop the selected conversation. Retry the connection change.");
|
|
479
|
-
results.push(proof);
|
|
480
|
-
}
|
|
481
|
-
if (sharedProcess || failedStartup) results.push(await stop("opencode-connection-changed"));
|
|
482
|
-
await input.publish();
|
|
483
|
-
return { closed: results.length, ok: true };
|
|
484
|
-
} finally {
|
|
485
|
-
connectionChanging = false;
|
|
616
|
+
if (typeof preparation.connections !== "function") {
|
|
617
|
+
throw new TypeError("OpenCode connection changes require the host's saved connections.");
|
|
486
618
|
}
|
|
619
|
+
// Running turns, retained conversations and the shared server continue.
|
|
620
|
+
// The server takes the saved connections at once, and each directory
|
|
621
|
+
// reloads them as soon as it has no turn in flight.
|
|
622
|
+
await application.assertConnectionChange?.();
|
|
623
|
+
await input.publish();
|
|
624
|
+
const update = () => updateConnections(preparation.connections).catch(() => null);
|
|
625
|
+
void sharedProcessStart?.then(update, () => null);
|
|
626
|
+
await update();
|
|
627
|
+
return { closed: 0, ok: true };
|
|
487
628
|
}
|
|
488
629
|
await Promise.all([...processStarts.entries()]
|
|
489
630
|
.filter(([key]) => key.includes("\0"))
|
|
@@ -532,7 +673,8 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
532
673
|
return acquire(prepared.key, async () => {
|
|
533
674
|
const configured = await prepared.configure();
|
|
534
675
|
const shared = await ensure(prepared.connection,
|
|
535
|
-
async () => startPreparedProcess(await configured.prepareServer())
|
|
676
|
+
async () => startPreparedProcess(await configured.prepareServer()),
|
|
677
|
+
async () => (await configured.prepareServer()).connections).catch(prepared.failure);
|
|
536
678
|
configured.target.server = shared.server;
|
|
537
679
|
return targetFor(prepared.key, configured.target);
|
|
538
680
|
}, prepared.onFailure).catch(prepared.failure);
|
|
@@ -545,13 +687,18 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
545
687
|
|
|
546
688
|
async function runPreparedRenewalTurn(preparation) {
|
|
547
689
|
const acquired = await acquirePrepared(await preparation.process());
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
690
|
+
const releaseDirectory = await admitDirectory(acquired.workdir);
|
|
691
|
+
try {
|
|
692
|
+
const target = await ensureOpenCodeSession(acquired, preparation.session());
|
|
693
|
+
const turn = preparation.turn(target);
|
|
694
|
+
const result = await runOpenCodeConversationTurn(target.server.client, target.upstreamSessionId, turn.input, {
|
|
695
|
+
...turn.options,
|
|
696
|
+
...(turn.release ? { close: () => releaseProcessTarget(target, {}, turn.release) } : {})
|
|
697
|
+
});
|
|
698
|
+
return turn.completeResult(result);
|
|
699
|
+
} finally {
|
|
700
|
+
releaseDirectory();
|
|
701
|
+
}
|
|
555
702
|
}
|
|
556
703
|
|
|
557
704
|
async function createPreparedConversation(prepared) {
|
|
@@ -569,6 +716,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
569
716
|
throw selection.profileChanged();
|
|
570
717
|
}
|
|
571
718
|
if (!conversationId && !selection.createIfMissing) throw selection.identityRequired();
|
|
719
|
+
if (!previous?.active) await currentDirectory(target.workdir);
|
|
572
720
|
const selected = await selectConversation(prepared.key, target, conversationId, selection.input, selection.registration);
|
|
573
721
|
return { ...selected, executionProfile: prepared.executionProfile, target };
|
|
574
722
|
}
|
|
@@ -613,7 +761,13 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
613
761
|
});
|
|
614
762
|
if (!result.deleted) return result;
|
|
615
763
|
const cleanup = prepared.cleanupAfterDelete;
|
|
616
|
-
|
|
764
|
+
let providerExit = null;
|
|
765
|
+
if (cleanup) {
|
|
766
|
+
providerExit = await closeSession(cleanup.sessionId, cleanup.options, cleanup.application);
|
|
767
|
+
// The result carries the deleted scope's exit proof. Only renewal releases
|
|
768
|
+
// a kept proof, and a scope is never renewed, so none is kept.
|
|
769
|
+
processExitProofs.delete(cleanup.sessionId);
|
|
770
|
+
}
|
|
617
771
|
return { conversationId, deleted: true, ok: providerExit?.ok !== false,
|
|
618
772
|
...(providerExit ? { providerExit } : {}) };
|
|
619
773
|
}
|
|
@@ -810,12 +964,15 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
810
964
|
deliveryMode: "steer"
|
|
811
965
|
};
|
|
812
966
|
}
|
|
967
|
+
// A new native turn starts only on its directory's current credentials.
|
|
968
|
+
while (!tracked.active && !directoryCurrent(target.workdir)) await waitForDirectory(target.workdir);
|
|
813
969
|
if (tracked.active) {
|
|
814
970
|
throw Object.assign(new Error("This conversation is still working."), {
|
|
815
971
|
code: "assistant_opencode_conversation_busy", details: {}, statusCode: 409
|
|
816
972
|
});
|
|
817
973
|
}
|
|
818
974
|
tracked.active = true;
|
|
975
|
+
const releaseDirectory = holdDirectory(target.workdir);
|
|
819
976
|
tracked.error = null;
|
|
820
977
|
tracked.interrupted = false;
|
|
821
978
|
const turnAbort = new AbortController();
|
|
@@ -842,10 +999,14 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
842
999
|
beforeDispatch: options.beforeDispatch
|
|
843
1000
|
});
|
|
844
1001
|
} catch (error) {
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
1002
|
+
try {
|
|
1003
|
+
await events.close();
|
|
1004
|
+
tracked.error = error;
|
|
1005
|
+
if (tracked.promptAttempted) await stopUnobservedSession(observedTarget, conversationId);
|
|
1006
|
+
tracked.active = false;
|
|
1007
|
+
} finally {
|
|
1008
|
+
releaseDirectory();
|
|
1009
|
+
}
|
|
849
1010
|
throw error;
|
|
850
1011
|
}
|
|
851
1012
|
tracked.inputMessageId = text(admitted.id);
|
|
@@ -872,6 +1033,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
872
1033
|
}
|
|
873
1034
|
return result;
|
|
874
1035
|
})();
|
|
1036
|
+
void tracked.completion.then(releaseDirectory, releaseDirectory);
|
|
875
1037
|
// Keep a failed admitted turn observable to the client's conversation reads.
|
|
876
1038
|
void tracked.completion.catch((error) => {
|
|
877
1039
|
if (tracked.abortController === turnAbort) tracked.error = error;
|
|
@@ -1108,6 +1270,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
1108
1270
|
try {
|
|
1109
1271
|
const preparation = await application.prepare({ overwrite: !currentMonitor, threadId: currentThreadId });
|
|
1110
1272
|
const preparedTarget = preparation.process ? await acquirePrepared(preparation.process) : preparation.target;
|
|
1273
|
+
if (!monitors.get(key)) await currentDirectory(preparedTarget.workdir);
|
|
1111
1274
|
target = await ensureOpenCodeSession(preparedTarget, preparation.session);
|
|
1112
1275
|
currentThreadId = target.upstreamSessionId;
|
|
1113
1276
|
if (startingTurn) {
|
|
@@ -1139,6 +1302,16 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
1139
1302
|
turn: openCodeTurnSnapshot(turns.get(key), currentThreadId) } };
|
|
1140
1303
|
}
|
|
1141
1304
|
}
|
|
1305
|
+
// A new native turn starts only on its directory's current credentials;
|
|
1306
|
+
// the wait, if any, is for that directory's own running turns.
|
|
1307
|
+
while (!currentMonitor && !directoryCurrent(target.workdir)) {
|
|
1308
|
+
await waitForDirectory(target.workdir);
|
|
1309
|
+
currentMonitor = monitors.get(key);
|
|
1310
|
+
if (currentMonitor && !turns.get(key)?.active) {
|
|
1311
|
+
await currentMonitor;
|
|
1312
|
+
currentMonitor = monitors.get(key);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1142
1315
|
admission = Promise.withResolvers();
|
|
1143
1316
|
// The observer may be between polls when dispatch or persistence fails.
|
|
1144
1317
|
// Keep the raw promise rejectable without an unhandled rejection.
|
|
@@ -1364,6 +1537,13 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
1364
1537
|
return monitor;
|
|
1365
1538
|
}
|
|
1366
1539
|
|
|
1540
|
+
// A removed connection's models are gone from its reloaded directory.
|
|
1541
|
+
function disconnectedProviderFailure(failure) {
|
|
1542
|
+
const providerId = /\bModel not found: ([^/\s]+)\//u.exec(failure)?.[1];
|
|
1543
|
+
return providerId && sharedProcess && !sharedProcess.connections.has(providerId)
|
|
1544
|
+
? `${providerId} isn't connected. Connect it again, then send your message again.` : "";
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1367
1547
|
function beginMonitor(key, target, turn, {
|
|
1368
1548
|
observe, observation, eventReady, readiness, finalResponse,
|
|
1369
1549
|
writeRun, projectMessages, completeResult, onRetired
|
|
@@ -1378,6 +1558,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
1378
1558
|
registeredTarget.upstreamSessionId === target.upstreamSessionId ? registeredTarget : null;
|
|
1379
1559
|
const finalAssistantResultProcess = finalAssistantResultTarget ? sharedProcess : null;
|
|
1380
1560
|
turns.set(key, turn);
|
|
1561
|
+
const releaseDirectory = holdDirectory(target.workdir);
|
|
1381
1562
|
const signal = AbortSignal.any([target.abortController.signal, turn.abortController.signal]);
|
|
1382
1563
|
const monitor = Promise.resolve().then(async () => {
|
|
1383
1564
|
const events = observation
|
|
@@ -1445,7 +1626,9 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
1445
1626
|
failure = "OpenCode finished without a user-facing final response. Please send your message again.";
|
|
1446
1627
|
finalState = "failed";
|
|
1447
1628
|
} else if (failure) {
|
|
1448
|
-
|
|
1629
|
+
const disconnected = disconnectedProviderFailure(failure);
|
|
1630
|
+
failure = disconnected || failure;
|
|
1631
|
+
credentialFailure = Boolean(disconnected) || openCodeCredentialFailure(failure);
|
|
1449
1632
|
finalState = "failed";
|
|
1450
1633
|
} else if (turn.interruptRequested) {
|
|
1451
1634
|
finalState = "interrupted";
|
|
@@ -1465,7 +1648,9 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
1465
1648
|
} else {
|
|
1466
1649
|
const cause = signal.aborted ? signal.reason : error;
|
|
1467
1650
|
failure = text(cause?.message) || "OpenCode turn failed.";
|
|
1468
|
-
|
|
1651
|
+
const disconnected = disconnectedProviderFailure(failure);
|
|
1652
|
+
failure = disconnected || failure;
|
|
1653
|
+
credentialFailure = Boolean(disconnected) || openCodeCredentialFailure(failure);
|
|
1469
1654
|
providerApiFailure = openCodeProviderApiFailure(cause);
|
|
1470
1655
|
finalState = target.abortController.signal.aborted
|
|
1471
1656
|
? "cancelled"
|
|
@@ -1506,6 +1691,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
1506
1691
|
if (monitors.get(key) === monitor) {
|
|
1507
1692
|
monitors.delete(key);
|
|
1508
1693
|
}
|
|
1694
|
+
releaseDirectory();
|
|
1509
1695
|
onRetired();
|
|
1510
1696
|
});
|
|
1511
1697
|
monitors.set(key, monitor);
|
|
@@ -1713,6 +1899,8 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
1713
1899
|
|
|
1714
1900
|
async function ensurePreparedTerminal(preparation) {
|
|
1715
1901
|
const target = await acquirePreparedTerminal(preparation);
|
|
1902
|
+
// The native terminal starts its own turns there.
|
|
1903
|
+
await currentDirectory(target.workdir);
|
|
1716
1904
|
return ensureOpenCodeSession(target, preparation.session());
|
|
1717
1905
|
}
|
|
1718
1906
|
|
|
@@ -1767,7 +1955,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
1767
1955
|
get current() { return sharedProcess; },
|
|
1768
1956
|
get closed() { return closed; },
|
|
1769
1957
|
get cleanupPending() { return Boolean(failedStartup); },
|
|
1770
|
-
acquire, targetFor, ensure, release, releaseTarget, releaseProcessTarget, closeSession, closeProject,
|
|
1958
|
+
acquire, targetFor, ensure, withStorageProcess, release, releaseTarget, releaseProcessTarget, closeSession, closeProject,
|
|
1771
1959
|
startPreparedProcess, acquirePrepared, createPreparedConversation, runPreparedRenewalTurn, selectPreparedConversation, existingPreparedConversation,
|
|
1772
1960
|
runPreparedConversationTurn, readPreparedConversation, waitPreparedConversationTurn, stopPreparedConversation, deletePreparedConversation,
|
|
1773
1961
|
sendPreparedMessage, inspectPreparedMessage, interruptPreparedTurn,
|
|
@@ -258,13 +258,13 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
|
|
|
258
258
|
return conversation.read(context, representation, nativeResult);
|
|
259
259
|
},
|
|
260
260
|
inspectTemporaryActivity({ native, context }) {
|
|
261
|
-
return native.owner.hasActiveTemporaryConversation(context
|
|
261
|
+
return native.owner.hasActiveTemporaryConversation(context);
|
|
262
262
|
},
|
|
263
263
|
releaseRenewalPredecessorProcessExitProof({ native, context }) {
|
|
264
|
-
return native.owner.
|
|
264
|
+
return native.owner.releaseSession(context, native.application);
|
|
265
265
|
},
|
|
266
266
|
releaseRenewalSuccessorProcessExitProof({ native, context }) {
|
|
267
|
-
return native.owner.
|
|
267
|
+
return native.owner.releaseSession(context, native.application);
|
|
268
268
|
},
|
|
269
269
|
async generateRenewalHandover({ native, input, context }) {
|
|
270
270
|
const prepared = native.preparation.handover(input, context);
|
|
@@ -432,7 +432,8 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
|
|
|
432
432
|
if (supplied) {
|
|
433
433
|
await acquire(context);
|
|
434
434
|
const result = await owner.inspectAdmission(await acquire(context), input);
|
|
435
|
-
return representation === "native" ? result
|
|
435
|
+
return representation === "native" ? result
|
|
436
|
+
: { accepted: result.admission === "accepted", notAdmitted: result.admission === "not-admitted" };
|
|
436
437
|
}
|
|
437
438
|
if (verifyAccount === true) {
|
|
438
439
|
const binding = input.receiptBinding;
|
|
@@ -446,7 +447,8 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
|
|
|
446
447
|
}
|
|
447
448
|
const history = await owner.readHistory(entry);
|
|
448
449
|
const nativeId = claudeNativeMessageId(input.messageId);
|
|
449
|
-
|
|
450
|
+
const accepted = history.userIds.includes(nativeId);
|
|
451
|
+
return { accepted, notAdmitted: !accepted && owner.nativeWorkSettled(entry),
|
|
450
452
|
messages: history.messages.filter(message => message.userId === nativeId) };
|
|
451
453
|
},
|
|
452
454
|
steer: command => owner.steer(control, command, commandOptions),
|
|
@@ -126,10 +126,11 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
|
|
|
126
126
|
runtime: createCodexAppServerRuntime({ credentials, execution: processExecution, configuration }) };
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
// Storage work borrows the shared process; it is not a user that keeps it running.
|
|
130
|
+
async function withNativeStorageProvider({ sessionId, native, binding, context }, operation) {
|
|
130
131
|
const prepared = await native.preparation.storage(sessionId, binding, context);
|
|
131
|
-
return
|
|
132
|
-
|
|
132
|
+
return native.providerOwner.withTransientSession(prepared.providerContext,
|
|
133
|
+
provider => operation(provider, prepared.toolHomeSource));
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
async function disposeNative({ sessionId, namespace, native, options }) {
|
|
@@ -160,17 +161,17 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
|
|
|
160
161
|
deleteDetachedConversation({ sessionId, native, input, context }) {
|
|
161
162
|
return native.runOwner.deleteDetachedThread(sessionId, input, context);
|
|
162
163
|
},
|
|
163
|
-
|
|
164
|
-
const { provider } = await nativeStorageProvider(prepared);
|
|
164
|
+
listConversationStorage(prepared) {
|
|
165
165
|
const { binding } = prepared;
|
|
166
|
-
return (
|
|
166
|
+
return withNativeStorageProvider(prepared, async provider =>
|
|
167
|
+
(await provider.listNativeThreadsForCwd(binding.workdir)).map((conversationId) => ({ ...binding, conversationId })));
|
|
167
168
|
},
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
169
|
+
retireConversationHistory(prepared) {
|
|
170
|
+
return withNativeStorageProvider(prepared, (provider, toolHomeSource) =>
|
|
171
|
+
retireCodexConversationHistory(provider, prepared.binding, {
|
|
172
|
+
toolHomeSource, beforeDelete: prepared.context.beforeDelete, signal: prepared.context.signal,
|
|
173
|
+
errorPrefix: prepared.native.errorPrefix
|
|
174
|
+
}));
|
|
174
175
|
},
|
|
175
176
|
invalidateRuntimes({ native, input }) {
|
|
176
177
|
return native.runOwner.invalidateRuntimes(input, native.preparation);
|
|
@@ -217,10 +218,16 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
|
|
|
217
218
|
return native.runOwner.ensureSessionReadiness(sessionId, await native.preparation.readiness());
|
|
218
219
|
},
|
|
219
220
|
async updateConversationSettings({ sessionId, native, context, input }) {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
221
|
+
// Changing a thread's settings is not a use that keeps Codex running.
|
|
222
|
+
const borrow = { release() {} };
|
|
223
|
+
try {
|
|
224
|
+
const acquired = await native.runOwner.conversationContext(sessionId, { conversationId: input.threadId }, context, borrow);
|
|
225
|
+
if (acquired.ok === false) return acquired;
|
|
226
|
+
await acquired.provider.updateThreadSettings(input.threadId, { model: input.model, effort: input.effort });
|
|
227
|
+
return { ok: true };
|
|
228
|
+
} finally {
|
|
229
|
+
borrow.release();
|
|
230
|
+
}
|
|
224
231
|
},
|
|
225
232
|
async createConversation({ sessionId, native, input, context }) {
|
|
226
233
|
return native.runOwner.createConversation(sessionId, input, context);
|