@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.
Files changed (61) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/fixtures/responsive-assistant/ConversationFixture.vue +2 -2
  3. package/fixtures/responsive-assistant/DeliveryFixture.vue +15 -2
  4. package/locales/en.json +4 -2
  5. package/package.json +5 -5
  6. package/src/client/conversation/AssistantConversationElement.vue +8 -1
  7. package/src/client/conversation/AssistantGoalControl.vue +2 -2
  8. package/src/client/conversation/AssistantTranscript.vue +41 -11
  9. package/src/client/conversation/messageDelivery.js +23 -6
  10. package/src/client/lib/assistantApi.js +5 -0
  11. package/src/server/conversation/claudeProcess.js +171 -53
  12. package/src/server/conversation/claudeTurn.js +199 -63
  13. package/src/server/conversation/codexAuthPreflight.js +3 -0
  14. package/src/server/conversation/codexConfiguration.js +42 -4
  15. package/src/server/conversation/codexConversations.js +256 -114
  16. package/src/server/conversation/codexEvents.js +16 -3
  17. package/src/server/conversation/codexHelperThreadRecovery.js +17 -4
  18. package/src/server/conversation/codexHelperThreads.js +10 -1
  19. package/src/server/conversation/codexHomeStorage.js +69 -0
  20. package/src/server/conversation/codexProcess.js +7 -2
  21. package/src/server/conversation/codexProvider.js +57 -46
  22. package/src/server/conversation/codexProviderOwner.js +272 -10
  23. package/src/server/conversation/codexProviderSelection.js +13 -3
  24. package/src/server/conversation/codexRuntimeLifecycle.js +56 -4
  25. package/src/server/conversation/codexSessionCleanup.js +67 -20
  26. package/src/server/conversation/codexSessionReconciliation.js +18 -4
  27. package/src/server/conversation/codexTurn.js +9 -2
  28. package/src/server/conversation/continuity.js +34 -14
  29. package/src/server/conversation/openCodeClient.js +34 -0
  30. package/src/server/conversation/openCodeProcess.js +37 -5
  31. package/src/server/conversation/openCodeRuntime.js +257 -69
  32. package/src/server/conversation/providers/claudeDriver.js +7 -5
  33. package/src/server/conversation/providers/codexDriver.js +23 -16
  34. package/src/server/conversation/providers/opencodeDriver.js +17 -14
  35. package/src/server/conversation/providers/scoped.js +5 -2
  36. package/src/server/conversation/runtime.js +93 -6
  37. package/src/server/hosts/codexConfiguration.js +4 -0
  38. package/test/assistantScroll.browser.test.js +134 -18
  39. package/test/claudeConversationSession.test.js +26 -1
  40. package/test/claudeLifecycle.test.js +293 -0
  41. package/test/claudeProcess.test.js +364 -33
  42. package/test/claudeTurn.test.js +192 -3
  43. package/test/codexAccountChange.test.js +261 -0
  44. package/test/codexConversationsCheckpoint.test.js +3 -0
  45. package/test/codexEvents.test.js +23 -1
  46. package/test/codexHomeStorage.test.js +57 -0
  47. package/test/codexInteractiveArguments.test.js +9 -0
  48. package/test/codexLifecycleIsolation.test.js +441 -0
  49. package/test/codexModelCatalog.test.js +48 -2
  50. package/test/codexProcess.test.js +21 -0
  51. package/test/codexTemporaryProviderRelease.test.js +180 -0
  52. package/test/codexTurn.test.js +52 -1
  53. package/test/conversationNativeStorage.test.js +284 -0
  54. package/test/conversationRuntime.test.js +66 -0
  55. package/test/conversationSession.test.js +4 -8
  56. package/test/messageDelivery.browser.test.js +18 -2
  57. package/test/messageDelivery.test.js +34 -1
  58. package/test/openCodeClient.test.js +31 -0
  59. package/test/openCodeConnectionChange.test.js +166 -0
  60. package/test/openCodeProcess.test.js +36 -0
  61. 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 connectionChanging = false;
187
-
188
- function requireConnectionAvailable() {
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
- async function ensure(selected, start) {
242
- requireConnectionAvailable();
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 currentConnection = sharedProcess.connections.get(selected?.modelProviderId);
248
- if (!selected || (
249
- currentConnection?.canonicalUrl === selected.canonicalUrl &&
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
- return sharedProcess;
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
- sharedProcess = {
271
- connections: new Map(connections.map(connection => [
272
- connection.modelProviderId,
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
- requireConnectionAvailable();
463
- connectionChanging = true;
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())).catch(prepared.failure);
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 target = await ensureOpenCodeSession(acquired, preparation.session());
549
- const turn = preparation.turn(target);
550
- const result = await runOpenCodeConversationTurn(target.server.client, target.upstreamSessionId, turn.input, {
551
- ...turn.options,
552
- ...(turn.release ? { close: () => releaseProcessTarget(target, {}, turn.release) } : {})
553
- });
554
- return turn.completeResult(result);
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
- const providerExit = cleanup ? await closeSession(cleanup.sessionId, cleanup.options, cleanup.application) : null;
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
- await events.close();
846
- tracked.error = error;
847
- if (tracked.promptAttempted) await stopUnobservedSession(observedTarget, conversationId);
848
- tracked.active = false;
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
- credentialFailure = openCodeCredentialFailure(failure);
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
- credentialFailure = openCodeCredentialFailure(failure);
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, { acquire: native.owner.acquire });
261
+ return native.owner.hasActiveTemporaryConversation(context);
262
262
  },
263
263
  releaseRenewalPredecessorProcessExitProof({ native, context }) {
264
- return native.owner.closeSession(context, native.application);
264
+ return native.owner.releaseSession(context, native.application);
265
265
  },
266
266
  releaseRenewalSuccessorProcessExitProof({ native, context }) {
267
- return native.owner.closeSession(context, native.application);
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 : { accepted: result.admission === "accepted" };
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
- return { accepted: history.userIds.includes(nativeId),
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
- async function nativeStorageProvider({ sessionId, native, binding, context }) {
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 { provider: await native.providerOwner.ensureSession(prepared.providerContext),
132
- toolHomeSource: prepared.toolHomeSource };
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
- async listConversationStorage(prepared) {
164
- const { provider } = await nativeStorageProvider(prepared);
164
+ listConversationStorage(prepared) {
165
165
  const { binding } = prepared;
166
- return (await provider.listNativeThreadsForCwd(binding.workdir)).map((conversationId) => ({ ...binding, conversationId }));
166
+ return withNativeStorageProvider(prepared, async provider =>
167
+ (await provider.listNativeThreadsForCwd(binding.workdir)).map((conversationId) => ({ ...binding, conversationId })));
167
168
  },
168
- async retireConversationHistory(prepared) {
169
- const { provider, toolHomeSource } = await nativeStorageProvider(prepared);
170
- return retireCodexConversationHistory(provider, prepared.binding, {
171
- toolHomeSource, beforeDelete: prepared.context.beforeDelete, signal: prepared.context.signal,
172
- errorPrefix: prepared.native.errorPrefix
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
- const acquired = await native.runOwner.conversationContext(sessionId, { conversationId: input.threadId }, context);
221
- if (acquired.ok === false) return acquired;
222
- await acquired.provider.updateThreadSettings(input.threadId, { model: input.model, effort: input.effort });
223
- return { ok: true };
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);