@jskit-ai/assistant-core 0.2.2 → 0.2.4

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 (59) 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 +49 -12
  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 +105 -58
  12. package/src/server/conversation/claudeTurn.js +179 -59
  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 +234 -29
  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 +13 -1
  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 +225 -64
  32. package/src/server/conversation/providers/claudeDriver.js +3 -3
  33. package/src/server/conversation/providers/codexDriver.js +10 -4
  34. package/src/server/conversation/providers/opencodeDriver.js +4 -2
  35. package/src/server/conversation/runtime.js +72 -2
  36. package/src/server/hosts/codexConfiguration.js +4 -0
  37. package/test/assistantScroll.browser.test.js +169 -10
  38. package/test/claudeLifecycle.test.js +293 -0
  39. package/test/claudeProcess.test.js +195 -51
  40. package/test/claudeTurn.test.js +174 -3
  41. package/test/codexAccountChange.test.js +261 -0
  42. package/test/codexConversationsCheckpoint.test.js +3 -0
  43. package/test/codexEvents.test.js +23 -1
  44. package/test/codexHomeStorage.test.js +57 -0
  45. package/test/codexInteractiveArguments.test.js +9 -0
  46. package/test/codexLifecycleIsolation.test.js +441 -0
  47. package/test/codexModelCatalog.test.js +48 -2
  48. package/test/codexProcess.test.js +21 -0
  49. package/test/codexTemporaryProviderRelease.test.js +180 -0
  50. package/test/codexTurn.test.js +21 -1
  51. package/test/conversationNativeStorage.test.js +57 -0
  52. package/test/conversationRuntime.test.js +66 -0
  53. package/test/conversationSession.test.js +4 -8
  54. package/test/messageDelivery.browser.test.js +18 -2
  55. package/test/messageDelivery.test.js +34 -1
  56. package/test/openCodeClient.test.js +31 -0
  57. package/test/openCodeConnectionChange.test.js +166 -0
  58. package/test/openCodeProcess.test.js +36 -0
  59. package/test/openCodeRuntime.test.js +25 -78
@@ -183,15 +183,9 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
183
183
  let sharedProcessStop = null;
184
184
  let failedStartup = null;
185
185
  let closed = false;
186
- let connectionChanging = false;
186
+ let connectionUpdate = Promise.resolve();
187
187
  let storageReads = 0;
188
188
  let storageIdle = null;
189
-
190
- function requireConnectionAvailable() {
191
- if (connectionChanging) throw Object.assign(new Error("OpenCode is changing a connection. Retry after the connection change finishes."), {
192
- code: "assistant_opencode_connection_changing", statusCode: 409
193
- });
194
- }
195
189
  const bindingScopes = new Map();
196
190
  let registryPath = "";
197
191
  let bindingWrite = Promise.resolve();
@@ -240,28 +234,161 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
240
234
  }
241
235
  }
242
236
 
