@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
@@ -14,6 +14,7 @@ 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;
@@ -27,6 +28,16 @@ function nativeWorkSettled(entry) {
27
28
  return !entry.sending && !entry.process && !entry.executionId && !entry.command && !entry.stopPending;
28
29
  }
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
+
30
41
  export function claudeNativeMessageId(id) {
31
42
  const value = createHash("sha256").update(String(id)).digest("hex");
32
43
  return `${value.slice(0, 8)}-${value.slice(8, 12)}-4${value.slice(13, 16)}-8${value.slice(17, 20)}-${value.slice(20, 32)}`;
@@ -181,9 +192,13 @@ export function createClaudeConversationOwner({
181
192
  // Otherwise the host retains entries and their original checkpoint order.
182
193
  const bound = Boolean(store.releaseExecution);
183
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);
184
199
 
185
200
  function createAccountQueries({ accountIdentity, createProcess }) {
186
- return createClaudeAccountQueries({ accountIdentity,
201
+ return createClaudeAccountQueries({ accountIdentity, configRoot,
187
202
  processes: () => entries.values(), isClosing: () => closing, createProcess });
188
203
  }
189
204
 
@@ -227,6 +242,12 @@ export function createClaudeConversationOwner({
227
242
  return readClaudeHistory({ configRoot, workdir: entry.nativeWorkdir, conversationId: entry.id });
228
243
  }
229
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
+
230
251
  async function open(context, { conversationId = "", mainId, create = false,
231
252
  cleanupExecutionId, recoveringCleanup = false, nativeWorkdir = context.workdir } = {}) {
232
253
  const main = !conversationId || conversationId === mainId;
@@ -246,7 +267,7 @@ export function createClaudeConversationOwner({
246
267
  let saved = store.read(context, id);
247
268
  if (saved && typeof saved.then === "function") saved = await saved;
248
269
  if (!main && !saved && !create && !recoveringCleanup) throw createError("This Claude conversation is unavailable.");
249
- const state = saved ? JSON.parse(saved) : {};
270
+ const state = savedState(saved, id);
250
271
  const entry = { id, key, main, persistent: state.persistent === true, context, process: null, startupCleanup: null,
251
272
  executionId: state.executionId || (recoveringCleanup ? cleanupExecutionId : ""), preparationRevision: 0,
252
273
  accountIdentities: state.accountIdentities || (state.accountIdentity ? { anthropic: state.accountIdentity } : {}),
@@ -326,9 +347,9 @@ export function createClaudeConversationOwner({
326
347
 
327
348
  async function ensureReady(entry) {
328
349
  if (entry.process) {
329
- // Status reads retain active instructions; apply changes at idle admission.
330
- if (isTurnActive(entry)) await bindAccount(entry);
331
- 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);
332
353
  } else {
333
354
  await bindAccount(entry);
334
355
  if (entry.executionId && !hasPendingClaudeStartup(entry)) await entry.nativeTurn.stopProcess("Claude's previous process exit could not be confirmed.");
@@ -707,7 +728,7 @@ export function createClaudeConversationOwner({
707
728
  else await input.onPromptSending?.({ threadId: entry.id, displayAttachments: input.displayAttachments, turnMetadata: actorMetadata });
708
729
  if (input.signal?.aborted) throw Object.assign(createError("The goal command was stopped before delivery."), { delivery: "not-sent" });
709
730
  await entry.nativeTurn.send(command ? entry.process.client : native.client, command ? preparation.message(entry, input) : prompt,
710
- { messageId: uuid, accept, ...(command ? { timeoutMs: command.timeoutMs } : {}) });
731
+ { messageId: uuid, accept, steer: steering, ...(command ? { timeoutMs: command.timeoutMs } : {}) });
711
732
  } catch (error) {
712
733
  if (!command) await entry.nativeTurn.stopProcess(error.message);
713
734
  throw error;
@@ -763,9 +784,11 @@ export function createClaudeConversationOwner({
763
784
  threadId: entry.id, turnId: accepted ? turnId : "" };
764
785
  }
765
786
 
766
- async function hasActiveTemporaryConversation(context, { acquire }) {
767
- await restoreSessionEntries(context, { acquire });
768
- 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));
769
792
  }
770
793
 
771
794
  function hasActiveConversation(binding) {
@@ -804,16 +827,28 @@ export function createClaudeConversationOwner({
804
827
  return [...entries.values()].filter((entry) => entry.context.key === context.key);
805
828
  }
806
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.
807
833
  async function closeSession(context, application) {
808
- 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 }) {
809
845
  closingSessions.add(ctx.key);
810
846
  try {
811
- await restoreSessionEntries(ctx, { acquire });
847
+ if (recover) await recoverSavedCustody(ctx);
812
848
  const proofs = await drain(ctx.key);
813
849
  const terminal = await application.terminals.close(ctx.sessionId);
814
- if (terminal?.ok !== false && context.forgetConversationBinding === true) {
815
- forget(ctx.key);
816
- }
850
+ // A forgotten entry is drained: everything it held is in its saved record.
851
+ if (terminal?.ok !== false && forgetEntries) forget(ctx.key);
817
852
  return { ok: terminal?.ok !== false, closed: proofs.length + Number(terminal?.closed || 0),
818
853
  processExitProof: proofs.at(-1) || { exited: true, scopeEmpty: true }, processExitProofs: proofs };
819
854
  } finally {
@@ -821,19 +856,27 @@ export function createClaudeConversationOwner({
821
856
  }
822
857
  }
823
858
 
824
- async function closeProject(input = {}, application) {
825
- const root = text(input.projectContextRoot);
826
- const contexts = new Map([...entries.values()]
827
- .filter((entry) => !root || path.resolve(store.projectContextRoot(entry)) === path.resolve(root))
828
- .map((entry) => [entry.context.key, entry.context]));
829
- let closed = 0;
830
- let ok = true;
831
- for (const ctx of contexts.values()) {
832
- const result = await closeSession(ctx, application);
833
- closed += result.closed;
834
- 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);
835
871
  }
836
- 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 }));
837
880
  }
838
881
 
839
882
  async function invalidateRuntimes(input = {}, application, account) {
@@ -842,44 +885,78 @@ export function createClaudeConversationOwner({
842
885
  return stopSelected(entry => store.modelProviderId(entry) === input.modelProviderId,
843
886
  "The provider connection changed.");
844
887
  }
845
- closing = true;
846
- try {
847
- await account.queries.invalidate().catch(account.failure);
848
- return await stopRetained(application);
849
- } finally {
850
- 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 };
851
897
  }
852
- }
853
-
854
- // Account changes and shutdown stop only what this server runs: a Claude
855
- // process cannot outlive the server that started it, so retained entries
856
- // hold every one. Saved conversations are not reopened just to be stopped.
857
- async function stopRetained(application) {
858
- 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)));
859
919
  let closed = 0;
860
- let ok = true;
861
- for (const [key, sessionId] of sessions) {
862
- closed += (await drain(key)).length;
863
- const terminal = await application.terminals.close(sessionId);
864
- closed += Number(terminal?.closed || 0);
865
- ok &&= terminal?.ok !== false;
866
- }
867
- 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(" ") } : {}) };
868
930
  }
869
931
 
870
932
  async function drain(key) {
871
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
+ }
872
944
  // Startup already lends cleanup before initialization completes. Stop it
873
945
  // first; waiting for the preparation queue first can exhaust host shutdown.
874
- for (const entry of selected) await entry.nativeTurn.stopProcess();
946
+ await stop();
875
947
  await Promise.allSettled(selected.map(entry => entry.nativeTurn.whenIdle()));
876
- const proofs = [];
877
- 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;
878
951
  return proofs;
879
952
  }
880
953
 
881
954
  function forget(contextKey) {
882
- 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
+ }
883
960
  }
884
961
 
885
962
  async function close(entry) {
@@ -904,12 +981,22 @@ export function createClaudeConversationOwner({
904
981
  }
905
982
  }
906
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.
907
986
  async function stopSelected(select, reason) {
908
987
  let closed = 0;
909
- for (const entry of entries.values()) if (select(entry)) {
910
- 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
+ }
911
998
  }
912
- return { ok: true, closed };
999
+ return { ok: failed.length === 0, closed, failed };
913
1000
  }
914
1001
 
915
1002
  async function wait(entry, input, { context, acquire }) {
@@ -1172,7 +1259,7 @@ export function createClaudeConversationOwner({
1172
1259
  }
1173
1260
 
1174
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,
1175
- interrupt, read, inspectAdmission, hasActiveTemporaryConversation, hasActiveConversation, retireConversationHistory, reconcileSessions, restoreSessionEntries, closeSession, closeProject, invalidateRuntimes, drain, forget, close, deleteConversation, recover, stopSelected, wait, stopConversation,
1262
+ interrupt, read, inspectAdmission, hasActiveTemporaryConversation, hasActiveConversation, retireConversationHistory, reconcileSessions, restoreSessionEntries, closeSession, releaseSession, closeProject, invalidateRuntimes, drain, forget, close, deleteConversation, recover, stopSelected, wait, stopConversation,
1176
1263
  readGoal, updateGoal, renewal, run, steer, cancel });
1177
1264
  }
1178
1265
 
@@ -1189,6 +1276,10 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1189
1276
  // Inputs Claude has begun reading. A queued steer joins the running loop, so
1190
1277
  // one result can finish several of them; queued-only input stays in flight.
1191
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;
1192
1283
  const tasks = new Set();
1193
1284
  let turnId = "";
1194
1285
  let messageId = "";
@@ -1222,6 +1313,20 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1222
1313
  clearFinalAssistantResult();
1223
1314
  }
