@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
@@ -19,14 +19,41 @@ export function createCodexConversationCommands({
19
19
  interruptFailedCode,
20
20
  namespace,
21
21
  providerSessions,
22
- sessionConnection,
23
22
  ensureSessionProvider,
23
+ borrowSessionProvider,
24
24
  steerFailedCode,
25
25
  turnOutcomes
26
26
  }) {
27
27
  const codexAppServerConversations = new Map();
28
28
  const codexAppServerConversationTurnStarts = new Map();
29
29
 
30
+ // Reads and Temporary chat commands borrow their provider instead of claiming
31
+ // it: the operation returns its borrow when it finishes, and an unused
32
+ // provider stops a short idle later.
33
+ function providerBorrow() {
34
+ return { release() {} };
35
+ }
36
+
37
+ // A persistent conversation holds its borrowed provider only while it
38
+ // observes native work running there: a turn, or an active goal.
39
+ function holdWhileActive(state) {
40
+ const active = state.persistent === true && Boolean(state.unsubscribe) &&
41
+ (codexAppServerConversationTurnIsActive(state.status) || state.goal?.status === "active");
42
+ if (active && !state.releaseProvider) {
43
+ state.releaseProvider = providerSessions.owner.holdProvider(state.provider);
44
+ } else if (!active && state.releaseProvider) {
45
+ const release = state.releaseProvider;
46
+ state.releaseProvider = null;
47
+ release();
48
+ }
49
+ }
50
+
51
+ function forgetObservation(state) {
52
+ state.unsubscribe?.();
53
+ state.unsubscribe = null;
54
+ holdWhileActive(state);
55
+ }
56
+
30
57
  function codexAppServerConversation(sessionId = "", conversationId = "") {
31
58
  return codexAppServerConversations.get(
32
59
  namespace(sessionId)
@@ -70,7 +97,7 @@ export function createCodexConversationCommands({
70
97
  const conversations = codexAppServerConversations.get(key) || new Map();
71
98
  let state = conversations.get(conversationId);
72
99
  if (state?.provider === provider && state.unsubscribe) return state;
73
- state?.unsubscribe?.();
100
+ if (state) forgetObservation(state);
74
101
  state = { conversationId, provider, persistent: true, status: "ready", runId: "", error: "", turnOutcome: "" };
75
102
  state.unsubscribe = provider.subscribe((notification) => {
76
103
  if (codexAppServerNotificationThreadId(notification) !== conversationId) return;
@@ -105,6 +132,7 @@ export function createCodexConversationCommands({
105
132
  turnOutcome: completedOutcome
106
133
  }).catch(() => {});
107
134
  }
135
+ holdWhileActive(state);
108
136
  });
109
137
  conversations.set(conversationId, state);
110
138
  codexAppServerConversations.set(key, conversations);
@@ -139,6 +167,7 @@ export function createCodexConversationCommands({
139
167
  // Stop it once; browser reloads reuse the existing backend observer.
140
168
  if (previous?.provider !== context.provider && (codexAppServerConversationTurnIsActive(status) || goal?.status === "active")) {
141
169
  Object.assign(state, { runId, status: "inProgress", goal });
170
+ holdWhileActive(state);
142
171
  try {
143
172
  await context.provider.stopThreadForObservationLoss(conversationId, "");
144
173
  } catch (error) {
@@ -150,6 +179,7 @@ export function createCodexConversationCommands({
150
179
  }
151
180
  if (goal?.status === "active") status = "inProgress";
152
181
  Object.assign(state, { runId, status, goal });
182
+ holdWhileActive(state);
153
183
  const messages = [];
154
184
  for (const turn of turns) {
155
185
  const turnId = codexAppServerProviderTurnId(turn);
@@ -231,7 +261,7 @@ export function createCodexConversationCommands({
231
261
  return preparation.settings(config);
232
262
  }
233
263
 
234
- async function acquireCodexAppServerConversationContext(sessionId, prepared, input = {}) {
264
+ async function acquireCodexAppServerConversationContext(sessionId, prepared, input = {}, borrow = null) {
235
265
  if (prepared.result) return prepared.result;
236
266
  const { context } = prepared;
237
267
  if (context.assistantScope) {
@@ -267,14 +297,21 @@ export function createCodexConversationCommands({
267
297
  assertAdmitted();
268
298
  const providerOptions = activeProvider ? null : await prepared.providerOptions;
269
299
  assertAdmitted();
270
- const provider = activeProvider?.provider || await ensureSessionProvider(sessionId, providerOptions);
300
+ let provider = activeProvider?.provider;
301
+ // A Helper's provider keeps its own retained lifecycle.
302
+ if (!provider && borrow && !prepared.helperTurn) {
303
+ const borrowed = await borrowSessionProvider(sessionId, providerOptions);
304
+ borrow.release = borrowed.release;
305
+ provider = borrowed.provider;
306
+ }
307
+ provider ||= await ensureSessionProvider(sessionId, providerOptions);
271
308
  assertAdmitted();
272
309
  return prepared.project(provider, activeProvider?.providerOptions || providerOptions, helperRestore);
273
310
  }
274
311
 
275
- async function codexAppServerConversationContext(sessionId, input, options) {
312
+ async function codexAppServerConversationContext(sessionId, input, options, borrow = null) {
276
313
  return acquireCodexAppServerConversationContext(sessionId,
277
- await conversationPreparation.context(sessionId, input, options), input);
314
+ await conversationPreparation.context(sessionId, input, options), input, borrow);
278
315
  }
279
316
 
280
317
  async function codexAppServerEphemeralScopeContext(sessionId, input, scope) {
@@ -289,15 +326,20 @@ export function createCodexConversationCommands({
289
326
 
290
327
  async function createCodexAppServerConversation(sessionId, input = {}, options = {}) {
291
328
  return codexAppServerConversationResult(async () => {
292
- const context = await codexAppServerConversationContext(sessionId, input, options);
293
- if (context.ok === false) return context;
294
- const execution = conversationPreparation.execution(sessionId, input, options, context, null, "create");
295
- return createPreparedCodexAppServerConversation(sessionId, input, {
296
- provider: context.provider,
297
- executionProfile: execution.executionProfile,
298
- helperIsolation: conversationPreparation.isolation,
299
- threadPreparation: () => execution.threadPreparation
300
- });
329
+ const borrow = input.persistent === true ? providerBorrow() : null;
330
+ try {
331
+ const context = await codexAppServerConversationContext(sessionId, input, options, borrow);
332
+ if (context.ok === false) return context;
333
+ const execution = conversationPreparation.execution(sessionId, input, options, context, null, "create");
334
+ return await createPreparedCodexAppServerConversation(sessionId, input, {
335
+ provider: context.provider,
336
+ executionProfile: execution.executionProfile,
337
+ helperIsolation: conversationPreparation.isolation,
338
+ threadPreparation: () => execution.threadPreparation
339
+ });
340
+ } finally {
341
+ borrow?.release();
342
+ }
301
343
  });
302
344
  }
303
345
 
@@ -401,35 +443,44 @@ export function createCodexConversationCommands({
401
443
  ok: false
402
444
  };
403
445
  }
404
- const context = await codexAppServerConversationContext(sessionId, input, options);
405
- if (context.ok === false) return context;
406
- const conversationState = codexAppServerConversation(sessionId, conversationId);
407
- const execution = conversationPreparation.execution(sessionId, input, options, context, conversationState, "start", { conversationId, prompt });
408
- if (input.ephemeral === true && !conversationState) {
409
- return {
410
- ...conversationPreparation.expired(conversationId, input),
411
- code: `${errorPrefix}temporary_conversation_expired`,
412
- ok: false,
413
- };
446
+ const borrow = input.persistent === true ? providerBorrow() : null;
447
+ try {
448
+ const context = await codexAppServerConversationContext(sessionId, input, options, borrow);
449
+ if (context.ok === false) return context;
450
+ // A persistent turn is observed on the provider it runs on, which its
451
+ // conversation then holds until the turn ends.
452
+ const conversationState = input.persistent === true
453
+ ? observeCodexConversation(sessionId, conversationId, context.provider)
454
+ : codexAppServerConversation(sessionId, conversationId);
455
+ const execution = conversationPreparation.execution(sessionId, input, options, context, conversationState, "start", { conversationId, prompt });
456
+ if (input.ephemeral === true && !conversationState) {
457
+ return {
458
+ ...conversationPreparation.expired(conversationId, input),
459
+ code: `${errorPrefix}temporary_conversation_expired`,
460
+ ok: false,
461
+ };
462
+ }
463
+ // Do not evaluate native preparation until the original duplicate/active
464
+ // checks and Helper isolation capability checks have run.
465
+ return await startPreparedCodexAppServerConversationTurn(sessionId, input, {
466
+ executionProfile: execution.executionProfile,
467
+ helperIsolation: conversationPreparation.isolation,
468
+ prepareTurn: execution.prepareTurn,
469
+ authorized: execution.authorized,
470
+ readOnly: execution.readOnly,
471
+ actorMetadata: execution.actorMetadata,
472
+ onEvent: execution.onEvent,
473
+ projectResult: execution.projectResult,
474
+ conversationId,
475
+ messageId,
476
+ prompt,
477
+ provider: context.provider,
478
+ conversationState,
479
+ threadPreparation: () => execution.threadPreparation
480
+ });
481
+ } finally {
482
+ borrow?.release();
414
483
  }
415
- // Do not evaluate native preparation until the original duplicate/active
416
- // checks and Helper isolation capability checks have run.
417
- return startPreparedCodexAppServerConversationTurn(sessionId, input, {
418
- executionProfile: execution.executionProfile,
419
- helperIsolation: conversationPreparation.isolation,
420
- prepareTurn: execution.prepareTurn,
421
- authorized: execution.authorized,
422
- readOnly: execution.readOnly,
423
- actorMetadata: execution.actorMetadata,
424
- onEvent: execution.onEvent,
425
- projectResult: execution.projectResult,
426
- conversationId,
427
- messageId,
428
- prompt,
429
- provider: context.provider,
430
- conversationState,
431
- threadPreparation: () => execution.threadPreparation
432
- });
433
484
  }));
434
485
  }
435
486
 
@@ -453,30 +504,42 @@ export function createCodexConversationCommands({
453
504
  if (input.persistent && conversationState?.persistent && conversationState.provider.isAvailable?.() &&
454
505
  !conversationState.provider.observationFailure && !conversationPreparation.admissionError(sessionId)) {
455
506
  // Poll the already observed native thread. Execution environment setup
456
- // belongs to Send and reconnection, not every history read.
457
- return readPreparedPersistentCodexConversation(sessionId, input, { provider: conversationState.provider });
507
+ // belongs to Send and reconnection, not every history read. The read
508
+ // holds that provider so its idle release cannot stop it mid-read.
509
+ const release = providerSessions.owner.holdProvider(conversationState.provider);
510
+ try {
511
+ return await readPreparedPersistentCodexConversation(sessionId, input, { provider: conversationState.provider });
512
+ } finally {
513
+ release();
514
+ }
458
515
  }
459
- const context = await codexAppServerConversationContext(sessionId, input, options);
460
- if (context.ok === false) {
461
- return context;
516
+ // Reading history or status is not a use that keeps Codex running.
517
+ const borrow = providerBorrow();
518
+ try {
519
+ const context = await codexAppServerConversationContext(sessionId, input, options, borrow);
520
+ if (context.ok === false) {
521
+ return context;
522
+ }
523
+ if (input.persistent) return await readPreparedPersistentCodexConversation(sessionId, input, context);
524
+ const thread = await context.provider.readThread(conversationId);
525
+ const runId = normalizeCodexRunText(input.runId) || codexAppServerThreadTurnId(thread);
526
+ const status = codexAppServerThreadStatus(thread);
527
+ const text = runId
528
+ ? codexAppServerProviderThreadAssistantSegments(thread, runId)
529
+ .map((segment) => segment.text)
530
+ .join("\n\n")
531
+ : "";
532
+ return {
533
+ conversationId,
534
+ error: codexAppServerThreadError(thread),
535
+ ok: true,
536
+ runId,
537
+ status,
538
+ ...conversationPreparation.response(text)
539
+ };
540
+ } finally {
541
+ borrow.release();
462
542
  }
463
- if (input.persistent) return readPreparedPersistentCodexConversation(sessionId, input, context);
464
- const thread = await context.provider.readThread(conversationId);
465
- const runId = normalizeCodexRunText(input.runId) || codexAppServerThreadTurnId(thread);
466
- const status = codexAppServerThreadStatus(thread);
467
- const text = runId
468
- ? codexAppServerProviderThreadAssistantSegments(thread, runId)
469
- .map((segment) => segment.text)
470
- .join("\n\n")
471
- : "";
472
- return {
473
- conversationId,
474
- error: codexAppServerThreadError(thread),
475
- ok: true,
476
- runId,
477
- status,
478
- ...conversationPreparation.response(text)
479
- };
480
543
  });
481
544
  }
482
545
 
@@ -500,17 +563,22 @@ export function createCodexConversationCommands({
500
563
  const result = await scoped.completion;
501
564
  return { ...result, conversationId, ok: true, runId };
502
565
  }
503
- const context = await codexAppServerConversationContext(sessionId, input, {
504
- assistantScope
505
- });
506
- if (context.ok === false) {
507
- return context;
566
+ const borrow = input.persistent === true ? providerBorrow() : null;
567
+ try {
568
+ const context = await codexAppServerConversationContext(sessionId, input, {
569
+ assistantScope
570
+ }, borrow);
571
+ if (context.ok === false) {
572
+ return context;
573
+ }
574
+ const result = await waitForPreparedCodexAppServerConversationTurn(sessionId, input, {
575
+ provider: context.provider,
576
+ onEvent
577
+ });
578
+ return { ...result, ...conversationPreparation.response(result.rawText) };
579
+ } finally {
580
+ borrow?.release();
508
581
  }
509
- const result = await waitForPreparedCodexAppServerConversationTurn(sessionId, input, {
510
- provider: context.provider,
511
- onEvent
512
- });
513
- return { ...result, ...conversationPreparation.response(result.rawText) };
514
582
  });
515
583
  }
516
584
 
@@ -536,14 +604,19 @@ export function createCodexConversationCommands({
536
604
  };
537
605
  }
538
606
  if (input.persistent) {
539
- const context = await codexAppServerConversationContext(sessionId, input, options);
540
- if (context.ok === false) return context;
541
- return stopPersistentCodexConversation({
542
- conversationId,
543
- conversationState,
544
- provider: context.provider,
545
- turnOutcome
546
- });
607
+ const borrow = providerBorrow();
608
+ try {
609
+ const context = await codexAppServerConversationContext(sessionId, input, options, borrow);
610
+ if (context.ok === false) return context;
611
+ return await stopPersistentCodexConversation({
612
+ conversationId,
613
+ conversationState,
614
+ provider: context.provider,
615
+ turnOutcome
616
+ });
617
+ } finally {
618
+ borrow.release();
619
+ }
547
620
  }
548
621
  const result = await interruptDetachedCodexAppServerConversationTurn(sessionId, {
549
622
  agentSettings: input.agentSettings,
@@ -557,12 +630,47 @@ export function createCodexConversationCommands({
557
630
  };
558
631
  }
559
632
 
633
+ // A scope's delete stops only what this server runs for it: its live providers,
634
+ // and a runtime whose earlier stop could not prove its exit. It never acquires
635
+ // a provider, which would start a Codex app-server only to stop it, so it needs
636
+ // no account, tool home or workdir. A runtime other providers share stays theirs.
637
+ async function stopCodexScopeRuntimes(sessionKey) {
638
+ const owner = providerSessions.owner;
639
+ const attempted = new Set([...owner.providers]
640
+ .filter(([providerKey]) => owner.providerOwners.get(providerKey)?.sessionKey === sessionKey)
641
+ .map(([, provider]) => provider));
642
+ const stopped = await owner.stopCachedProvidersForSession(sessionKey, { requireStopped: true });
643
+ const failed = [...stopped.failed];
644
+ const results = [...stopped.results];
645
+ for (const record of [...owner.ownedRuntimes.values()]) {
646
+ if (record.sessionKey !== sessionKey || attempted.has(record.provider) ||
647
+ owner.runtimeIsShared(record.providerKey, record.providerOptions)) continue;
648
+ try {
649
+ // Its provider is no longer cached, so the runtime's own options name its lock.
650
+ results.push(await owner.withLifecycle(() => owner.stopOwnedRuntime(record),
651
+ record.providerKey, record.providerOptions));
652
+ } catch (error) {
653
+ failed.push({ code: normalizeCodexRunText(error?.code), error: codexAppServerErrorMessage(error),
654
+ providerKey: record.providerKey, retryable: error?.retryable === true });
655
+ }
656
+ }
657
+ if (failed.length) {
658
+ const error = new Error(failed.map((failure) => failure.error).join(" "));
659
+ error.code = failed[0].code;
660
+ error.retryable = failed.every((failure) => failure.retryable);
661
+ error.details = { failed };
662
+ throw error;
663
+ }
664
+ return { ok: true, results, stopped: results.some((result) => result?.stopped === true) };
665
+ }
666
+
560
667
  async function deleteScopedCodexAppServerConversation(sessionId, input = {}, options = {}) {
561
668
  const conversationId = normalizeCodexRunText(input.conversationId);
562
669
  const sessionKey = namespace(sessionId);
563
670
  const conversations = codexAppServerConversations.get(sessionKey);
564
- const conversationExpired = input.ephemeral === true && !conversations?.has(conversationId) &&
565
- !(options.assistantScope && input.executionProfile);
671
+ // An ephemeral native thread lives only in the process that created it, so
672
+ // one this server no longer tracks has nothing left to delete.
673
+ const conversationExpired = input.ephemeral === true && !conversations?.has(conversationId);
566
674
  let result;
567
675
  if (conversationExpired) {
568
676
  result = {
@@ -577,23 +685,7 @@ export function createCodexConversationCommands({
577
685
  return { ...result, conversationId };
578
686
  }
579
687
  }
580
- let providerExit = null;
581
- if (options.assistantScope) {
582
- const context = await codexAppServerEphemeralScopeContext(
583
- sessionId,
584
- input,
585
- options.assistantScope
586
- );
587
- if (context.ok === false) {
588
- return context;
589
- }
590
- const connection = sessionConnection(sessionId, context.providerOptions);
591
- providerExit = await providerSessions.owner.stopProvider(connection.providerKey, context.providerOptions, {
592
- preserveProcessExitProof: false,
593
- requireStopped: true,
594
- get runtimeHost() { return connection.runtimeHost; }
595
- });
596
- }
688
+ const providerExit = options.assistantScope ? await stopCodexScopeRuntimes(sessionKey) : null;
597
689
  return {
598
690
  ...result,
599
691
  conversationId,
@@ -602,19 +694,59 @@ export function createCodexConversationCommands({
602
694
  };
603
695
  }
604
696
 
697
+ // Close stops each persistent conversation through the provider that observes
698
+ // it. A closing session refuses a rebuilt context, and a new connection could
699
+ // not see the running turn anyway. One conversation's failure does not skip
700
+ // the others; it stays registered so that a retried close stops it.
701
+ async function stopObservedCodexConversation(state) {
702
+ const { provider } = state;
703
+ if (provider.observationFailure) {
704
+ await provider.failObservation(provider.observationFailure);
705
+ if (codexAppServerConversationTurnIsActive(state.status)) {
706
+ throw new Error("Codex's stop is not yet confirmed.");
707
+ }
708
+ return;
709
+ }
710
+ // A provider its owner has since closed observes nothing left to stop here:
711
+ // the owner stopped or detached its process, and reconnecting it would
712
+ // start a process nothing owns.
713
+ if (![...providerSessions.owner.providers].some(([, owned]) => owned === provider)) return;
714
+ // Closing is not the person stopping the turn; its status alone decides the outcome.
715
+ await stopPersistentCodexConversation({
716
+ conversationId: state.conversationId, conversationState: state, provider, turnOutcome: ""
717
+ });
718
+ }
719
+
605
720
  async function closeCodexAppServerConversations(sessionId) {
606
721
  const key = namespace(sessionId);
607
- for (const state of codexAppServerConversations.get(key)?.values() || []) {
608
- if (state.persistent) {
609
- // Closing is not the person stopping the turn; its status alone decides the outcome.
610
- await stopCodexAppServerConversation(sessionId, { conversationId: state.conversationId, persistent: true },
611
- {}, { turnOutcome: "" });
612
- state.unsubscribe?.();
613
- } else {
722
+ const conversations = codexAppServerConversations.get(key);
723
+ const failed = [];
724
+ for (const state of [...(conversations?.values() || [])]) {
725
+ if (!state.persistent) {
614
726
  await deleteScopedCodexAppServerConversation(sessionId, { conversationId: state.conversationId }).catch(() => null);
727
+ conversations.delete(state.conversationId);
728
+ continue;
729
+ }
730
+ try {
731
+ await stopObservedCodexConversation(state);
732
+ } catch (error) {
733
+ failed.push({ conversationId: state.conversationId, error: codexAppServerErrorMessage(error) });
734
+ continue;
615
735
  }
736
+ forgetObservation(state);
737
+ conversations.delete(state.conversationId);
738
+ }
739
+ if (conversations && codexAppServerConversations.get(key) === conversations && !conversations.size) {
740
+ codexAppServerConversations.delete(key);
741
+ }
742
+ if (failed.length) {
743
+ const error = new Error(`Codex could not confirm that ${failed.length === 1 ? "a conversation" : `${failed.length} conversations`} stopped: ${
744
+ failed.map(({ conversationId, error: reason }) => `${conversationId} (${reason})`).join("; ")}. Retry closing the session.`);
745
+ error.code = `${errorPrefix}codex_conversation_close_failed`;
746
+ error.retryable = true;
747
+ error.details = { failed, sessionId: normalizeCodexRunText(sessionId) };
748
+ throw error;
616
749
  }
617
- codexAppServerConversations.delete(key);
618
750
  }
619
751
 
620
752
  async function codexAppServerConversationControl(sessionId, input, {
@@ -626,10 +758,12 @@ export function createCodexConversationCommands({
626
758
  const prepared = conversationPreparation.control(sessionId, input, operation);
627
759
  if (prepared.result) return prepared.result;
628
760
  const { admission, threadId, turnId } = prepared;
761
+ // Stopping or deleting a thread is cleanup, not a use that keeps Codex running.
762
+ const borrow = providerBorrow();
629
763
  try {
630
764
  let context = null;
631
765
  try {
632
- context = await codexAppServerConversationContext(sessionId, input, { assistantScope, runtime, session });
766
+ context = await codexAppServerConversationContext(sessionId, input, { assistantScope, runtime, session }, borrow);
633
767
  } catch (error) {
634
768
  const unavailable = prepared.unavailable();
635
769
  if (unavailable) return unavailable;
@@ -664,6 +798,7 @@ export function createCodexConversationCommands({
664
798
  }
665
799
  return await dispatch({ provider: context.provider, threadId, turnId });
666
800
  } finally {
801
+ borrow.release();
667
802
  admission.release();
668
803
  }
669
804
  });
@@ -942,6 +1077,7 @@ export function createCodexConversationCommands({
942
1077
  status: "failed",
943
1078
  watcher: null
944
1079
  });
1080
+ holdWhileActive(conversationState);
945
1081
  }
946
1082
  throw error;
947
1083
  }
@@ -1011,7 +1147,10 @@ export function createCodexConversationCommands({
1011
1147
  });
1012
1148
  void conversationState.completion.catch(() => null);
1013
1149
  }
1014
- if (input.persistent && conversationState) Object.assign(conversationState, { runId, status, messageId });
1150
+ if (input.persistent && conversationState) {
1151
+ Object.assign(conversationState, { runId, status, messageId });
1152
+ holdWhileActive(conversationState);
1153
+ }
1015
1154
  return { conversationId, messageId, ok: true, runId, status };
1016
1155
  }
1017
1156
 
@@ -1039,6 +1178,7 @@ export function createCodexConversationCommands({
1039
1178
  conversationState.status = "interrupted";
1040
1179
  conversationState.watcher?.failNow(new Error("Temporary AI turn was stopped."));
1041
1180
  conversationState.watcher = null;
1181
+ holdWhileActive(conversationState);
1042
1182
  }
1043
1183
  return result;
1044
1184
  }
@@ -1069,8 +1209,9 @@ export function createCodexConversationCommands({
1069
1209
  });
1070
1210
  if (result.ok !== false && conversation) {
1071
1211
  const { conversationId, sessionKey, conversations } = conversation;
1072
- conversations?.get(conversationId)?.watcher?.failNow(new Error("Temporary AI conversation was closed."));
1073
- conversations?.get(conversationId)?.unsubscribe?.();
1212
+ const state = conversations?.get(conversationId);
1213
+ state?.watcher?.failNow(new Error("Temporary AI conversation was closed."));
1214
+ if (state) forgetObservation(state);
1074
1215
  conversations?.delete(conversationId);
1075
1216
  if (conversations?.size === 0) {
1076
1217
  codexAppServerConversations.delete(sessionKey);
@@ -1103,6 +1244,7 @@ export function createCodexConversationCommands({
1103
1244
  if (conversationState.goal?.status === "active") conversationState.goal = { ...conversationState.goal, status: "paused" };
1104
1245
  conversationState.watcher?.failNow(new Error("Temporary AI turn was stopped."));
1105
1246
  conversationState.watcher = null;
1247
+ holdWhileActive(conversationState);
1106
1248
  }
1107
1249
  return { ok: true, conversationId, status: "interrupted" };
1108
1250
  }
@@ -241,18 +241,31 @@ function codexAppServerErrorText(value = null, seen = new Set()) {
241
241
  return [...new Set(messages)].join(" ");
242
242
  }
243
243
 
244
+ // A running app-server keeps the credentials it started with. After a sign-out
245
+ // or account switch, OpenAI's rejection ends its turns with ChatGPT's refresh
246
+ // failure or an API key's raw 401; another provider's 401 keeps its own text.
247
+ const CODEX_SIGNED_OUT_MESSAGE = "Codex (OpenAI) isn't connected. Sign in to Codex, then send your message again.";
248
+ const CODEX_SIGNED_OUT_ERRORS = [
249
+ /\bcould not be refreshed because you have since logged out or signed in to another account\b/u,
250
+ /^unexpected status 401 Unauthorized\b.*\burl: https:\/\/(?:api\.openai\.com|chatgpt\.com)\//su
251
+ ];
252
+
253
+ function codexAppServerSignedOutText(text = "") {
254
+ return CODEX_SIGNED_OUT_ERRORS.some((pattern) => pattern.test(text)) ? CODEX_SIGNED_OUT_MESSAGE : text;
255
+ }
256
+
244
257
  function codexAppServerNotificationError(notification = {}) {
245
258
  const params = codexAppServerNotificationParams(notification);
246
259
  const status = isRecord(params.status) ? params.status : {};
247
260
  const turn = isRecord(params.turn) ? params.turn : {};
248
- return normalizeNullableText(
261
+ return codexAppServerSignedOutText(normalizeNullableText(
249
262
  codexAppServerErrorText(params.error) ||
250
263
  params.message ||
251
264
  codexAppServerErrorText(status.error) ||
252
265
  status.message ||
253
266
  codexAppServerErrorText(turn.error) ||
254
267
  turn.message
255
- );
268
+ ));
256
269
  }
257
270
 
258
271
  function codexAppServerNotificationUsageLimitExceeded(notification = {}) {
@@ -795,7 +808,7 @@ function codexAppServerProviderTurnText(thread = null, turnId = "") {
795
808
  }
796
809
 
797
810
  function codexAppServerProviderTurnError(turn = {}) {
798
- return codexAppServerErrorText(turn.error || turn.status?.error || turn.state?.error);
811
+ return codexAppServerSignedOutText(codexAppServerErrorText(turn.error || turn.status?.error || turn.state?.error));
799
812
  }
800
813
 
801
814
  export {
@@ -211,13 +211,22 @@ export function createCodexHelperThreadRecovery(lifecycle, {
211
211
 
212
212
  async function restoreCodexAppServerHelperThreads(projectRuntimeRoot = "", context = {}) {
213
213
  const previous = codexAppServerHelperThreadRestores.get(projectRuntimeRoot) || Promise.resolve();
214
+ // Every failure, including an unreadable ledger or a refused host notice,
215
+ // is reported in the result. Callers that require restoration assert it;
216
+ // cleanup and reconciliation continue with the records that did restore.
214
217
  const restore = previous.catch(() => null).then(async () => {
215
218
  const ledger = codexAppServerHelperThreadLedger(projectRuntimeRoot);
216
- const listed = await ledger.readAll();
217
- const failed = listed.failures.map((failure) => ({
219
+ const failed = [];
220
+ let listed = { failures: [], records: [] };
221
+ try {
222
+ listed = await ledger.readAll();
223
+ } catch (error) {
224
+ failed.push(codexAppServerHelperFailure({ projectRuntimeRoot }, error));
225
+ }
226
+ failed.push(...listed.failures.map((failure) => ({
218
227
  ...failure,
219
228
  projectRuntimeRoot
220
- }));
229
+ })));
221
230
  const normalizedSessionId = normalizeText(context.sessionId);
222
231
  const records = listed.records.filter((record) => {
223
232
  return !normalizedSessionId || record.sessionId === normalizedSessionId;
@@ -238,7 +247,11 @@ export function createCodexHelperThreadRecovery(lifecycle, {
238
247
  failed.push(codexAppServerHelperFailure(record, error));
239
248
  }
240
249
  }
241
- await context.publish({ records, failed, retiredBySession, normalizedSessionId });
250
+ try {
251
+ await context.publish({ records, failed, retiredBySession, normalizedSessionId });
252
+ } catch (error) {
253
+ failed.push(codexAppServerHelperFailure({ projectRuntimeRoot }, error));
254
+ }
242
255
  return {
243
256
  failed,
244
257
  ok: failed.length === 0,
@@ -578,8 +578,17 @@ export function createCodexHelperThreadLifecycle({
578
578
  if (result.ok !== false) {
579
579
  return result;
580
580
  }
581
+ // Name the first failing record and its cause; details keep every failure.
582
+ const [first, ...others] = Array.isArray(result.failed) ? result.failed : [];
583
+ const sessionId = normalizeText(first?.sessionId);
584
+ const threadId = normalizeText(first?.threadId);
585
+ const subject = sessionId
586
+ ? `session ${sessionId}${threadId ? `, thread ${threadId}` : ""}`
587
+ : "the project's saved records";
588
+ const cause = first ? ` For ${subject}: ${normalizeText(first.error) || "restoration failed."}` : "";
589
+ const more = others.length ? ` (${others.length} more)` : "";
581
590
  throw codexAppServerHelperOwnershipError(
582
- `${applicationName} could not reconcile persisted low-cost assistant thread ownership.`,
591
+ `${applicationName} could not reconcile persisted low-cost assistant thread ownership.${cause}${more}`,
583
592
  {
584
593
  failed: result.failed,
585
594
  retryable: result.failed?.some((failure) => failure.retryable !== false) !== false,