243
- async function ensure(selected, start) {
244
- 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) {
245
372
  await sharedProcessStop;
246
- requireConnectionAvailable();
247
373
  if (failedStartup) await stop("opencode-startup-cleanup");
248
374
  if (sharedProcess) {
249
- const currentConnection = sharedProcess.connections.get(selected?.modelProviderId);
250
- if (!selected || (
251
- currentConnection?.canonicalUrl === selected.canonicalUrl &&
252
- currentConnection?.fingerprint === selected.fingerprint &&
253
- currentConnection?.endpointCode === selected.endpointCode
254
- )) {
375
+ const recorded = sharedProcess.connections.get(selected?.modelProviderId);
376
+ const current = !selected || sameConnection(recorded, selected);
377
+ if (current || (readConnections && inPlaceConnection(recorded, selected))) {
255
378
  const probe = AbortSignal.timeout(1_000);
379
+ let healthy = false;
256
380
  try {
257
381
  await sharedProcess.server.client.health({ signal: probe });
258
- return sharedProcess;
382
+ healthy = true;
259
383
  } catch (error) {
260
384
  // A slow probe is not proof that the shared server stopped working.
261
385
  if (probe.aborted) throw error;
262
- requireConnectionAvailable();
263
386
  await stop("opencode-health-check-failed");
264
387
  }
388
+ if (healthy && sharedProcess) {
389
+ if (readConnections && (!current || !sharedProcess.connectionsCurrent)) await updateConnections(readConnections);
390
+ return sharedProcess;
391
+ }
265
392
  } else {
266
393
  await stop("opencode-connection-changed");
267
394
  }
@@ -269,13 +396,14 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
269
396
  if (sharedProcessStart) return sharedProcessStart;
270
397
  sharedProcessStart = Promise.resolve().then(async () => {
271
398
  const { connections, server } = await start();
272
- sharedProcess = {
273
- connections: new Map(connections.map(connection => [
274
- connection.modelProviderId,
275
- { canonicalUrl: connection.canonicalUrl, endpointCode: connection.endpointCode, fingerprint: connection.fingerprint }
276
- ])),
277
- server
399
+ const started = {
400
+ connections: new Map(connections.map(connection => [connection.modelProviderId, connectionRecord(connection)])),
401
+ connectionsCurrent: true, directories: new Map(), generation: 0, server
278
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(() => {});
279
407
  for (const target of processes.values()) {
280
408
  target.server = openCodeServerForDirectory(server, target.workdir);
281
409
  target.upstream = null;
@@ -360,7 +488,6 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
360
488
  }
361
489
 
362
490
  async function acquire(key, prepare, onFailure = async () => {}) {
363
- requireConnectionAvailable();
364
491
  const pending = processStarts.get(key);
365
492
  if (pending) return pending;
366
493
  const start = Promise.resolve().then(async () => {
@@ -486,31 +613,18 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
486
613
  }
487
614
  const application = preparation.release;
488
615
  if (typeof input.publish === "function") {
489
- requireConnectionAvailable();
490
- connectionChanging = true;
491
- try {
492
- await Promise.all([...processStarts.values()].map(start => start.catch(() => null)));
493
- await sharedProcessStart?.catch(() => null);
494
- await application.assertConnectionChange?.();
495
- const targets = [...processes.values()];
496
- const providerId = text(input.modelProviderId);
497
- if (!providerId || targets.some(target => text(target.modelProviderId) !== providerId || !application.ownsTarget(target))) {
498
- 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."), {
499
- code: "assistant_opencode_connection_in_use", statusCode: 409
500
- });
501
- }
502
- const results = [];
503
- for (const target of targets) {
504
- const proof = await releaseProcessTarget(target, { retainSharedProcess: true }, application);
505
- if (proof?.exited === false || proof?.ok === false) throw new Error("OpenCode could not stop the selected conversation. Retry the connection change.");
506
- results.push(proof);
507
- }
508
- if (sharedProcess || failedStartup) results.push(await stop("opencode-connection-changed"));
509
- await input.publish();
510
- return { closed: results.length, ok: true };
511
- } finally {
512
- connectionChanging = false;
616
+ if (typeof preparation.connections !== "function") {
617
+ throw new TypeError("OpenCode connection changes require the host's saved connections.");
513
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 };
514
628
  }
515
629
  await Promise.all([...processStarts.entries()]
516
630
  .filter(([key]) => key.includes("\0"))
@@ -559,7 +673,8 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
559
673
  return acquire(prepared.key, async () => {
560
674
  const configured = await prepared.configure();
561
675
  const shared = await ensure(prepared.connection,
562
- async () => startPreparedProcess(await configured.prepareServer())).catch(prepared.failure);
676
+ async () => startPreparedProcess(await configured.prepareServer()),
677
+ async () => (await configured.prepareServer()).connections).catch(prepared.failure);
563
678
  configured.target.server = shared.server;
564
679
  return targetFor(prepared.key, configured.target);
565
680
  }, prepared.onFailure).catch(prepared.failure);
@@ -572,13 +687,18 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
572
687
 
573
688
  async function runPreparedRenewalTurn(preparation) {
574
689
  const acquired = await acquirePrepared(await preparation.process());
575
- const target = await ensureOpenCodeSession(acquired, preparation.session());
576
- const turn = preparation.turn(target);
577
- const result = await runOpenCodeConversationTurn(target.server.client, target.upstreamSessionId, turn.input, {
578
- ...turn.options,
579
- ...(turn.release ? { close: () => releaseProcessTarget(target, {}, turn.release) } : {})
580
- });
581
- 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
+ }
582
702
  }
583
703
 
584
704
  async function createPreparedConversation(prepared) {
@@ -596,6 +716,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
596
716
  throw selection.profileChanged();
597
717
  }
598
718
  if (!conversationId && !selection.createIfMissing) throw selection.identityRequired();
719
+ if (!previous?.active) await currentDirectory(target.workdir);
599
720
  const selected = await selectConversation(prepared.key, target, conversationId, selection.input, selection.registration);
600
721
  return { ...selected, executionProfile: prepared.executionProfile, target };
601
722
  }
@@ -640,7 +761,13 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
640
761
  });
641
762
  if (!result.deleted) return result;
642
763
  const cleanup = prepared.cleanupAfterDelete;
643
- 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
+ }
644
771
  return { conversationId, deleted: true, ok: providerExit?.ok !== false,
645
772
  ...(providerExit ? { providerExit } : {}) };
646
773
  }
@@ -837,12 +964,15 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
837
964
  deliveryMode: "steer"
838
965
  };
839
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);
840
969
  if (tracked.active) {
841
970
  throw Object.assign(new Error("This conversation is still working."), {
842
971
  code: "assistant_opencode_conversation_busy", details: {}, statusCode: 409
843
972
  });
844
973
  }
