@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
@@ -14,11 +14,30 @@ function failure(message, code = "assistant_claude_turn_failed") {
14
14
 
15
15
  const text = normalizeNullableText;
16
16
  const now = () => new Date().toISOString();
17
+ const CLAUDE_SIGNED_OUT_MESSAGE = "Claude isn't connected. Sign in to Claude, then send your message again.";
17
18
 
18
19
  function hasPendingClaudeStartup(entry) {
19
20
  return entry.startupCleanup?.pending === true && entry.startupCleanup.executionId === entry.executionId;
20
21
  }
21
22
 
23
+ // Claude records an admitted prompt in its history, and only a send in
24
+ // progress writes prompts to a live process. With neither left, a prompt
25
+ // missing from history can never arrive. A turn left marked active by a
26
+ // lost process does not change that.
27
+ function nativeWorkSettled(entry) {
28
+ return !entry.sending && !entry.process && !entry.executionId && !entry.command && !entry.stopPending;
29
+ }
30
+
31
+ // Work this server is running: a send, command or stop in progress, a live
32
+ // process, or a startup still lending cleanup. A restored execution with none
33
+ // of these belongs to an earlier server; no Claude process outlives the
34
+ // server that started it.
35
+ function runsNativeWork(entry) {
36
+ return Boolean(entry.sending || entry.process || entry.command || entry.stopPending || hasPendingClaudeStartup(entry));
37
+ }
38
+
39
+ const ACTIVE_TURN_STATES = ["starting", "active", "finalizing"];
40
+
22
41
  export function claudeNativeMessageId(id) {
23
42
  const value = createHash("sha256").update(String(id)).digest("hex");
24
43
  return `${value.slice(0, 8)}-${value.slice(8, 12)}-4${value.slice(13, 16)}-8${value.slice(17, 20)}-${value.slice(20, 32)}`;
@@ -173,9 +192,13 @@ export function createClaudeConversationOwner({
173
192
  // Otherwise the host retains entries and their original checkpoint order.
174
193
  const bound = Boolean(store.releaseExecution);
175
194
  const isTurnActive = entry => bound ? entry.nativeTurn.read().active : Boolean(entry.turn?.active);
195
+ // What cleanup must stop: running work, or custody (an execution or an
196
+ // active turn) that only a verified stop retires. An idle entry has nothing
197
+ // to stop, and stopping it would only rewrite its saved record.
198
+ const holdsNativeWork = entry => runsNativeWork(entry) || Boolean(entry.executionId) || isTurnActive(entry);
176
199
 
177
200
  function createAccountQueries({ accountIdentity, createProcess }) {
178
- return createClaudeAccountQueries({ accountIdentity,
201
+ return createClaudeAccountQueries({ accountIdentity, configRoot,
179
202
  processes: () => entries.values(), isClosing: () => closing, createProcess });
180
203
  }
181
204
 
@@ -219,6 +242,12 @@ export function createClaudeConversationOwner({
219
242
  return readClaudeHistory({ configRoot, workdir: entry.nativeWorkdir, conversationId: entry.id });
220
243
  }
221
244
 
245
+ function savedState(saved, conversationId) {
246
+ if (!saved) return {};
247
+ try { return JSON.parse(saved); }
248
+ catch { throw createError(`The saved Claude conversation ${conversationId} is unreadable. Restore its saved conversation state.`); }
249
+ }
250
+
222
251
  async function open(context, { conversationId = "", mainId, create = false,
223
252
  cleanupExecutionId, recoveringCleanup = false, nativeWorkdir = context.workdir } = {}) {
224
253
  const main = !conversationId || conversationId === mainId;
@@ -238,7 +267,7 @@ export function createClaudeConversationOwner({
238
267
  let saved = store.read(context, id);
239
268
  if (saved && typeof saved.then === "function") saved = await saved;
240
269
  if (!main && !saved && !create && !recoveringCleanup) throw createError("This Claude conversation is unavailable.");
241
- const state = saved ? JSON.parse(saved) : {};
270
+ const state = savedState(saved, id);
242
271
  const entry = { id, key, main, persistent: state.persistent === true, context, process: null, startupCleanup: null,
243
272
  executionId: state.executionId || (recoveringCleanup ? cleanupExecutionId : ""), preparationRevision: 0,
244
273
  accountIdentities: state.accountIdentities || (state.accountIdentity ? { anthropic: state.accountIdentity } : {}),
@@ -318,9 +347,9 @@ export function createClaudeConversationOwner({
318
347
 
319
348
  async function ensureReady(entry) {
320
349
  if (entry.process) {
321
- // Status reads retain active instructions; apply changes at idle admission.
322
- if (isTurnActive(entry)) await bindAccount(entry);
323
- else await ensureProcess(entry);
350
+ // Status reads retain an active turn's instructions and account: an
351
+ // account change never stops running work. Both apply at idle admission.
352
+ if (!isTurnActive(entry)) await ensureProcess(entry);
324
353
  } else {
325
354
  await bindAccount(entry);
326
355
  if (entry.executionId && !hasPendingClaudeStartup(entry)) await entry.nativeTurn.stopProcess("Claude's previous process exit could not be confirmed.");
@@ -563,7 +592,13 @@ export function createClaudeConversationOwner({
563
592
  timeoutMessage: "Claude timed out summarising its context." });
564
593
  }
565
594
 
566
- async function send(entry, input = {}, { renewal = false, command, applicationCommand } = {}) {
595
+ async function send(entry, ...args) {
596
+ entry.sending = (entry.sending || 0) + 1;
597
+ try { return await dispatchPrompt(entry, ...args); }
598
+ finally { entry.sending -= 1; }
599
+ }
600
+
601
+ async function dispatchPrompt(entry, input = {}, { renewal = false, command, applicationCommand } = {}) {
567
602
  const message = command ? input.text : text(input.message || input.prompt);
568
603
  if (!command && !message) throw createError("Enter a message for Claude.");
569
604
  const context = entry.context;
@@ -693,7 +728,7 @@ export function createClaudeConversationOwner({
693
728
  else await input.onPromptSending?.({ threadId: entry.id, displayAttachments: input.displayAttachments, turnMetadata: actorMetadata });
694
729
  if (input.signal?.aborted) throw Object.assign(createError("The goal command was stopped before delivery."), { delivery: "not-sent" });
695
730
  await entry.nativeTurn.send(command ? entry.process.client : native.client, command ? preparation.message(entry, input) : prompt,
696
- { messageId: uuid, accept, ...(command ? { timeoutMs: command.timeoutMs } : {}) });
731
+ { messageId: uuid, accept, steer: steering, ...(command ? { timeoutMs: command.timeoutMs } : {}) });
697
732
  } catch (error) {
698
733
  if (!command) await entry.nativeTurn.stopProcess(error.message);
699
734
  throw error;
@@ -732,10 +767,11 @@ export function createClaudeConversationOwner({
732
767
  }
733
768
  const requestIds = new Map((input.requestMessageIds || [input.messageId]).filter(id => typeof id === "string" && id)
734
769
  .map(id => [claudeNativeMessageId(id), id]));
770
+ const admitted = history.userIds.includes(claudeNativeMessageId(input.messageId || ""));
735
771
  return { ...entry.nativeTurn.readResult(), ok: true,
736
772
  messages: [...messages.values()].map(message => ({ ...message,
737
773
  ...(message.userId ? { requestMessageId: requestIds.get(message.userId) || "" } : {}) })), text: current.text || history.text,
738
- admitted: history.userIds.includes(claudeNativeMessageId(input.messageId || "")) };
774
+ admitted, notAdmitted: !admitted && Boolean(input.messageId) && nativeWorkSettled(entry) };
739
775
  }
740
776
 
741
777
  async function inspectAdmission(entry, input) {
@@ -743,13 +779,16 @@ export function createClaudeConversationOwner({
743
779
  const history = await readHistory(entry);
744
780
  const turnId = claudeNativeMessageId(input.messageId);
745
781
  const accepted = history.userIds.includes(turnId);
746
- return { ok: true, admission: accepted ? "accepted" : "unknown", messageId: input.messageId,
782
+ const admission = accepted ? "accepted" : nativeWorkSettled(entry) ? "not-admitted" : "unknown";
783
+ return { ok: true, admission, messageId: input.messageId,
747
784
  threadId: entry.id, turnId: accepted ? turnId : "" };
748
785
  }
749
786
 
750
- async function hasActiveTemporaryConversation(context, { acquire }) {
751
- await restoreSessionEntries(context, { acquire });
752
- return [...entries.values()].some((entry) => entry.context.key === context.key && !entry.main && entry.turn?.active);
787
+ // Temporary work this server runs. Saved conversations are not reopened to
788
+ // answer: a turn an earlier server saved as active ended with that server.
789
+ function hasActiveTemporaryConversation(context) {
790
+ return [...entries.values()].some((entry) => entry.context.key === context.key && !entry.main &&
791
+ isTurnActive(entry) && runsNativeWork(entry));
753
792
  }
754
793
 
755
794
  function hasActiveConversation(binding) {
@@ -788,16 +827,28 @@ export function createClaudeConversationOwner({
788
827
  return [...entries.values()].filter((entry) => entry.context.key === context.key);
789
828
  }
790
829
 
830
+ // A session's close stops what this server runs for it, then reopens only
831
+ // saved custody an earlier server left. Once the owner is closing for an
832
+ // account change or shutdown it reopens nothing.
791
833
  async function closeSession(context, application) {
792
- const ctx = await application.readContext(context);
834
+ return closeRetained(await application.readContext(context), application,
835
+ { recover: !closing, forget: context.forgetConversationBinding === true });
836
+ }
837
+
838
+ // Renewal proof release ends a session this server no longer uses. Its own
839
+ // close already recovered saved custody while its record could be written.
840
+ async function releaseSession(context, application) {
841
+ return closeRetained(await application.readContext(context), application, { recover: false, forget: true });
842
+ }
843
+
844
+ async function closeRetained(ctx, application, { recover, forget: forgetEntries }) {
793
845
  closingSessions.add(ctx.key);
794
846
  try {
795
- await restoreSessionEntries(ctx, { acquire });
847
+ if (recover) await recoverSavedCustody(ctx);
796
848
  const proofs = await drain(ctx.key);
797
849
  const terminal = await application.terminals.close(ctx.sessionId);
798
- if (terminal?.ok !== false && context.forgetConversationBinding === true) {
799
- forget(ctx.key);
800
- }
850
+ // A forgotten entry is drained: everything it held is in its saved record.
851
+ if (terminal?.ok !== false && forgetEntries) forget(ctx.key);
801
852
  return { ok: terminal?.ok !== false, closed: proofs.length + Number(terminal?.closed || 0),
802
853
  processExitProof: proofs.at(-1) || { exited: true, scopeEmpty: true }, processExitProofs: proofs };
803
854
  } finally {
@@ -805,19 +856,27 @@ export function createClaudeConversationOwner({
805
856
  }
806
857
  }
807
858
 
808
- async function closeProject(input = {}, application) {
809
- const root = text(input.projectContextRoot);
810
- const contexts = new Map([...entries.values()]
811
- .filter((entry) => !root || path.resolve(store.projectContextRoot(entry)) === path.resolve(root))
812
- .map((entry) => [entry.context.key, entry.context]));
813
- let closed = 0;
814
- let ok = true;
815
- for (const ctx of contexts.values()) {
816
- const result = await closeSession(ctx, application);
817
- closed += result.closed;
818
- ok &&= result.ok;
859
+ // Saved custody an earlier server left: an execution whose exit is still
860
+ // unproven, or a turn saved as active. Only those conversations are reopened,
861
+ // so a verified stop can retire them; idle saved conversations stay closed.
862
+ async function recoverSavedCustody(ctx) {
863
+ let saved = store.readSessionConversations(ctx);
864
+ if (saved && typeof saved.then === "function") saved = await saved;
865
+ for (const conversationId of saved.ids) {
866
+ if (entries.has(`${ctx.key}\0${conversationId}`)) continue;
867
+ let record = store.read(saved.context, conversationId);
868
+ if (record && typeof record.then === "function") record = await record;
869
+ const state = savedState(record, conversationId);
870
+ if (state.executionId || ACTIVE_TURN_STATES.includes(state.state)) await acquire(saved.context, conversationId);
819
871
  }
820
- return { ok, closed };
872
+ }
873
+
874
+ // Project close releases what this server runs for the project's sessions,
875
+ // from their retained entries alone, and forgets them: nothing uses them.
876
+ function closeProject(input = {}, application) {
877
+ const root = text(input.projectContextRoot);
878
+ return eachSession(entry => !root || path.resolve(store.projectContextRoot(entry)) === path.resolve(root),
879
+ ctx => closeRetained(ctx, application, { recover: false, forget: true }));
821
880
  }
822
881
 
823
882
  async function invalidateRuntimes(input = {}, application, account) {
@@ -826,44 +885,78 @@ export function createClaudeConversationOwner({
826
885
  return stopSelected(entry => store.modelProviderId(entry) === input.modelProviderId,
827
886
  "The provider connection changed.");
828
887
  }
829
- closing = true;
830
- try {
831
- await account.queries.invalidate().catch(account.failure);
832
- return await stopRetained(application);
833
- } finally {
834
- closing = input.reason === "server-shutdown";
888
+ if (input.reason !== "server-shutdown") {
889
+ // An account change stops and waits on nothing. Claude Code reads its
890
+ // credentials for every request, so running work continues on the newly
891
+ // connected account or ends at once as signed out; each conversation
892
+ // rebinds its account at its next idle admission. Only the account's
893
+ // cached reads are dropped. A cancelled query's process stop that fails
894
+ // stays owned and is retried before the next query.
895
+ account.queries.invalidate().catch(() => {});
896
+ return { ok: true, closed: 0 };
835
897
  }
836
- }
837
-
838
- // Account changes and shutdown stop only what this server runs: a Claude
839
- // process cannot outlive the server that started it, so retained entries
840
- // hold every one. Saved conversations are not reopened just to be stopped.
841
- async function stopRetained(application) {
842
- const sessions = new Map([...entries.values()].map(entry => [entry.context.key, entry.context.sessionId]));
898
+ closing = true;
899
+ // Neither waits for the other: a failed account query cleanup must not
900
+ // leave the sessions' processes running.
901
+ const [queries, retained] = await Promise.allSettled([account.queries.invalidate(), stopRetained(application)]);
902
+ if (queries.status === "rejected") await account.failure(queries.reason);
903
+ if (retained.status === "rejected") throw retained.reason;
904
+ return retained.value;
905
+ }
906
+
907
+ // Shutdown stops only what this server runs: a Claude process cannot outlive
908
+ // the server that started it, so retained entries hold every one. Saved
909
+ // conversations are not reopened just to be stopped.
910
+ function stopRetained(application) {
911
+ return eachSession(() => true, ctx => closeRetained(ctx, application, { recover: false, forget: false }));
912
+ }
913
+
914
+ // Sessions close independently and together: one that fails is reported by
915
+ // its ID while every other session still stops.
916
+ async function eachSession(select, close) {
917
+ const contexts = new Map([...entries.values()].filter(select).map(entry => [entry.context.key, entry.context]));
918
+ const settled = await Promise.allSettled([...contexts.values()].map(ctx => close(ctx)));
843
919
  let closed = 0;
844
- let ok = true;
845
- for (const [key, sessionId] of sessions) {
846
- closed += (await drain(key)).length;
847
- const terminal = await application.terminals.close(sessionId);
848
- closed += Number(terminal?.closed || 0);
849
- ok &&= terminal?.ok !== false;
850
- }
851
- return { ok, closed };
920
+ const failed = [];
921
+ [...contexts.values()].forEach((ctx, index) => {
922
+ const result = settled[index];
923
+ if (result.status === "fulfilled") closed += result.value.closed;
924
+ const error = result.status === "rejected" ? result.reason?.message || String(result.reason)
925
+ : result.value.ok === false ? "Its Claude Code terminal could not be closed." : "";
926
+ if (error) failed.push({ sessionId: ctx.sessionId, error });
927
+ });
928
+ return { ok: failed.length === 0, closed, failed,
929
+ ...(failed.length ? { error: failed.map(({ sessionId, error }) => `Claude session ${sessionId}: ${error}`).join(" ") } : {}) };
852
930
  }
853
931
 
854
932
  async function drain(key) {
855
933
  const selected = [...entries.values()].filter(entry => entry.context.key === key);
934
+ // Fence configuration still in progress: it must not start a process later.
935
+ for (const entry of selected) entry.preparationRevision++;
936
+ const proofs = [];
937
+ let failure;
938
+ async function stop() {
939
+ for (const entry of selected) if (holdsNativeWork(entry)) {
940
+ try { proofs.push(await entry.nativeTurn.stopProcess()); }
941
+ catch (error) { failure ||= error; }
942
+ }
943
+ }
856
944
  // Startup already lends cleanup before initialization completes. Stop it
857
945
  // first; waiting for the preparation queue first can exhaust host shutdown.
858
- for (const entry of selected) await entry.nativeTurn.stopProcess();
946
+ await stop();
859
947
  await Promise.allSettled(selected.map(entry => entry.nativeTurn.whenIdle()));
860
- const proofs = [];
861
- for (const entry of selected) proofs.push(await entry.nativeTurn.stopProcess());
948
+ await stop();
949
+ // Every other entry still stopped; the first failure keeps the close retryable.
950
+ if (failure) throw failure;
862
951
  return proofs;
863
952
  }
864
953
 
865
954
  function forget(contextKey) {
866
- for (const [key, entry] of entries) if (entry.context.key === contextKey) entries.delete(key);
955
+ for (const [key, entry] of entries) if (entry.context.key === contextKey) {
956
+ // Nothing would ever stop a process a forgotten entry started.
957
+ entry.disposed = true;
958
+ entries.delete(key);
959
+ }
867
960
  }
868
961
 
869
962
  async function close(entry) {
@@ -888,12 +981,22 @@ export function createClaudeConversationOwner({
888
981
  }
889
982
  }
890
983
 
984
+ // A provider connection change stops the matching work this server runs.
985
+ // Each entry stops on its own, so one failure is reported by ID.
891
986
  async function stopSelected(select, reason) {
892
987
  let closed = 0;
893
- for (const entry of entries.values()) if (select(entry)) {
894
- await entry.nativeTurn.stopProcess(reason); closed++;
988
+ const failed = [];
989
+ for (const entry of [...entries.values()].filter(select)) {
990
+ entry.preparationRevision++;
991
+ if (!holdsNativeWork(entry)) continue;
992
+ try {
993
+ await entry.nativeTurn.stopProcess(reason);
994
+ closed++;
995
+ } catch (error) {
996
+ failed.push({ sessionId: entry.context.sessionId, conversationId: entry.id, error: error.message });
997
+ }
895
998
  }
896
- return { ok: true, closed };
999
+ return { ok: failed.length === 0, closed, failed };
897
1000
  }
898
1001
 
899
1002
  async function wait(entry, input, { context, acquire }) {
@@ -1155,8 +1258,8 @@ export function createClaudeConversationOwner({
1155
1258
  if (!bound) return { value: delivered.value };
1156
1259
  }
1157
1260
 
1158
- return Object.freeze({ applicationToolsSupported: true, entries, closingSessions, get closing() { return closing; }, createAccountQueries, open, acquire, createConversation, snapshot, readRetainedTurn, readFinalAssistantResult, readHistory, ensureReady, ensureProcess, readContextUsage, compactContext, releaseRetainedGoal, stopForTerminal, prepareTerminal, send, startTurn,
1159
- interrupt, read, inspectAdmission, hasActiveTemporaryConversation, hasActiveConversation, retireConversationHistory, reconcileSessions, restoreSessionEntries, closeSession, closeProject, invalidateRuntimes, drain, forget, close, deleteConversation, recover, stopSelected, wait, stopConversation,
1261
+ return Object.freeze({ applicationToolsSupported: true, entries, closingSessions, get closing() { return closing; }, createAccountQueries, open, acquire, createConversation, snapshot, readRetainedTurn, readFinalAssistantResult, readHistory, nativeWorkSettled, ensureReady, ensureProcess, readContextUsage, compactContext, releaseRetainedGoal, stopForTerminal, prepareTerminal, send, startTurn,
1262
+ interrupt, read, inspectAdmission, hasActiveTemporaryConversation, hasActiveConversation, retireConversationHistory, reconcileSessions, restoreSessionEntries, closeSession, releaseSession, closeProject, invalidateRuntimes, drain, forget, close, deleteConversation, recover, stopSelected, wait, stopConversation,
1160
1263
  readGoal, updateGoal, renewal, run, steer, cancel });
1161
1264
  }
1162
1265
 
@@ -1173,6 +1276,10 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1173
1276
  // Inputs Claude has begun reading. A queued steer joins the running loop, so
1174
1277
  // one result can finish several of them; queued-only input stays in flight.
1175
1278
  const startedInputs = new Set();
1279
+ // Steers written into a running loop that Claude has not begun reading. Claude
1280
+ // Code reads queued input only when the running tool call returns.
1281
+ const waitingSteers = new Set();
1282
+ let steeringClient = null;
1176
1283
  const tasks = new Set();
1177
1284
  let turnId = "";
1178
1285
  let messageId = "";
@@ -1206,6 +1313,20 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1206
1313
  clearFinalAssistantResult();
1207
1314
  }
1208
1315
 
1316
+ // A waiting steer would otherwise sit behind a long foreground command (or a
1317
+ // wait loop) until it returns. Claude's Ctrl+B control moves foreground Bash
1318
+ // and subagents to the background: they keep running and report completion,
1319
+ // and the blocked tool call returns so Claude reads the steer now.
1320
+ function backgroundForegroundWork() {
1321
+ const client = steeringClient;
1322
+ if (!waitingSteers.size || !client?.request || stopped || cancelling) return;
1323
+ void client.request({ subtype: "background_tasks" }).then(
1324
+ () => publish({ type: "diagnostic", stage: "steering_backgrounded", pendingCommands: inFlight.size, backgroundTasks: tasks.size }),
1325
+ // An older CLI still reads the steer when its tool call returns.
1326
+ error => publish({ type: "diagnostic", stage: "steering_background_failed", error: error.message })
1327
+ ).catch(() => {});
1328
+ }
1329
+
1209
1330
  function currentReceiptOwner(owner) {
1210
1331
  return Boolean(entry && owner && inputOwner === owner && acceptedInput === owner &&
1211
1332
  owner.commandId === currentCommandId && entry.process && entry.process === owner.process &&
@@ -1450,6 +1571,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1450
1571
  currentCommandId = uuid;
1451
1572
  startedInputs.add(uuid);
1452
1573
  }
1574
+ if (frame.state !== "queued") waitingSteers.delete(uuid);
1453
1575
  if (frame.state === "completed") {
1454
1576
  inFlight.delete(uuid);
1455
1577
  startedInputs.delete(uuid);
@@ -1493,6 +1615,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1493
1615
  for (const task of frame.tasks || []) if (task.task_id && !task.ambient) tasks.add(task.task_id);
1494
1616
  }
1495
1617
  if (frame.subtype === "task_started" && frame.task_id && !frame.ambient) tasks.add(frame.task_id);
1618
+ // A command Claude started before reading a waiting steer would hold it too.
1619
+ if (frame.subtype === "task_started") backgroundForegroundWork();
1496
1620
  if (["task_notification", "task_updated"].includes(frame.subtype) &&
1497
1621
  ["completed", "failed", "stopped", "killed"].includes(frame.status || frame.patch?.status)) {
1498
1622
  tasks.delete(frame.task_id);
@@ -1580,7 +1704,10 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1580
1704
  for (const id of pendingAssistantMessages.keys()) await flushAssistantMessages(id, role);
1581
1705
  const output = typeof frame.structured_output === "object" ? JSON.stringify(frame.structured_output) : String(frame.result || "");
1582
1706
  checkOutput(output);
1583
- const message = failed ? (frame.errors || []).join("; ") || output || frame.subtype : "";
1707
+ // A conversation whose account signed out under it ends its next request
1708
+ // at once with Claude Code's own "Not logged in" answer.
1709
+ const message = !failed ? "" : /^Not logged in\b/u.test(output) ? CLAUDE_SIGNED_OUT_MESSAGE
1710
+ : (frame.errors || []).join("; ") || output || frame.subtype;
1584
1711
  result = failed ? "" : output;
1585
1712
  if (result && ![...messages.values()].some((block) => block.complete && block.role === "assistant" && block.text === result)) {
1586
1713
  await publishMessage({ id: `claude_${frame.uuid || turnId}_result`, role: "assistant", text: result, complete: true,
@@ -1630,6 +1757,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1630
1757
  admissions.clear();
1631
1758
  inFlight.clear();
1632
1759
  startedInputs.clear();
1760
+ waitingSteers.clear();
1761
+ steeringClient = null;
1633
1762
  tasks.clear();
1634
1763
  // Verified process cleanup can precede Claude's terminal generation frame.
1635
1764
  // Retain already displayed text for the existing transcript reader, with
@@ -1698,7 +1827,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1698
1827
  acceptedInput = null;
1699
1828
  clearFinalAssistantResult();
1700
1829
  },
1701
- async send(client, message, { messageId: uuid, accept, timeoutMs = 30_000 } = {}) {
1830
+ async send(client, message, { messageId: uuid, accept, timeoutMs = 30_000, steer = false } = {}) {
1702
1831
  if (!active || stopped || cancelling) throw Object.assign(
1703
1832
  failure("Claude has no active turn for this message.", !active && !stopped && !cancelling
1704
1833
  ? "conversation_steer_turn_finished" : "assistant_claude_turn_failed"),
@@ -1714,14 +1843,20 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1714
1843
  const admitted = Promise.withResolvers();
1715
1844
  void admitted.promise.catch(() => {});
1716
1845
  inFlight.add(uuid);
1846
+ if (steer) {
1847
+ waitingSteers.add(uuid);
1848
+ steeringClient = client;
1849
+ }
1717
1850
  const timer = setTimeout(() => admitted.reject(Object.assign(
1718
1851
  failure("Claude has not acknowledged this prompt. Its delivery is uncertain.", "assistant_claude_admission_unknown"),
1719
1852
  { delivery: "uncertain" }
1720
1853
  )), timeoutMs);
1721
1854
  admissions.set(uuid, { ...admitted, accept, timer, receiptOwner });
1722
1855
  try {
1723
- await Promise.all([client.send(message, { messageId: uuid, sessionId: conversationId }), admitted.promise]);
1856
+ await Promise.all([client.send(message, { messageId: uuid, sessionId: conversationId })
1857
+ .then(() => { if (steer) backgroundForegroundWork(); }), admitted.promise]);
1724
1858
  } catch (error) {
1859
+ waitingSteers.delete(uuid);
1725
1860
  if (inputOwner === receiptOwner) invalidateFinalAssistantResult();
1726
1861
  throw error;
1727
1862
  } finally {
@@ -1737,7 +1872,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1737
1872
  }
1738
1873
  // Claude Code acknowledges streamed input as queued and folds it into the
1739
1874
  // running turn after the current tool call, or starts it once the main
1740
- // loop is idle. Interrupting instead would kill a running foreground tool.
1875
+ // loop is idle. Interrupting instead would kill a running foreground tool;
1876
+ // send() moves such a tool to the background so the steer is read now.
1741
1877
  // The next input owns completion now: a result or task finishing before
1742
1878
  // it starts must not settle this turn with the previous answer.
1743
1879
  outcome = null;
@@ -11,6 +11,8 @@ import { normalizeNullableText as normalizeAgentText } from "./normalize.js";
11
11
  // reconnect-required output marks the stored credentials invalid.
12
12
  const CODEX_AUTH_PREFLIGHT_TIMEOUT_MS = 15000;
13
13
  const CODEX_AUTH_PREFLIGHT_OUTPUT_TAIL_BYTES = 4096;
14
+ // The host names this run where it lists running work.
15
+ const CODEX_AUTH_PREFLIGHT_EXECUTION = Object.freeze({ label: "Checking the Codex sign-in", operationId: "codex-auth-preflight" });
14
16
 
15
17
  function tailAppend(text = "", chunk = "", maxBytes = CODEX_AUTH_PREFLIGHT_OUTPUT_TAIL_BYTES) {
16
18
  const next = `${String(text || "")}${String(chunk || "")}`;
@@ -60,6 +62,7 @@ async function runCodexAuthPreflight({
60
62
  command: codexCommand,
61
63
  credentialHome: codexAppServerCredentialHome(normalizedToolHomeSource, baseEnv),
62
64
  cwd: processCwd,
65
+ execution: CODEX_AUTH_PREFLIGHT_EXECUTION,
63
66
  inheritProcessEnv: false,
64
67
  mode: "capture",
65
68
  timeout: normalizePositiveInteger(timeoutMs, { fallback: CODEX_AUTH_PREFLIGHT_TIMEOUT_MS })
@@ -1,10 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { execFile } from "node:child_process";
3
- import { open, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
+ import os from "node:os";
4
5
  import path from "node:path";
5
6
  import { promisify } from "node:util";
6
7
  import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
7
8
  import { normalizeNullableText } from "./normalize.js";
9
+ import { codexDisabledProcessFeatureArguments } from "./codexHomeStorage.js";
8
10
 
9
11
  const execute = promisify(execFile);
10
12
  const CODEX_THREAD_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
@@ -101,7 +103,8 @@ export function codexInteractiveArguments({
101
103
  return [
102
104
  command,
103
105
  ...(disableStartupUpdates ? ["-c", "check_for_update_on_startup=false"] : []),
104
- ...(remoteEndpoint ? ["--remote", remoteEndpoint] : []),
106
+ // A remote terminal uses its app-server's features; a local one starts its own.
107
+ ...(remoteEndpoint ? ["--remote", remoteEndpoint] : codexDisabledProcessFeatureArguments()),
105
108
  "--model",
106
109
  model,
107
110
  "-c",
@@ -155,10 +158,42 @@ export async function codexAppServerProjectHookTrustConfig(provider, cwd = "", {
155
158
  return { "hooks.state": state };
156
159
  }
157
160
 
161
+ // What an exported catalogue was made from: the Codex binary, the catalogue
162
+ // source and added models, and the metadata (never the contents) of the home's
163
+ // account and configuration files. A bare command name has no binary identity,
164
+ // so its catalogue is exported on every start.
165
+ async function codexModelCatalogRevision({ command, codexHome, bundled, additionalModels }) {
166
+ if (!path.isAbsolute(command)) return null;
167
+ const identity = async (file) => {
168
+ const found = await stat(file).catch(() => null);
169
+ return found && [found.dev, found.ino, found.size, found.mtimeMs, found.ctimeMs];
170
+ };
171
+ const binary = await identity(command);
172
+ if (!binary) return null;
173
+ return createHash("sha256").update(JSON.stringify({
174
+ command, binary, bundled, additionalModels,
175
+ auth: await identity(path.join(codexHome, "auth.json")),
176
+ config: await identity(path.join(codexHome, "config.toml"))
177
+ })).digest("hex");
178
+ }
179
+
158
180
  // Codex's model manager reads model_catalog_json once, before any threads
159
181
  // exist. A thread override changes routing, but cannot supply model metadata.
160
182
  // Export from the installed CLI so native model definitions stay CLI-owned.
161
- export async function prepareCodexModelCatalog({ command, runtimeDir, bundled = false, signal, additionalModels = [] }) {
183
+ // An app-server start reuses the runtime's exported catalogue until its
184
+ // revision changes, so reconnects and idle restarts run no exporter.
185
+ export async function prepareCodexModelCatalog({
186
+ command, runtimeDir, bundled = false, signal, additionalModels = [],
187
+ codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex")
188
+ }) {
189
+ const catalogPath = path.join(runtimeDir, "models.json");
190
+ const revisionPath = path.join(runtimeDir, "models.revision");
191
+ const revisionInput = { command, codexHome, bundled, additionalModels };
192
+ const revision = await codexModelCatalogRevision(revisionInput);
193
+ if (revision && await stat(catalogPath).then(() => true, () => false) &&
194
+ await readFile(revisionPath, "utf8").catch(() => "") === revision) {
195
+ return catalogPath;
196
+ }
162
197
  let catalog;
163
198
  const models = new Map();
164
199
  try {
@@ -185,15 +220,18 @@ export async function prepareCodexModelCatalog({ command, runtimeDir, bundled =
185
220
  for (const model of additionalModels) {
186
221
  models.set(model.slug, { ...model, priority: ++priority });
187
222
  }
188
- const catalogPath = path.join(runtimeDir, "models.json");
189
223
  const temporary = `${catalogPath}.tmp`;
190
224
  try {
191
225
  signal?.throwIfAborted();
226
+ await rm(revisionPath, { force: true });
192
227
  await writeFile(temporary, JSON.stringify({ ...catalog, models: [...models.values()] }), { mode: 0o600 });
193
228
  await rename(temporary, catalogPath);
194
229
  } finally {
195
230
  await rm(temporary, { force: true });
196
231
  }
232
+ // The exporter can refresh the account's tokens; record what it left behind.
233
+ const exported = await codexModelCatalogRevision(revisionInput);
234
+ if (exported) await writeFile(revisionPath, exported, { mode: 0o600 });
197
235
  return catalogPath;
198
236
  }
199
237