@jskit-ai/assistant-core 0.1.239 → 0.1.241

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 (103) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/package.json +5 -5
  3. package/src/client/conversation/AssistantTranscript.vue +26 -29
  4. package/src/client/conversation/LongTextPreviewBlocks.vue +11 -0
  5. package/src/client/conversation/messageDelivery.js +8 -2
  6. package/src/client/conversation/useAssistantAttachments.js +1 -1
  7. package/src/server/conversation/agentRun.js +12 -198
  8. package/src/server/conversation/attachments.js +6 -5
  9. package/src/server/conversation/boundedToolLoop.js +160 -0
  10. package/src/server/conversation/claudeHistory.js +20 -16
  11. package/src/server/conversation/claudeProcess.js +2 -2
  12. package/src/server/conversation/claudeTurn.js +51 -40
  13. package/src/server/conversation/codexAuthPreflight.js +104 -0
  14. package/src/server/conversation/codexClient.js +11 -16
  15. package/src/server/conversation/codexConfiguration.js +112 -98
  16. package/src/server/conversation/codexConversationStore.js +189 -0
  17. package/src/server/conversation/codexConversations.js +47 -44
  18. package/src/server/conversation/codexDetachedTurn.js +12 -11
  19. package/src/server/conversation/codexEvents.js +95 -99
  20. package/src/server/conversation/codexGoalCommands.js +1 -1
  21. package/src/server/conversation/codexHelperExecution.js +3 -13
  22. package/src/server/conversation/codexHelperThreadLedger.js +7 -13
  23. package/src/server/conversation/codexHelperThreadRecovery.js +2 -16
  24. package/src/server/conversation/codexHelperThreads.js +5 -6
  25. package/src/server/conversation/codexHistoryAdapter.js +32 -26
  26. package/src/server/conversation/codexMessageCommands.js +22 -22
  27. package/src/server/conversation/codexMessageDelivery.js +12 -7
  28. package/src/server/conversation/codexModelCatalog.js +97 -0
  29. package/src/server/conversation/codexNotificationObserver.js +24 -40
  30. package/src/server/conversation/codexNotificationQueue.js +2 -3
  31. package/src/server/conversation/codexOutput.js +20 -27
  32. package/src/server/conversation/codexProcess.js +123 -127
  33. package/src/server/conversation/codexProcessLeader.js +3 -3
  34. package/src/server/conversation/codexProvider.js +31 -614
  35. package/src/server/conversation/codexProviderOwner.js +2 -2
  36. package/src/server/conversation/codexProviderSelection.js +38 -40
  37. package/src/server/conversation/codexRenewalCommands.js +13 -10
  38. package/src/server/conversation/codexRenewalThreads.js +412 -0
  39. package/src/server/conversation/codexRunJournal.js +24 -42
  40. package/src/server/conversation/codexRuntimeLifecycle.js +19 -15
  41. package/src/server/conversation/codexSessionCleanup.js +11 -10
  42. package/src/server/conversation/codexSessionReconciliation.js +6 -6
  43. package/src/server/conversation/codexThreadReadiness.js +19 -33
  44. package/src/server/conversation/codexThreadStatus.js +3 -3
  45. package/src/server/conversation/codexTurn.js +124 -297
  46. package/src/server/conversation/codexTurnRecovery.js +49 -28
  47. package/src/server/conversation/codexTurnState.js +34 -13
  48. package/src/server/conversation/commandWrapper.js +3 -0
  49. package/src/server/conversation/deliveryTiming.js +14 -5
  50. package/src/server/conversation/index.js +1 -0
  51. package/src/server/conversation/localExecution.js +13 -17
  52. package/src/server/conversation/normalize.js +8 -3
  53. package/src/server/conversation/openCodeClient.js +9 -10
  54. package/src/server/conversation/openCodeProcess.js +9 -5
  55. package/src/server/conversation/openCodeRuntime.js +10 -11
  56. package/src/server/conversation/openCodeTurn.js +43 -29
  57. package/src/server/conversation/providers/claudeDriver.js +10 -16
  58. package/src/server/conversation/providers/codexDriver.js +27 -43
  59. package/src/server/conversation/providers/opencodeDriver.js +17 -42
  60. package/src/server/conversation/providers/scoped.js +48 -4
  61. package/src/server/conversation/runtime.js +50 -49
  62. package/src/server/conversation/runtimeStateUpgrade.js +34 -22
  63. package/src/server/conversation/transcript.js +10 -10
  64. package/src/server/hosts/codexConfiguration.js +1 -0
  65. package/src/server/hosts/codexProvider.js +8 -6
  66. package/src/server/index.js +2 -1
  67. package/src/server/lib/assistantToolLoop.js +1 -153
  68. package/src/server/lib/serviceToolCatalog.js +17 -42
  69. package/src/shared/assistantResource.js +4 -2
  70. package/src/shared/conversation/attachments.js +4 -0
  71. package/src/shared/conversation/longTextBlocks.js +54 -19
  72. package/src/shared/conversation/numberedQuestionSugar.js +17 -10
  73. package/src/shared/conversation/pagination.js +4 -6
  74. package/src/shared/conversation/patches.js +1 -4
  75. package/src/shared/conversation/streaming.js +3 -11
  76. package/src/shared/conversation/turns.js +11 -0
  77. package/src/shared/index.js +1 -0
  78. package/test/assistantBoundedToolLoop.test.js +2 -2
  79. package/test/assistantResource.test.js +4 -4
  80. package/test/assistantScroll.browser.test.js +29 -6
  81. package/test/assistantSettingsResource.test.js +2 -2
  82. package/test/claudeConversationSession.test.js +20 -3
  83. package/test/claudeTurn.test.js +27 -7
  84. package/test/codexClient.test.js +20 -0
  85. package/test/codexConversationSession.test.js +5 -3
  86. package/test/codexConversationsCheckpoint.test.js +51 -0
  87. package/test/codexModelCatalog.test.js +1 -1
  88. package/test/codexProviderHome.test.js +51 -0
  89. package/test/codexProviderSelection.test.js +71 -0
  90. package/test/codexTurn.test.js +222 -6
  91. package/test/conversationAgentRun.test.js +2 -1
  92. package/test/conversationAttachments.test.js +27 -2
  93. package/test/conversationDeliveryTiming.test.js +21 -0
  94. package/test/conversationNormalize.test.js +11 -0
  95. package/test/fixtures/nestedListQuestionMessage.md +37 -0
  96. package/test/frameworkActions.test.js +2 -2
  97. package/test/longTextBlocks.test.js +102 -0
  98. package/test/messageDelivery.test.js +7 -1
  99. package/test/openCodeClient.test.js +20 -0
  100. package/test/openCodeRuntime.test.js +1 -1
  101. package/test/openCodeTurn.test.js +90 -0
  102. package/test/opencodeConversationSession.test.js +1 -1
  103. package/test/serviceToolCatalog.test.js +27 -0