845
974
  tracked.active = true;
975
+ const releaseDirectory = holdDirectory(target.workdir);
846
976
  tracked.error = null;
847
977
  tracked.interrupted = false;
848
978
  const turnAbort = new AbortController();
@@ -869,10 +999,14 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
869
999
  beforeDispatch: options.beforeDispatch
870
1000
  });
871
1001
  } catch (error) {
872
- await events.close();
873
- tracked.error = error;
874
- if (tracked.promptAttempted) await stopUnobservedSession(observedTarget, conversationId);
875
- 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
+ }
876
1010
  throw error;
877
1011
  }
878
1012
  tracked.inputMessageId = text(admitted.id);
@@ -899,6 +1033,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
899
1033
  }
900
1034
  return result;
901
1035
  })();
1036
+ void tracked.completion.then(releaseDirectory, releaseDirectory);
902
1037
  // Keep a failed admitted turn observable to the client's conversation reads.
903
1038
  void tracked.completion.catch((error) => {
904
1039
  if (tracked.abortController === turnAbort) tracked.error = error;
@@ -1135,6 +1270,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
1135
1270
  try {
1136
1271
  const preparation = await application.prepare({ overwrite: !currentMonitor, threadId: currentThreadId });
1137
1272
  const preparedTarget = preparation.process ? await acquirePrepared(preparation.process) : preparation.target;
1273
+ if (!monitors.get(key)) await currentDirectory(preparedTarget.workdir);
1138
1274
  target = await ensureOpenCodeSession(preparedTarget, preparation.session);
1139
1275
  currentThreadId = target.upstreamSessionId;
1140
1276
  if (startingTurn) {
@@ -1166,6 +1302,16 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
1166
1302
  turn: openCodeTurnSnapshot(turns.get(key), currentThreadId) } };
1167
1303
  }
1168
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
+ }
1169
1315
  admission = Promise.withResolvers();
1170
1316
  // The observer may be between polls when dispatch or persistence fails.
1171
1317
  // Keep the raw promise rejectable without an unhandled rejection.
@@ -1391,6 +1537,13 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
1391
1537
  return monitor;
1392
1538
  }
