@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
@@ -0,0 +1,69 @@
1
+ import { lstat, readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ // At process start Codex syncs the openai/plugins repository into `.tmp/plugins`
5
+ // and downloads plugin, app and connector catalogues into `cache/`, roughly
6
+ // 100-250 MB per home, refreshed and never pruned. No JSKIT conversation uses
7
+ // plugins or apps (helper and tool-free threads also disable them per thread),
8
+ // so every Codex process JSKIT launches turns both off. Codex's terminal also
9
+ // starts a shared app-server daemon by default (`daemon_auto_start`, 0.163),
10
+ // with a detached updater that keeps it running after the terminal closes; a
11
+ // JSKIT terminal runs its own process or its host's app-server instead.
12
+ const CODEX_DISABLED_PROCESS_FEATURES = Object.freeze(["plugins", "apps", "daemon_auto_start"]);
13
+
14
+ export function codexDisabledProcessFeatureArguments() {
15
+ return CODEX_DISABLED_PROCESS_FEATURES.flatMap((feature) => ["-c", `features.${feature}=false`]);
16
+ }
17
+
18
+ // What those features leave behind (Codex 0.162: core-plugins startup_sync.rs,
19
+ // git_policy.rs, marketplace_policy.rs and remote_catalog_cache.rs; connectors
20
+ // directory_cache.rs and connector_runtime persistence.rs). User-installed
21
+ // plugins under `plugins/` and configured marketplaces are not listed.
22
+ const PLUGIN_AND_APP_ENTRIES = Object.freeze([
23
+ [".tmp", /^(?:plugins(?:$|[.-])|git-|bundled-marketplaces$)/u],
24
+ ["cache", /^(?:remote_plugin_catalog|codex_apps_tools|codex_apps_server_info|codex_app_directory|bundled_plugin_exclusions)$/u]
25
+ ]);
26
+
27
+ // A home that will not launch again keeps its auth, config, catalogue, sessions
28
+ // and state/goal/memory/queue/thread-history databases, which retained
29
+ // conversations may still read. Its diagnostic log database and temporary and
30
+ // cache directories serve only a running process; Codex prunes the log database
31
+ // (10 days, 64 MiB) only while it runs.
32
+ const RETIRED_HOME_ENTRIES = /^(?:logs_\d+\.sqlite(?:-wal|-shm)?|\.tmp|cache|tmp)$/u;
33
+
34
+ async function directoryNames(directory) {
35
+ try {
36
+ // Never follow a linked home or subdirectory.
37
+ if (!(await lstat(directory)).isDirectory()) return [];
38
+ return await readdir(directory);
39
+ } catch (error) {
40
+ if (error.code === "ENOENT") return [];
41
+ throw error;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Existing Codex-owned entries in `codexHome` that no JSKIT-launched Codex
47
+ * uses: plugin and app caches, or with `retired: true` everything runtime-only
48
+ * in a home that will not launch again. Returns sorted absolute paths; a link is
49
+ * returned as itself. Remove them only while no Codex process uses the home.
50
+ */
51
+ export async function codexHomeDisposablePaths(codexHome, { retired = false } = {}) {
52
+ if (typeof codexHome !== "string" || !path.isAbsolute(codexHome)) {
53
+ throw new TypeError("An absolute Codex home is required.");
54
+ }
55
+ const found = [];
56
+ const names = await directoryNames(codexHome);
57
+ if (retired) {
58
+ for (const name of names) {
59
+ if (RETIRED_HOME_ENTRIES.test(name)) found.push(path.join(codexHome, name));
60
+ }
61
+ } else if (names.length) {
62
+ for (const [directory, pattern] of PLUGIN_AND_APP_ENTRIES) {
63
+ for (const name of await directoryNames(path.join(codexHome, directory))) {
64
+ if (pattern.test(name)) found.push(path.join(codexHome, directory, name));
65
+ }
66
+ }
67
+ }
68
+ return found.sort();
69
+ }
@@ -11,6 +11,7 @@ import { createConversationProcessIdentity } from "./localExecution.js";
11
11
  import { isRecord, normalizePositiveInteger } from "@jskit-ai/kernel/shared/support/normalize";
12
12
  import { normalizeNullableText } from "./normalize.js";
13
13
  import { copyCodexProviderHome, prepareCodexModelCatalog } from "./codexConfiguration.js";
14
+ import { codexDisabledProcessFeatureArguments } from "./codexHomeStorage.js";
14
15
  import { startCodexHistoryAdapter } from "./codexHistoryAdapter.js";
15
16
 
16
17
  const CODEX_APP_SERVER_METADATA_FILE = "runtime.json";
@@ -29,14 +30,16 @@ export async function runCodexAppServerProcess({
29
30
  const abortStartup = () => startup.abort();
30
31
  process.once("SIGTERM", abortStartup);
31
32
  process.once("SIGINT", abortStartup);
33
+ const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
32
34
  const catalogPath = await prepareCodexModelCatalog({
33
35
  command,
34
36
  runtimeDir,
35
37
  bundled,
36
38
  additionalModels,
39
+ codexHome,
37
40
  signal: startup.signal
38
41
  });
39
- const adapter = await startCodexHistoryAdapter({ token, codexHome: process.env.CODEX_HOME || path.join(os.homedir(), ".codex") });
42
+ const adapter = await startCodexHistoryAdapter({ token, codexHome });
40
43
  await writeFile(`${descriptor}.tmp`, JSON.stringify({ baseUrl: adapter.baseUrl, runtimeToken: token }), { mode: 0o600 });
41
44
  await rename(`${descriptor}.tmp`, descriptor);
42
45
  const child = spawn(command, [...args, "-c", `model_catalog_json=${JSON.stringify(catalogPath)}`], { stdio: "inherit" });
@@ -407,7 +410,7 @@ function codexAppServerRuntimeDirIsManaged(runtimeDir = "") {
407
410
  basename.startsWith(`${CODEX_APP_SERVER_RUNTIME_DIR_NAME}-`);
408
411
  }
409
412
 
410
- async function removeCodexAppServerRuntimeDir(runtimeDir = "") {
413
+ export async function removeCodexAppServerRuntimeDir(runtimeDir = "") {
411
414
  const normalizedRuntimeDir = normalizeNullableText(runtimeDir);
412
415
  if (!codexAppServerRuntimeDirIsManaged(normalizedRuntimeDir)) {
413
416
  return false;
@@ -1231,6 +1234,8 @@ export function createCodexAppServerRuntime({ execution, credentials, configurat
1231
1234
  ]),
1232
1235
  "-c",
1233
1236
  "check_for_update_on_startup=false",
1237
+ // Plugin and app startup syncs fill the home with unused catalogues.
1238
+ ...codexDisabledProcessFeatureArguments(),
1234
1239
  ...(projectTrustOverride
1235
1240
  ? [
1236
1241
  "-c",
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { lstat, realpath } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { CodexAppServerJsonRpcClient } from "./codexClient.js";
5
+ import { releaseSupersededCodexRuntime } from "./codexProviderOwner.js";
5
6
  import {
6
7
  createCodexAccountReader, codexAuthOutputRequiresReconnect, codexAppServerProjectHookTrustConfig,
7
8
  codexAppServerTextHasControlCharacters, CODEX_APP_SERVER_ACCOUNT_ID_MAX_LENGTH
@@ -43,8 +44,6 @@ const CODEX_APP_SERVER_THREAD_INVENTORY_PAGE_LIMIT = 100;
43
44
  const CODEX_APP_SERVER_THREAD_INVENTORY_MAX_PAGES = 100;
44
45
  export const CODEX_APP_SERVER_THREAD_INVENTORY_MAX_COUNT = 1000;
45
46
  export const CODEX_APP_SERVER_THREAD_INVENTORY_ID_MAX_LENGTH = 512;
46
- const CODEX_APP_SERVER_THREAD_INVENTORY_MAX_ENTRY_BYTES = 64 * 1024;
47
- const CODEX_APP_SERVER_THREAD_INVENTORY_MAX_TOTAL_BYTES = 2 * 1024 * 1024;
48
47
  const CODEX_APP_SERVER_HELPER_MAX_MESSAGE_BYTES = 4 * 1024 * 1024;
49
48
  const CODEX_APP_SERVER_SERVER_INFO_USER_AGENT_MAX_LENGTH = 512;
50
49
  const CODEX_APP_SERVER_CHATGPT_REFRESH_METHOD = "account/chatgptAuthTokens/refresh";
@@ -337,8 +336,12 @@ async function ensureCodexAppServerThread({
337
336
  throw new Error("Codex app-server did not return a thread id.");
338
337
  }
339
338
  stageStartedAt = Date.now();
340
- await identity.write({ appServerRuntime, threadId, workdir: normalizedWorkdir,
339
+ const recorded = await identity.write({ appServerRuntime, threadId, workdir: normalizedWorkdir,
341
340
  ...(toolConfiguration ? { toolSchemaIdentity: toolConfiguration.toolSchemaIdentity } : {}) });
341
+ // The session no longer uses the runtime directory it recorded before (an
342
+ // earlier sign-in's). Releasing it is cleanup: a failure keeps the directory.
343
+ const superseded = normalizeAgentText(recorded?.supersededRuntimeDir);
344
+ if (superseded) await releaseSupersededCodexRuntime(superseded).catch(() => null);
342
345
  if (providerReady) await providerReady({ provider, threadId });
343
346
  onStage({ durationMs: Date.now() - stageStartedAt, stage: "identity-metadata" });
344
347
  return {
@@ -570,6 +573,7 @@ async function listBoundedCodexAppServerThreadIds({
570
573
  client,
571
574
  cwd = "",
572
575
  errorCode = "",
576
+ findThreadId = "",
573
577
  label = "",
574
578
  requestLabel = "",
575
579
  runRequest,
@@ -603,43 +607,35 @@ async function listBoundedCodexAppServerThreadIds({
603
607
  }, { signal }),
604
608
  requestLabel
605
609
  );
606
- if (
607
- !Array.isArray(response?.data) ||
608
- response.data.length > CODEX_APP_SERVER_THREAD_INVENTORY_PAGE_LIMIT ||
609
- state.entryCount + response.data.length > CODEX_APP_SERVER_THREAD_INVENTORY_MAX_COUNT
610
- ) {
611
- throw codexAppServerThreadInventoryError(
612
- errorCode,
613
- label,
614
- "exceeded its entry limit"
615
- );
610
+ if (!Array.isArray(response?.data) || response.data.length > CODEX_APP_SERVER_THREAD_INVENTORY_PAGE_LIMIT) {
611
+ throw codexAppServerThreadInventoryError(errorCode, label, "returned a malformed page");
612
+ }
613
+ // Only thread ids are retained, so only they are bounded. A search for one
614
+ // thread retains none and is bounded by the page limit alone.
615
+ if (!findThreadId && state.entryCount + response.data.length > CODEX_APP_SERVER_THREAD_INVENTORY_MAX_COUNT) {
616
+ throw codexAppServerThreadInventoryError(errorCode, label,
617
+ `exceeded its ${CODEX_APP_SERVER_THREAD_INVENTORY_MAX_COUNT}-thread limit`);
616
618
  }
617
619
  for (const entry of response.data) {
618
- const threadId = normalizeAgentText(entry?.id);
619
- const threadCwd = normalizeAgentText(entry?.cwd);
620
- let entryBytes = 0;
621
- try {
622
- entryBytes = Buffer.byteLength(JSON.stringify(entry), "utf8");
623
- } catch {
624
- entryBytes = CODEX_APP_SERVER_THREAD_INVENTORY_MAX_ENTRY_BYTES + 1;
620
+ const threadId = isRecord(entry) ? normalizeAgentText(entry.id) : "";
621
+ const refusal = !isRecord(entry) ? "listed an entry that is not a thread"
622
+ : !threadId ? "listed a thread without an id"
623
+ : threadId.length > CODEX_APP_SERVER_THREAD_INVENTORY_ID_MAX_LENGTH
624
+ ? `listed a thread id longer than ${CODEX_APP_SERVER_THREAD_INVENTORY_ID_MAX_LENGTH} characters`
625
+ : codexAppServerTextHasControlCharacters(threadId) ? "listed a thread id with control characters"
626
+ : verifyCwd && normalizeAgentText(entry.cwd) !== cwd
627
+ ? `listed thread ${threadId} outside the requested directory` : "";
628
+ if (refusal) {
629
+ throw codexAppServerThreadInventoryError(errorCode, label, refusal);
625
630
  }
626
- if (
627
- !isRecord(entry) ||
628
- !threadId ||
629
- threadId.length > CODEX_APP_SERVER_THREAD_INVENTORY_ID_MAX_LENGTH ||
630
- codexAppServerTextHasControlCharacters(threadId) ||
631
- (verifyCwd && threadCwd !== cwd) ||
632
- entryBytes > CODEX_APP_SERVER_THREAD_INVENTORY_MAX_ENTRY_BYTES ||
633
- state.totalBytes + entryBytes > CODEX_APP_SERVER_THREAD_INVENTORY_MAX_TOTAL_BYTES
634
- ) {
635
- throw codexAppServerThreadInventoryError(
636
- errorCode,
637
- label,
638
- "contained an invalid entry"
639
- );
631
+ if (findThreadId) {
632
+ if (threadId === findThreadId) {
633
+ state.found = true;
634
+ return state;
635
+ }
636
+ continue;
640
637
  }
641
638
  state.entryCount += 1;
642
- state.totalBytes += entryBytes;
643
639
  state.threadIds.add(threadId);
644
640
  }
645
641
  const nextCursor = response.nextCursor === undefined || response.nextCursor === null || response.nextCursor === ""
@@ -734,6 +730,7 @@ export class CodexAppServerAgentProvider {
734
730
  this.planUsagePending = null;
735
731
  this.planUsageAuthGeneration = 0;
736
732
  this.availabilityPromise = null;
733
+ this.stale = false;
737
734
  this.options = options;
738
735
  this.client = null;
739
736
  this.connectPromise = null;
@@ -955,7 +952,22 @@ export class CodexAppServerAgentProvider {
955
952
  }
956
953
  }
957
954
 
955
+ // An account change leaves a runtime that is still working on the
956
+ // credentials it started with. Codex reads them once, so this provider
957
+ // finishes its running turn on them and then stops; it never re-checks,
958
+ // replaces or restarts its runtime, and never marks the new account invalid.
959
+ markStale() {
960
+ this.stale = true;
961
+ }
962
+
963
+ staleRuntimeError() {
964
+ return Object.assign(new Error("Codex (OpenAI) changed account. Send your message again."), {
965
+ code: `${this.errorPrefix}codex_account_changed`, retryable: true
966
+ });
967
+ }
968
+
958
969
  async prepareRuntime() {
970
+ if (this.stale) throw this.staleRuntimeError();
959
971
  const previousRuntime = this.runtime;
960
972
  const nextRuntime = await this.host.runtime.ensure(this.host.parameters);
961
973
  if (this.options.modelProviderId) nextRuntime.modelProviderId = this.options.modelProviderId;
@@ -997,7 +1009,7 @@ export class CodexAppServerAgentProvider {
997
1009
  }
998
1010
 
999
1011
  async assertRuntimeAuthReady(reason = "codex-app-server") {
1000
- if (this.isHelperProvider()) {
1012
+ if (this.isHelperProvider() || this.stale) {
1001
1013
  return;
1002
1014
  }
1003
1015
  const runtime = this.runtime || {};
@@ -1047,7 +1059,7 @@ export class CodexAppServerAgentProvider {
1047
1059
  error?.observed || "",
1048
1060
  await tailTextFile(this.runtime?.logPath || "")
1049
1061
  ].filter(Boolean).join("\n");
1050
- if (codexAuthOutputRequiresReconnect(observed)) {
1062
+ if (!this.stale && codexAuthOutputRequiresReconnect(observed)) {
1051
1063
  const runtime = this.runtime || {};
1052
1064
  if (runtime.runtimeDir) {
1053
1065
  await this.stopRuntimeAndRequireDrain(reason);
@@ -1355,6 +1367,7 @@ export class CodexAppServerAgentProvider {
1355
1367
 
1356
1368
  async ensureAvailable({ modelProviderId = "" } = {}) {
1357
1369
  if (this.observationFailure) throw this.observationFailure;
1370
+ if (this.stale && !this.isAvailable()) throw this.staleRuntimeError();
1358
1371
  if (this.isAvailable()) {
1359
1372
  return {
1360
1373
  client: this.client,
@@ -1843,8 +1856,7 @@ export class CodexAppServerAgentProvider {
1843
1856
  signal,
1844
1857
  state: {
1845
1858
  entryCount: 0,
1846
- threadIds: new Set(),
1847
- totalBytes: 0
1859
+ threadIds: new Set()
1848
1860
  },
1849
1861
  verifyCwd: true
1850
1862
  });
@@ -1863,7 +1875,7 @@ export class CodexAppServerAgentProvider {
1863
1875
  throw new TypeError("Codex descendant inventory requires an exact thread id.");
1864
1876
  }
1865
1877
  const client = await this.activeClient();
1866
- const state = { entryCount: 0, threadIds: new Set(), totalBytes: 0 };
1878
+ const state = { entryCount: 0, threadIds: new Set() };
1867
1879
  for (const archived of [false, true]) {
1868
1880
  await listBoundedCodexAppServerThreadIds({
1869
1881
  ancestorThreadId, archived, client, state,
@@ -1878,7 +1890,7 @@ export class CodexAppServerAgentProvider {
1878
1890
  async listNativeThreadsForCwd(cwd) {
1879
1891
  if (!path.isAbsolute(cwd || "") || codexAppServerTextHasControlCharacters(cwd)) throw new TypeError("Native inventory requires an exact absolute directory.");
1880
1892
  const client = await this.activeClient();
1881
- const state = { entryCount: 0, threadIds: new Set(), totalBytes: 0 };
1893
+ const state = { entryCount: 0, threadIds: new Set() };
1882
1894
  for (const archived of [false, true]) {
1883
1895
  await listBoundedCodexAppServerThreadIds({ archived, client, cwd, state, verifyCwd: true,
1884
1896
  sourceKinds: CODEX_NATIVE_STORAGE_SOURCE_KINDS,
@@ -1894,13 +1906,13 @@ export class CodexAppServerAgentProvider {
1894
1906
  throw new TypeError("Native existence checks require an exact thread id.");
1895
1907
  }
1896
1908
  const client = await this.activeClient();
1897
- const state = { entryCount: 0, threadIds: new Set(), totalBytes: 0 };
1909
+ const state = { entryCount: 0, found: false, threadIds: new Set() };
1898
1910
  for (const archived of [false, true]) {
1899
- await listBoundedCodexAppServerThreadIds({ archived, client, state, signal,
1911
+ await listBoundedCodexAppServerThreadIds({ archived, client, findThreadId: id, state, signal,
1900
1912
  sourceKinds: CODEX_NATIVE_STORAGE_SOURCE_KINDS,
1901
1913
  errorCode: `${this.errorPrefix}codex_retirement_inventory_invalid`, label: "native storage",
1902
1914
  requestLabel: "codex-app-server-native-existence", runRequest: this.runRequest.bind(this) });
1903
- if (state.threadIds.has(id)) return true;
1915
+ if (state.found) return true;
1904
1916
  }
1905
1917
  return false;
1906
1918
  }
@@ -1912,8 +1924,7 @@ export class CodexAppServerAgentProvider {
1912
1924
  const execution = await this.currentHelperExecutionContext();
1913
1925
  const state = {
1914
1926
  entryCount: 0,
1915
- threadIds: new Set(),
1916
- totalBytes: 0
1927
+ threadIds: new Set()
1917
1928
  };
1918
1929
  for (const archived of [false, true]) {
1919
1930
  await listBoundedCodexAppServerThreadIds({