@@ -3,7 +3,8 @@ import { execFile } from "node:child_process";
3
3
  import { open, readFile, rename, rm, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { promisify } from "node:util";
6
- import { isPlainObject as isRecord, normalizeText } from "./normalize.js";
6
+ import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
7
+ import { normalizeNullableText } from "./normalize.js";
7
8
 
8
9
  const execute = promisify(execFile);
9
10
  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;
@@ -40,9 +41,9 @@ export function codexAppServerThreadSettings({
40
41
  ...(config && typeof config === "object" && !Array.isArray(config) ? config : {})
41
42
  },
42
43
  cwd,
43
- ...(typeof systemPrompt === "string" && systemPrompt.trim() ? { systemPrompt: normalizeText(systemPrompt) } : {}),
44
+ ...(typeof systemPrompt === "string" && systemPrompt.trim() ? { systemPrompt: normalizeNullableText(systemPrompt) } : {}),
44
45
  ...(hostContext ? { hostContext } : {}),
45
- model: normalizeText(model) || effectiveSettings.model,
46
+ model: normalizeNullableText(model) || effectiveSettings.model,
46
47
  ...(effectiveSettings.modelProviderId ? { modelProvider: effectiveSettings.modelProviderId } : {}),
47
48
  sandbox
48
49
  };
@@ -73,11 +74,11 @@ export function codexAppServerTurnSettings({
73
74
  const settings = {
74
75
  approvalPolicy,
75
76
  cwd,
76
- model: normalizeText(model) || effectiveSettings.model,
77
+ model: normalizeNullableText(model) || effectiveSettings.model,
77
78
  sandboxPolicy: externalSandbox ? { networkAccess: "enabled", type: "externalSandbox" } : sandboxPolicy
78
79
  };
79
80
  if (effectiveSettings.request.reasoning !== false) {
80
- settings.effort = normalizeText(effort) || effectiveSettings.thinking;
81
+ settings.effort = normalizeNullableText(effort) || effectiveSettings.thinking;
81
82
  }
82
83
  if (effectiveSettings.request.summary !== false) {
83
84
  settings.summary = reasoningSummary;
@@ -115,22 +116,22 @@ export function codexInteractiveArguments({
115
116
  export async function codexAppServerProjectHookTrustConfig(provider, cwd = "", {
116
117
  persist = false
117
118
  } = {}) {
118
- const normalizedCwd = normalizeText(cwd);
119
+ const normalizedCwd = normalizeNullableText(cwd);
119
120
  if (!normalizedCwd || typeof provider?.listHooks !== "function") {
120
121
  return null;
121
122
  }
122
123
  await provider.trustProject(normalizedCwd);
123
124
  const result = await provider.listHooks([normalizedCwd]);
124
125
  const record = (Array.isArray(result?.data) ? result.data : [])
125
- .find((item) => normalizeText(item?.cwd) === normalizedCwd);
126
+ .find((item) => normalizeNullableText(item?.cwd) === normalizedCwd);
126
127
  const trustedHooks = (Array.isArray(record?.hooks) ? record.hooks : [])
127
128
  // Session flags contain the host's command hook. Trust its exact hash
128
129
  // alongside project hooks; unrelated account and plugin hooks stay separate.
129
130
  .filter((hook) => hook?.enabled === true && ["project", "sessionFlags"].includes(hook?.source))
130
131
  .map((hook) => ({
131
- currentHash: normalizeText(hook?.currentHash),
132
- key: normalizeText(hook?.key),
133
- trustStatus: normalizeText(hook?.trustStatus)
132
+ currentHash: normalizeNullableText(hook?.currentHash),
133
+ key: normalizeNullableText(hook?.key),
134
+ trustStatus: normalizeNullableText(hook?.trustStatus)
134
135
  }))
135
136
  .filter(({ currentHash, key }) => key && currentHash);
136
137
  if (trustedHooks.length === 0) {
@@ -277,7 +278,7 @@ function deepFreezeCodexAppServerHelperConfig(value) {
277
278
  }
278
279
 
279
280
  function codexAppServerIsolationPolicyError(message = "", details = {}) {
280
- const error = new Error(normalizeText(message) || "Codex cannot prove the helper execution policy.");
281
+ const error = new Error(normalizeNullableText(message) || "Codex cannot prove the helper execution policy.");
281
282
  error.code = "codex_policy_unenforceable";
282
283
  error.details = Object.freeze({ ...details });
283
284
  for (const [name, value] of Object.entries(error.details)) {
@@ -288,7 +289,7 @@ function codexAppServerIsolationPolicyError(message = "", details = {}) {
288
289
  return error;
289
290
  }
290
291
 
291
- /** Original native helper policy verification; hosts supply their profile policy. */
292
+ /** Native helper isolation verification; hosts supply their profile policy. */
292
293
  export function createCodexAppServerIsolation({
293
294
  clientName = "jskit",
294
295
  minimumVersion = "0.151.0",
@@ -346,8 +347,8 @@ export function createCodexAppServerIsolation({
346
347
  );
347
348
  }
348
349
  const context = await provider.currentHelperExecutionContext();
349
- const cwd = normalizeText(context?.cwd);
350
- const accountIdentitySignature = normalizeText(context?.accountIdentitySignature);
350
+ const cwd = normalizeNullableText(context?.cwd);
351
+ const accountIdentitySignature = normalizeNullableText(context?.accountIdentitySignature);
351
352
  if (
352
353
  context?.executionMode !== "helper" ||
353
354
  !cwd ||
@@ -366,7 +367,7 @@ export function createCodexAppServerIsolation({
366
367
  }
367
368
 
368
369
  function codexAppServerSemanticVersionParts(value = "") {
369
- const match = normalizeText(value).match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u);
370
+ const match = normalizeNullableText(value).match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u);
370
371
  if (!match) {
371
372
  return null;
372
373
  }
@@ -375,7 +376,7 @@ export function createCodexAppServerIsolation({
375
376
  }
376
377
 
377
378
  function codexAppServerUserAgentVersionParts(value = "") {
378
- const userAgent = normalizeText(value);
379
+ const userAgent = normalizeNullableText(value);
379
380
  if (
380
381
  !userAgent ||
381
382
  userAgent.length > userAgentMaxLength ||
@@ -398,7 +399,7 @@ export function createCodexAppServerIsolation({
398
399
  { minimumVersion }
399
400
  );
400
401
  }
401
- const userAgent = normalizeText(provider.currentServerInfo()?.userAgent);
402
+ const userAgent = normalizeNullableText(provider.currentServerInfo()?.userAgent);
402
403
  const actualParts = codexAppServerUserAgentVersionParts(userAgent);
403
404
  if (!actualParts) {
404
405
  throw createError(
@@ -482,8 +483,8 @@ export function createCodexAppServerIsolation({
482
483
  }
483
484
  const record = result.data[0];
484
485
  if (
485
- normalizeText(record?.cwd) !== cwd ||
486
- normalizeText(record?.cwd).length > hookFieldMaxLength ||
486
+ normalizeNullableText(record?.cwd) !== cwd ||
487
+ normalizeNullableText(record?.cwd).length > hookFieldMaxLength ||
487
488
  !Array.isArray(record.hooks) ||
488
489
  !Array.isArray(record.errors) ||
489
490
  record.hooks.length > hookMaxCount ||
@@ -500,10 +501,10 @@ export function createCodexAppServerIsolation({
500
501
  );
501
502
  }
502
503
  const hooks = record.hooks.map((hook) => {
503
- const key = normalizeText(hook?.key);
504
- const currentHash = normalizeText(hook?.currentHash);
505
- const handlerType = normalizeText(hook?.handlerType);
506
- const sourcePath = normalizeText(hook?.sourcePath);
504
+ const key = normalizeNullableText(hook?.key);
505
+ const currentHash = normalizeNullableText(hook?.currentHash);
506
+ const handlerType = normalizeNullableText(hook?.handlerType);
507
+ const sourcePath = normalizeNullableText(hook?.sourcePath);
507
508
  if (
508
509
  !key ||
509
510
  [key, currentHash, handlerType, sourcePath].some((field) => (
@@ -600,6 +601,21 @@ export function createCodexAppServerIsolation({
600
601
  return frozenConfig;
601
602
  }
602
603
 
604
+ function isolationConfigFrom(configResult, hookResult, cwd, { effort = "", summary = "" } = {}) {
605
+ const mcpServerNames = codexAppServerHelperMcpServerNames(configResult);
606
+ const hookState = codexAppServerHelperHookState(hookResult, cwd);
607
+ return {
608
+ mcpServerNames,
609
+ hookState,
610
+ config: codexAppServerHelperIsolationConfig({
611
+ effort,
612
+ summary,
613
+ hookKeys: hookState.hookKeys,
614
+ mcpServerNames
615
+ })
616
+ };
617
+ }
618
+
603
619
  async function codexAppServerHelperIsolationState(provider, { effort = "", summary = "" } = {}) {
604
620
  assertCodexAppServerHelperCompatibility(provider);
605
621
  if (
@@ -627,16 +643,13 @@ export function createCodexAppServerIsolation({
627
643
  "Codex app-server reconnected during helper policy verification."
628
644
  );
629
645
  }
630
- const mcpServerNames = codexAppServerHelperMcpServerNames(configResult);
631
- const hookState = codexAppServerHelperHookState(hookResult, executionContext.cwd);
646
+ const { mcpServerNames, hookState, config } = isolationConfigFrom(configResult, hookResult, executionContext.cwd, {
647
+ effort,
648
+ summary
649
+ });
632
650
  return Object.freeze({
633
651
  accountIdentitySignature: executionContext.accountIdentitySignature,
634
- config: codexAppServerHelperIsolationConfig({
635
- effort,
636
- summary,
637
- hookKeys: hookState.hookKeys,
638
- mcpServerNames
639
- }),
652
+ config,
640
653
  connectionGeneration: generation,
641
654
  executionCwd: executionContext.cwd,
642
655
  hookFingerprint: hookState.fingerprint,
@@ -654,7 +667,7 @@ export function createCodexAppServerIsolation({
654
667
  }
655
668
 
656
669
  function codexAppServerHelperVerificationFailure(error, cleanupError, threadId = "") {
657
- const normalizedThreadId = normalizeText(threadId);
670
+ const normalizedThreadId = normalizeNullableText(threadId);
658
671
  if (!cleanupError) {
659
672
  error.codexAppServerHelperThreadId = normalizedThreadId;
660
673
  error.codexAppServerHelperThreadRetired = true;
@@ -686,34 +699,19 @@ export function createCodexAppServerIsolation({
686
699
  throw codexAppServerHelperVerificationFailure(error, cleanupError, threadId);
687
700
  }
688
701
 
689
- async function startCodexAppServerHelperThread(provider, prepare) {
690
- if (
691
- typeof provider?.startThread !== "function" ||
692
- typeof provider?.deleteThread !== "function"
693
- ) {
694
- throw createError(
695
- "Codex helper execution cannot own and clean up its app-server thread."
696
- );
697
- }
698
- const prepared = await prepare();
699
- const thread = await provider.startThread(prepared.settings);
700
- const threadId = normalizeText(thread?.id);
701
- if (!threadId) {
702
- throw createError(
703
- "Codex app-server did not return a helper thread id."
704
- );
705
- }
702
+ // Re-inspect after the native thread exists; a changed surface retires it.
703
+ async function verifyHelperThread(provider, enforcement, thread, threadId, phase) {
706
704
  try {
707
705
  const verified = await codexAppServerHelperIsolationState(
708
706
  provider,
709
707
  {
710
- effort: prepared.enforcement.config.model_reasoning_effort,
711
- summary: prepared.enforcement.config.model_reasoning_summary
708
+ effort: enforcement.config.model_reasoning_effort,
709
+ summary: enforcement.config.model_reasoning_summary
712
710
  }
713
711
  );
714
- if (!codexAppServerHelperIsolationMatches(prepared.enforcement, verified)) {
712
+ if (!codexAppServerHelperIsolationMatches(enforcement, verified)) {
715
713
  throw createError(
716
- "Codex execution surfaces changed while the helper thread was starting."
714
+ `Codex execution surfaces changed while the helper thread was ${phase}.`
717
715
  );
718
716
  }
719
717
  return Object.freeze({
@@ -730,6 +728,26 @@ export function createCodexAppServerIsolation({
730
728
  }
731
729
  }
732
730
 
731
+ async function startCodexAppServerHelperThread(provider, prepare) {
732
+ if (
733
+ typeof provider?.startThread !== "function" ||
734
+ typeof provider?.deleteThread !== "function"
735
+ ) {
736
+ throw createError(
737
+ "Codex helper execution cannot own and clean up its app-server thread."
738
+ );
739
+ }
740
+ const prepared = await prepare();
741
+ const thread = await provider.startThread(prepared.settings);
742
+ const threadId = normalizeNullableText(thread?.id);
743
+ if (!threadId) {
744
+ throw createError(
745
+ "Codex app-server did not return a helper thread id."
746
+ );
747
+ }
748
+ return verifyHelperThread(provider, prepared.enforcement, thread, threadId, "starting");
749
+ }
750
+
733
751
  async function resumeCodexAppServerHelperThread(provider, threadId, prepare) {
734
752
  if (
735
753
  typeof provider?.resumeThread !== "function" ||
@@ -739,7 +757,7 @@ export function createCodexAppServerIsolation({
739
757
  "Codex helper execution cannot safely resume and clean up its app-server thread."
740
758
  );
741
759
  }
742
- const normalizedThreadId = normalizeText(threadId);
760
+ const normalizedThreadId = normalizeNullableText(threadId);
743
761
  if (!normalizedThreadId) {
744
762
  throw createError(
745
763
  "Codex helper resume requires a controller-owned thread id."
@@ -751,42 +769,11 @@ export function createCodexAppServerIsolation({
751
769
  normalizedThreadId,
752
770
  prepared.settings
753
771
  );
754
- try {
755
- const verified = await codexAppServerHelperIsolationState(
756
- provider,
757
- {
758
- effort: enforcement.config.model_reasoning_effort,
759
- summary: enforcement.config.model_reasoning_summary
760
- }
761
- );
762
- if (!codexAppServerHelperIsolationMatches(enforcement, verified)) {
763
- throw createError(
764
- "Codex execution surfaces changed while the helper thread was resuming."
765
- );
766
- }
767
- return Object.freeze({
768
- enforcement: verified,
769
- thread,
770
- threadId: normalizedThreadId
771
- });
772
- } catch (error) {
773
- await throwAfterCodexAppServerHelperVerificationFailure({
774
- error,
775
- provider,
776
- threadId: normalizedThreadId
777
- });
778
- }
772
+ return verifyHelperThread(provider, enforcement, thread, normalizedThreadId, "resuming");
779
773
  }
780
774
 
781
775
  function configuration({ configResult, hookResult, workdir, effort = "", summary = "" }) {
782
- const mcpServerNames = codexAppServerHelperMcpServerNames(configResult);
783
- const hookState = codexAppServerHelperHookState(hookResult, workdir);
784
- return codexAppServerHelperIsolationConfig({
785
- effort,
786
- summary,
787
- hookKeys: hookState.hookKeys,
788
- mcpServerNames
789
- });
776
+ return isolationConfigFrom(configResult, hookResult, workdir, { effort, summary }).config;
790
777
  }
791
778
 
792
779
  // Native Helper requests retain their verified configuration object and the
@@ -924,19 +911,19 @@ export function codexCatalogReasoningEfforts(model = {}) {
924
911
  return new Set((Array.isArray(model?.supportedReasoningEfforts)
925
912
  ? model.supportedReasoningEfforts
926
913
  : [])
927
- .map((option) => normalizeText(option?.reasoningEffort))
914
+ .map((option) => normalizeNullableText(option?.reasoningEffort))
928
915
  .filter(Boolean));
929
916
  }
930
917
 
931
918
  export function codexCatalogModels(rows) {
932
919
  return rows.map((model) => ({
933
- id: normalizeText(model.model),
934
- label: normalizeText(model.displayName) || normalizeText(model.model),
920
+ id: normalizeNullableText(model.model),
921
+ label: normalizeNullableText(model.displayName) || normalizeNullableText(model.model),
935
922
  status: "available",
936
923
  variants: [...codexCatalogReasoningEfforts(model)]
937
924
  .map((variantId) => ({
938
- id: normalizeText(variantId),
939
- label: normalizeText(variantId).replace(/^./u, (value) => value.toUpperCase())
925
+ id: normalizeNullableText(variantId),
926
+ label: normalizeNullableText(variantId).replace(/^./u, (value) => value.toUpperCase())
940
927
  }))
941
928
  }));
942
929
  }
@@ -991,12 +978,21 @@ export function codexProviderConfiguration(provider, apiKey) {
991
978
  };
992
979
  }
993
980
 
981
+ /** Where a curated provider home keeps the model catalogue its config.toml names. */
982
+ export function codexProviderCatalogPath(codexHome) {
983
+ return path.join(codexHome, "models.json");
984
+ }
985
+
986
+ function codexProviderCatalogSetting(codexHome) {
987
+ return `model_catalog_json = ${JSON.stringify(codexProviderCatalogPath(codexHome))}`;
988
+ }
989
+
994
990
  /** Format a private native config file from an already-authorized provider and key. */
995
991
  export function codexProviderFileConfiguration(provider, key, codexHome) {
996
992
  return [
997
993
  `model = ${JSON.stringify(provider.models[0].id)}`,
998
994
  `model_provider = ${JSON.stringify(provider.id)}`,
999
- `model_catalog_json = ${JSON.stringify(path.join(codexHome, "models.json"))}`,
995
+ codexProviderCatalogSetting(codexHome),
1000
996
  `model_reasoning_effort = ${JSON.stringify(provider.models[0].defaultThinking)}`,
1001
997
  'model_reasoning_summary = "none"',
1002
998
  `web_search = "${provider.webSearch ? "live" : "disabled"}"`,
@@ -1010,6 +1006,24 @@ export function codexProviderFileConfiguration(provider, key, codexHome) {
1010
1006
  ].join("\n");
1011
1007
  }
1012
1008
 
1009
+ /**
1010
+ * Copy a curated provider home's config and catalogue into another home, with
1011
+ * the copied config naming the copied catalogue. A config that does not name
1012
+ * its own home's catalogue exactly once is refused rather than left pointing
1013
+ * at the source home.
1014
+ */
1015
+ export async function copyCodexProviderHome(sourceHome, targetHome) {
1016
+ const config = await readFile(path.join(sourceHome, "config.toml"), "utf8");
1017
+ const sourceSetting = codexProviderCatalogSetting(sourceHome);
1018
+ if (config.split(sourceSetting).length !== 2) {
1019
+ throw new Error("Codex provider config does not name its own model catalogue.");
1020
+ }
1021
+ await writeFile(codexProviderCatalogPath(targetHome),
1022
+ await readFile(codexProviderCatalogPath(sourceHome)), { mode: 0o600 });
1023
+ await writeFile(path.join(targetHome, "config.toml"),
1024
+ config.replace(sourceSetting, codexProviderCatalogSetting(targetHome)), { mode: 0o600 });
1025
+ }
1026
+
1013
1027
  export async function verifyCodexProviderKey(provider, apiKey, fetchImpl = fetch) {
1014
1028
  let response;
1015
1029
  try {
@@ -1057,9 +1071,9 @@ export function createCodexAccountReader({
1057
1071
  } = {}) {
1058
1072
  function codexAppServerHelperAuthError(code = "", message = "") {
1059
1073
  const error = new Error(
1060
- normalizeText(message) || "Codex helper authentication is unavailable. Reconnect Codex and retry."
1074
+ normalizeNullableText(message) || "Codex helper authentication is unavailable. Reconnect Codex and retry."
1061
1075
  );
1062
- error.code = normalizeText(code) || `${errorPrefix}codex_helper_auth_unavailable`;
1076
+ error.code = normalizeNullableText(code) || `${errorPrefix}codex_helper_auth_unavailable`;
1063
1077
  return error;
1064
1078
  }
1065
1079
 
@@ -1079,7 +1093,7 @@ export function createCodexAccountReader({
1079
1093
  function codexAccountIdentitySignature(authMode = "", identity = "") {
1080
1094
  return `sha256:${createHash("sha256")
1081
1095
  .update(`${identityNamespace}\0`, "utf8")
1082
- .update(normalizeText(authMode), "utf8")
1096
+ .update(normalizeNullableText(authMode), "utf8")
1083
1097
  .update("\0", "utf8")
1084
1098
  .update(String(identity || ""), "utf8")
1085
1099
  .digest("hex")}`;
@@ -1137,7 +1151,7 @@ export function createCodexAccountReader({
1137
1151
  }
1138
1152
 
1139
1153
  async function readCodexSelectedAccountAuth(options = {}) {
1140
- const toolHomeSource = normalizeText(options.toolHomeSource);
1154
+ const toolHomeSource = normalizeNullableText(options.toolHomeSource);
1141
1155
  if (!toolHomeSource || !path.isAbsolute(toolHomeSource)) {
1142
1156
  throw codexAppServerHelperAuthError(
1143
1157
  `${errorPrefix}codex_helper_auth_unavailable`,
@@ -1145,7 +1159,7 @@ export function createCodexAccountReader({
1145
1159
  );
1146
1160
  }
1147
1161
  const auth = await readBoundedCodexAuthJson(path.join(toolHomeSource, ".codex", "auth.json"));
1148
- const storedMode = normalizeText(auth.auth_mode).toLowerCase();
1162
+ const storedMode = normalizeNullableText(auth.auth_mode).toLowerCase();
1149
1163
  if (storedMode === "chatgpt") {
1150
1164
  const tokens = isRecord(auth.tokens) ? auth.tokens : {};
1151
1165
  const accountId = boundedCodexAuthText(tokens.account_id, {
@@ -0,0 +1,189 @@
1
+ import { normalizeNullableText } from "./normalize.js";
2
+ import { createReentrantConversationStorage } from "./storage.js";
3
+ import { isCompletedEnvelopeTurn } from "./transcript.js";
4
+ import { conversationAgentRunRecord, conversationAgentRunEvent } from "./agentRun.js";
5
+
6
+ /** The original native run occupies its bound conversation segment. Reading a
7
+ * binding without a run never constructs historical ownership or writes state.
8
+ */
9
+ export function createCodexConversationRunStore({ storage, scope, segmentId, isCurrent, clock = () => new Date() } = {}) {
10
+ if (typeof isCurrent !== "function") throw new TypeError("A native run store requires its provider ownership fence.");
11
+ storage = createReentrantConversationStorage(storage);
12
+ const runId = "codex_app_server";
13
+
14
+ function assertScope(sessionId, requestedRunId = runId) {
15
+ if (sessionId !== scope) throw new Error("The native run belongs to another conversation.");
16
+ if (normalizeNullableText(requestedRunId) !== runId) throw new TypeError("This binding stores only its Codex app-server run.");
17
+ if (!isCurrent()) throw new Error("A retired provider cannot change a native binding.");
18
+ }
19
+
20
+ async function currentMetadata(transaction) {
21
+ const metadata = await transaction.readMetadata();
22
+ if (!isCurrent() || metadata.runtime?.segmentId !== segmentId || metadata.runtime?.engine !== "codex") {
23
+ throw new Error("A retired native conversation cannot change the current binding.");
24
+ }
25
+ return metadata;
26
+ }
27
+
28
+ async function readAgentRun(sessionId, requestedRunId) {
29
+ assertScope(sessionId, requestedRunId);
30
+ return storage.read(scope, async transaction => {
31
+ const metadata = await currentMetadata(transaction);
32
+ return conversationAgentRunRecord(metadata.runtime.binding?.codexAppServerRun, runId);
33
+ });
34
+ }
35
+
36
+ function mutateSession(sessionId, operation) {
37
+ assertScope(sessionId);
38
+ return storage.write(scope, async transaction => {
39
+ await currentMetadata(transaction);
40
+ return operation(transaction);
41
+ });
42
+ }
43
+
44
+ return Object.freeze({
45
+ readAgentRun,
46
+ mutateSession,
47
+ async getSession(sessionId) {
48
+ const run = await readAgentRun(sessionId, runId);
49
+ return { sessionId, agentRuns: run ? [run] : [] };
50
+ },
51
+ async writeAgentRunEvent(sessionId, requestedRunId, input = {}) {
52
+ assertScope(sessionId, requestedRunId);
53
+ return mutateSession(sessionId, async transaction => {
54
+ const previous = await readAgentRun(sessionId, runId) || { events: [], id: runId };
55
+ const record = conversationAgentRunEvent(previous, runId, input, { now: clock });
56
+ const metadata = await currentMetadata(transaction);
57
+ metadata.runtime.binding.codexAppServerRun = record;
58
+ await transaction.writeMetadata(metadata);
59
+ return record;
60
+ });
61
+ }
62
+ });
63
+ }
64
+
65
+ /** The native owner uses the existing transcript and transient streams through
66
+ * the same fenced transaction as its original run. Its private delivery read is
67
+ * a projection of the one authored request, never another stored journal.
68
+ */
69
+ export function createCodexConversationStore({ storage, scope, segmentId, isCurrent, transcript, streams } = {}) {
70
+ const runStore = createCodexConversationRunStore({ storage, scope, segmentId, isCurrent });
71
+ const assertCurrent = sessionId => {
72
+ if (sessionId !== scope || !isCurrent()) throw new Error("A retired provider cannot change this conversation.");
73
+ };
74
+ const reader = name => (sessionId, ...args) => {
75
+ assertCurrent(sessionId);
76
+ return transcript[name](sessionId, ...args);
77
+ };
78
+ async function nativeAuthorship(transaction, identity) {
79
+ if (!identity?.threadId || !identity.turnId) return undefined;
80
+ const { runtime } = await transaction.readMetadata();
81
+ const run = runtime.binding?.codexAppServerRun;
82
+ if (runtime.segmentId !== segmentId || runtime.binding.threadId !== identity.threadId ||
83
+ run?.providerThreadId !== identity.threadId || run.providerTurnId !== identity.turnId || !run.outerTurnId) return undefined;
84
+ for (const turnId of (await transaction.listTurnIds()).reverse()) {
85
+ const turn = await transaction.readTurn(turnId);
86
+ if (turn?.metadata?.runtime?.segmentId !== segmentId) continue;
87
+ if (turn.user?.messageId === run.outerTurnId) return { origin: "user", messageId: run.outerTurnId, turnId };
88
+ if (turn.system?.messageId === run.outerTurnId && turn.system.origin === "application") {
89
+ return { origin: "application", messageId: run.outerTurnId, turnId,
90
+ ...(isCompletedEnvelopeTurn(turn) ? { completedEnvelope: true } : {}) };
91
+ }
92
+ }
93
+ const request = runtime.request;
94
+ if (request?.messageId === run.outerTurnId && ["user", "application"].includes(request.origin)) {
95
+ return { origin: request.origin, messageId: request.messageId,
96
+ ...(request.origin === "application" && request.completedEnvelope === true ? { completedEnvelope: true } : {}) };
97
+ }
98
+ return undefined;
99
+ }
100
+ const writer = name => (sessionId, input = {}) => runStore.mutateSession(sessionId, async transaction => {
101
+ const authorship = await nativeAuthorship(transaction, input.nativeIdentity);
102
+ if (authorship?.completedEnvelope && !authorship.turnId) {
103
+ throw new Error("The native output's original turn no longer exists.");
104
+ }
105
+ // Queued human rows do not own an already admitted private native response.
106
+ // Corrections retain their original explicit target and message identity.
107
+ const written = await transcript[name](sessionId, {
108
+ ...input, ...(name !== "upsertConversationAssistantMessage" && authorship?.completedEnvelope && authorship.turnId
109
+ ? { turnId: authorship.turnId } : {})
110
+ });
111
+ if (!written || written.metadata?.runtime?.segmentId) return written;
112
+ const origin = authorship?.origin;
113
+ await transaction.updateTurnMetadata(written.turnId, { runtime: {
114
+ status: "complete", engine: "codex", segmentId, ...(origin ? { origin } : {}),
115
+ ...(input.nativeIdentity?.turnId ? { nativeTurnId: input.nativeIdentity.turnId } : {})
116
+ } });
117
+ return transaction.readTurn(written.turnId);
118
+ });
119
+ return Object.freeze({
120
+ ...runStore,
121
+ readConversationLog: reader("readConversationLog"),
122
+ conversationMessageIdExists: reader("conversationMessageIdExists"),
123
+ writeConversationAssistantMessage: writer("writeConversationAssistantMessage"),
124
+ writeConversationCommentaryMessage: writer("writeConversationCommentaryMessage"),
125
+ writeConversationThinkingMessage: writer("writeConversationThinkingMessage"),
126
+ upsertConversationAssistantMessage: writer("upsertConversationAssistantMessage"),
127
+ async writeConversationUserMessage(sessionId, input = {}) {
128
+ return runStore.mutateSession(sessionId, async transaction => {
129
+ if (input.messageId && await transaction.hasMessage(input.messageId)) return null;
130
+ const metadata = await transaction.readMetadata();
131
+ const request = input.authoredRequest?.messageId === input.messageId ? input.authoredRequest
132
+ : metadata.runtime.request?.messageId === input.messageId ? metadata.runtime.request : null;
133
+ if (!request) {
134
+ const written = await transcript.writeConversationUserMessage(sessionId, input);
135
+ if (!written) return null;
136
+ await transaction.updateTurnMetadata(written.turnId, { runtime: {
137
+ status: "running", engine: "codex", segmentId, origin: "user",
138
+ ...(input.nativeIdentity?.turnId ? { nativeTurnId: input.nativeIdentity.turnId } : {})
139
+ } });
140
+ return transaction.readTurn(written.turnId);
141
+ }
142
+ const turnId = await transaction.nextTurnId();
143
+ await transaction.appendMessage(turnId, {
144
+ role: request.origin === "application" ? "system" : "user",
145
+ messageId: request.messageId, text: request.text, origin: request.origin || "user",
146
+ attachments: request.attachments || input.attachments || [], at: request.at || new Date().toISOString(),
147
+ ...(request.data !== undefined ? { data: request.data } : {}), ...(request.goal ? { goal: request.goal } : {}),
148
+ turnMetadata: { ...input.turnMetadata, runtime: { ...input.turnMetadata?.runtime,
149
+ status: "running", engine: "codex", segmentId, origin: request.origin || "user",
150
+ ...(metadata.runtime.request?.messageId === input.messageId && metadata.runtime.request.completedEnvelope === true
151
+ ? { completedEnvelope: true } : {}),
152
+ ...(input.nativeIdentity?.turnId ? { nativeTurnId: input.nativeIdentity.turnId } : {}),
153
+ ...(request.goal ? { goalMessageId: request.messageId } : {}) } }
154
+ });
155
+ return transaction.readTurn(turnId);
156
+ });
157
+ },
158
+ readMetadataValue(sessionId, key) {
159
+ assertCurrent(sessionId);
160
+ if (key !== "assistant_delivery") throw new Error("The native conversation requested unknown delivery metadata.");
161
+ return storage.read(sessionId, async transaction => {
162
+ const { runtime } = await transaction.readMetadata();
163
+ if (!isCurrent() || runtime.segmentId !== segmentId || runtime.engine !== "codex") {
164
+ throw new Error("A retired native conversation cannot read current delivery metadata.");
165
+ }
166
+ const request = runtime.request;
167
+ return JSON.stringify({ engines: { codex: { pending: request && { ...request,
168
+ displayMessage: request.displayMessage ?? request.text,
169
+ displayAttachments: request.displayAttachments ?? request.attachments } } } });
170
+ });
171
+ },
172
+ updateConversationStream(sessionId, input) {
173
+ return runStore.mutateSession(sessionId, async transaction => {
174
+ const authorship = await nativeAuthorship(transaction, input.nativeIdentity);
175
+ return streams.update(sessionId, { ...input, origin: authorship?.origin, authorship });
176
+ });
177
+ },
178
+ completeConversationStreamMessage(sessionId, messageId, options) {
179
+ assertCurrent(sessionId);
180
+ if (options?.nativeIdentity) return runStore.mutateSession(sessionId, async transaction => {
181
+ const authorship = await nativeAuthorship(transaction, options.nativeIdentity);
182
+ return streams.complete(sessionId, messageId, { ...options, authorship,
183
+ ...(!authorship ? { text: undefined } : {}) });
184
+ });
185
+ return streams.complete(sessionId, messageId, options);
186
+ },
187
+ clearConversationStream(sessionId) { assertCurrent(sessionId); return streams.clear(sessionId); }
188
+ });
189
+ }