1393
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
+
1394
1547
  function beginMonitor(key, target, turn, {
1395
1548
  observe, observation, eventReady, readiness, finalResponse,
1396
1549
  writeRun, projectMessages, completeResult, onRetired
@@ -1405,6 +1558,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
1405
1558
  registeredTarget.upstreamSessionId === target.upstreamSessionId ? registeredTarget : null;
1406
1559
  const finalAssistantResultProcess = finalAssistantResultTarget ? sharedProcess : null;
1407
1560
  turns.set(key, turn);
1561
+ const releaseDirectory = holdDirectory(target.workdir);
1408
1562
  const signal = AbortSignal.any([target.abortController.signal, turn.abortController.signal]);
1409
1563
  const monitor = Promise.resolve().then(async () => {
1410
1564
  const events = observation
@@ -1472,7 +1626,9 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
1472
1626
  failure = "OpenCode finished without a user-facing final response. Please send your message again.";
1473
1627
  finalState = "failed";
1474
1628
  } else if (failure) {
1475
- credentialFailure = openCodeCredentialFailure(failure);
1629
+ const disconnected = disconnectedProviderFailure(failure);
1630
+ failure = disconnected || failure;
1631
+ credentialFailure = Boolean(disconnected) || openCodeCredentialFailure(failure);
1476
1632
  finalState = "failed";
1477
1633
  } else if (turn.interruptRequested) {
1478
1634
  finalState = "interrupted";
@@ -1492,7 +1648,9 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
1492
1648
  } else {
1493
1649
  const cause = signal.aborted ? signal.reason : error;
1494
1650
  failure = text(cause?.message) || "OpenCode turn failed.";
1495
- credentialFailure = openCodeCredentialFailure(failure);
1651
+ const disconnected = disconnectedProviderFailure(failure);
1652
+ failure = disconnected || failure;
1653
+ credentialFailure = Boolean(disconnected) || openCodeCredentialFailure(failure);
1496
1654
  providerApiFailure = openCodeProviderApiFailure(cause);
1497
1655
  finalState = target.abortController.signal.aborted
1498
1656
  ? "cancelled"
@@ -1533,6 +1691,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
1533
1691
  if (monitors.get(key) === monitor) {
1534
1692
  monitors.delete(key);
1535
1693
  }
1694
+ releaseDirectory();
1536
1695
  onRetired();
1537
1696
  });
1538
1697
  monitors.set(key, monitor);
@@ -1740,6 +1899,8 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
1740
1899
 
1741
1900
  async function ensurePreparedTerminal(preparation) {
1742
1901
  const target = await acquirePreparedTerminal(preparation);
1902
+ // The native terminal starts its own turns there.
1903
+ await currentDirectory(target.workdir);
1743
1904
  return ensureOpenCodeSession(target, preparation.session());
1744
1905
  }
1745
1906
 
@@ -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);
@@ -218,10 +218,16 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
218
218
  return native.runOwner.ensureSessionReadiness(sessionId, await native.preparation.readiness());
219
219
  },
220
220
  async updateConversationSettings({ sessionId, native, context, input }) {
221
- const acquired = await native.runOwner.conversationContext(sessionId, { conversationId: input.threadId }, context);
222
- if (acquired.ok === false) return acquired;
223
- await acquired.provider.updateThreadSettings(input.threadId, { model: input.model, effort: input.effort });
224
- 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
+ }
225
231
  },
226
232
  async createConversation({ sessionId, native, input, context }) {
227
233
  return native.runOwner.createConversation(sessionId, input, context);
@@ -490,13 +490,15 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
490
490
  const start = host.opencode
491
491
  ? async () => facilities.runtime.startPreparedProcess(await facilities.prepareServer())
492
492
  : () => facilities.start({ signal, connection });
493
+ // A managed host's saved connections reach its running server in place.
494
+ const savedConnections = host.opencode ? async () => (await facilities.prepareServer()).connections : null;
493
495
  if (!native) {
494
496
  native = await facilities.runtime.acquire(binding.directory, async () => {
495
- const shared = await facilities.runtime.ensure(facilities.selected, start);
497
+ const shared = await facilities.runtime.ensure(facilities.selected, start, savedConnections);
496
498
  return { key: binding.directory, workdir: binding.workdir, upstreamSessionId: binding.sessionId,
497
499
  abortController: new AbortController(), server: openCodeServerForDirectory(shared.server, binding.workdir) };
498
500
  });
499
- } else await facilities.runtime.ensure(facilities.selected, start);
501
+ } else await facilities.runtime.ensure(facilities.selected, start, savedConnections);
500
502
  signal.throwIfAborted();