1224
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
+
1225
1330
  function currentReceiptOwner(owner) {
1226
1331
  return Boolean(entry && owner && inputOwner === owner && acceptedInput === owner &&
1227
1332
  owner.commandId === currentCommandId && entry.process && entry.process === owner.process &&
@@ -1466,6 +1571,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1466
1571
  currentCommandId = uuid;
1467
1572
  startedInputs.add(uuid);
1468
1573
  }
1574
+ if (frame.state !== "queued") waitingSteers.delete(uuid);
1469
1575
  if (frame.state === "completed") {
1470
1576
  inFlight.delete(uuid);
1471
1577
  startedInputs.delete(uuid);
@@ -1509,6 +1615,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1509
1615
  for (const task of frame.tasks || []) if (task.task_id && !task.ambient) tasks.add(task.task_id);
1510
1616
  }
1511
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();
1512
1620
  if (["task_notification", "task_updated"].includes(frame.subtype) &&
1513
1621
  ["completed", "failed", "stopped", "killed"].includes(frame.status || frame.patch?.status)) {
1514
1622
  tasks.delete(frame.task_id);
@@ -1596,7 +1704,10 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1596
1704
  for (const id of pendingAssistantMessages.keys()) await flushAssistantMessages(id, role);
1597
1705
  const output = typeof frame.structured_output === "object" ? JSON.stringify(frame.structured_output) : String(frame.result || "");
1598
1706
  checkOutput(output);
1599
- 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;
1600
1711
  result = failed ? "" : output;
1601
1712
  if (result && ![...messages.values()].some((block) => block.complete && block.role === "assistant" && block.text === result)) {
1602
1713
  await publishMessage({ id: `claude_${frame.uuid || turnId}_result`, role: "assistant", text: result, complete: true,
@@ -1646,6 +1757,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1646
1757
  admissions.clear();
1647
1758
  inFlight.clear();
1648
1759
  startedInputs.clear();
1760
+ waitingSteers.clear();
1761
+ steeringClient = null;
1649
1762
  tasks.clear();
1650
1763
  // Verified process cleanup can precede Claude's terminal generation frame.
1651
1764
  // Retain already displayed text for the existing transcript reader, with
@@ -1714,7 +1827,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1714
1827
  acceptedInput = null;
1715
1828
  clearFinalAssistantResult();
1716
1829
  },
1717
- async send(client, message, { messageId: uuid, accept, timeoutMs = 30_000 } = {}) {
1830
+ async send(client, message, { messageId: uuid, accept, timeoutMs = 30_000, steer = false } = {}) {
1718
1831
  if (!active || stopped || cancelling) throw Object.assign(
1719
1832
  failure("Claude has no active turn for this message.", !active && !stopped && !cancelling
1720
1833
  ? "conversation_steer_turn_finished" : "assistant_claude_turn_failed"),
@@ -1730,14 +1843,20 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1730
1843
  const admitted = Promise.withResolvers();
1731
1844
  void admitted.promise.catch(() => {});
1732
1845
  inFlight.add(uuid);
1846
+ if (steer) {
1847
+ waitingSteers.add(uuid);
1848
+ steeringClient = client;
1849
+ }
1733
1850
  const timer = setTimeout(() => admitted.reject(Object.assign(
1734
1851
  failure("Claude has not acknowledged this prompt. Its delivery is uncertain.", "assistant_claude_admission_unknown"),
1735
1852
  { delivery: "uncertain" }
1736
1853
  )), timeoutMs);
1737
1854
  admissions.set(uuid, { ...admitted, accept, timer, receiptOwner });
1738
1855
  try {
1739
- 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]);
1740
1858
  } catch (error) {
1859
+ waitingSteers.delete(uuid);
1741
1860
  if (inputOwner === receiptOwner) invalidateFinalAssistantResult();
1742
1861
  throw error;
1743
1862
  } finally {
@@ -1753,7 +1872,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1753
1872
  }
1754
1873
  // Claude Code acknowledges streamed input as queued and folds it into the
1755
1874
  // running turn after the current tool call, or starts it once the main
1756
- // 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.
1757
1877
  // The next input owns completion now: a result or task finishing before
1758
1878
  // it starts must not settle this turn with the previous answer.
1759
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