501
503
  await updateBinding({ executionId: native.server.executionId, processDirectory: native.server.privateRoot || "" });
502
504
  const catalogue = await native.server.client.providers({ signal });
@@ -28,6 +28,9 @@ function failure(message, code, statusCode = 409, { messageCode, messageParams }
28
28
  return Object.assign(new Error(message), { code, statusCode }, messageCode ? { messageCode, messageParams: messageParams || {} } : {});
29
29
  }
30
30
 
31
+ // The newest messages cancelled in one open conversation, refused if sent again.
32
+ const MAX_CANCELLED_MESSAGES = 100;
33
+
31
34
  // A native delivery result keeps its own sentence and messageCode when it has one.
32
35
  function deliveryFailure(delivered) {
33
36
  const code = delivered?.code || "conversation_delivery_uncertain";
@@ -1558,6 +1561,10 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1558
1561
  deliveryTiming?.("preparation-started");
1559
1562
  const operation = goal ? "goal" : input?.steer === true ? "steer" : origin === "application" ? "wake" : "send";
1560
1563
  await access(context, entry.id, operation, entry, grant);
1564
+ if (entry.cancelledMessages.has(input?.messageId)) {
1565
+ throw Object.assign(failure("This message was cancelled. Send it again as a new message.", "conversation_message_cancelled", 409,
1566
+ { messageCode: "assistant.conversation.messageCancelled" }), { delivery: "not-sent" });
1567
+ }
1561
1568
  await entry.conversation?.admission(context, operation);
1562
1569
  deliveryTiming?.("authorized");
1563
1570
  if (goal) context?.signal?.throwIfAborted();
@@ -1738,6 +1745,34 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1738
1745
  return { stopped: true };
1739
1746
  }
1740
1747
 
1748
+ // Cancel of one message in a runtime-owned conversation never waits for the
1749
+ // conversation queue. The message is recorded as cancelled at once, so a send
1750
+ // of it still queued, or arriving later, is refused as not sent; its own
1751
+ // submission stops, ending any turn it started. Its unconfirmed request or
1752
+ // pending native attempt is discarded afterwards, in queue order. A message
1753
+ // already accepted stays history.
1754
+ async function cancelDelivery(entry, context, messageId, representation) {
1755
+ entry.cancelledMessages.delete(messageId);
1756
+ entry.cancelledMessages.add(messageId);
1757
+ if (entry.cancelledMessages.size > MAX_CANCELLED_MESSAGES) entry.cancelledMessages.delete(entry.cancelledMessages.values().next().value);
1758
+ const interrupted = Boolean(entry.active?.requests.has(messageId));
1759
+ if (interrupted) await stop(entry, context, undefined, representation);
1760
+ void serial(entry, async () => {
1761
+ await access(context, entry.id, "cancel", entry);
1762
+ if (entry.conversation) {
1763
+ await nativeChangeover(entry, context, null, () => { throw new Error("Cancel never sends a message."); }).discard({ messageId });
1764
+ return;
1765
+ }
1766
+ await storage.write(entry.id, async transaction => {
1767
+ const metadata = await transaction.readMetadata();
1768
+ if (metadata.runtime.request?.messageId !== messageId) return;
1769
+ delete metadata.runtime.request;
1770
+ await transaction.writeMetadata(metadata);
1771
+ });
1772
+ }).catch(() => {});
1773
+ return { ok: true, cancelled: true, messageId, interrupted };
1774
+ }
1775
+
1741
1776
  async function readGoal(entry, context, representation) {
1742
1777
  await access(context, entry.id, "readGoal", entry);
1743
1778
  if (!capabilities(entry).goals) return null;
@@ -2316,7 +2351,10 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
2316
2351
  function retireEntry(entry) {
2317
2352
  entry.disposed = true;
2318
2353
  entry.listeners.clear();
2319
- streams.clear(entry.id);
2354
+ // Live streams are kept per conversation id. A logical conversation streams
2355
+ // through its own owner, so the stream under its id belongs to another entry
2356
+ // with the same id, such as its session's Main conversation.
2357
+ if (!entry.conversation?.commands) streams.clear(entry.id);
2320
2358
  if (entries.get(entry.key) === entry) entries.delete(entry.key);
2321
2359
  }
2322
2360
 
@@ -2570,7 +2608,8 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
2570
2608
  const key = conversation?.namespace || id;
2571
2609
  let entry = entries.get(key);
2572
2610
  if (!entry) {
2573
- entry = { id, key, conversation, host: selectedHost, pending: Promise.resolve(), active: null, listeners: new Set(), error: "" };
2611
+ entry = { id, key, conversation, host: selectedHost, pending: Promise.resolve(), active: null, listeners: new Set(), error: "",
2612
+ cancelledMessages: new Set() };
2574
2613
  entries.set(key, entry);
2575
2614
  entry.initializing = initialize(entry, configuration, engine).catch(error => {
2576
2615
  if (entries.get(key) === entry) entries.delete(key);
@@ -2623,6 +2662,20 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
2623
2662
  await access(context, id, "read", entry);
2624
2663
  return read(entry);
2625
2664
  },
2665
+ // Cancel of one unsent or unconfirmed message always records it; an
2666
+ // application conversation supplies its own operation for that.
2667
+ async cancelDelivery({ messageId } = {}) {
2668
+ if (typeof messageId !== "string" || !messageId) throw new TypeError("A message id is required.");
2669
+ await access(context, id, "cancel", entry);
2670
+ if (entry.conversation?.commands) {
2671
+ if (typeof entry.conversation.commands.cancelDelivery !== "function") {
2672
+ throw failure("This conversation cannot cancel one message.", "conversation_unsupported", 400,
2673
+ { messageCode: "assistant.conversation.messageCancelUnsupported" });
2674
+ }
2675
+ return entry.conversation.commands.cancelDelivery({ messageId }, context);
2676
+ }
2677
+ return cancelDelivery(entry, context, messageId, representation);
2678
+ },
2626
2679
  async cancel(input) {
2627
2680
  await access(context, id, "cancel", entry);
2628
2681
  if (entry.conversation?.commands) return entry.conversation.commands.cancel(input, context);
@@ -2847,6 +2900,23 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
2847
2900
  });
2848
2901
  return retained || cleanupUnopened();
2849
2902
  },
2903
+ // Trusted server lifecycle: the application deleted or archived a logical
2904
+ // conversation that it binds through its own commands. Its open entry, if
2905
+ // any, leaves memory and its handles close. Nothing is opened; native and
2906
+ // storage-owned conversations keep their own disposal.
2907
+ async retireOpenConversation({ namespace, sessionId }) {
2908
+ const entry = entries.get(namespace);
2909
+ if (!entry || entry.id !== sessionId) return { retired: false };
2910
+ if (!entry.conversation?.commands) {
2911
+ throw new TypeError("Only a logical conversation bound through its commands retires without disposal.");
2912
+ }
2913
+ await entry.initializing.catch(() => {});
2914
+ return serial(entry, async () => {
2915
+ if (entry.disposed || entries.get(namespace) !== entry) return { retired: false };
2916
+ retireEntry(entry);
2917
+ return { retired: true };
2918
+ });
2919
+ },
2850
2920
  // Host publication from the same native owner, through the existing entry.
2851
2921
  // This does not subscribe to a provider or retain another observer registry.
2852
2922
  async publishNative({ namespace, sessionId, event }) {
@@ -20,3 +20,7 @@ export {
20
20
  createCodexAppServerIsolation,
21
21
  readCodexToolFreeConfiguration
22
22
  } from "../conversation/codexConfiguration.js";
23
+ export {
24
+ codexDisabledProcessFeatureArguments,
25
+ codexHomeDisposablePaths
26
+ } from "../conversation/codexHomeStorage.js";