@jskit-ai/assistant-core 0.1.240 → 0.1.242

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 (101) 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 +37 -23
  11. package/src/server/conversation/claudeProcess.js +2 -2
  12. package/src/server/conversation/claudeTurn.js +41 -38
  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/codexHelperExecution.js +3 -13
  21. package/src/server/conversation/codexHelperThreadLedger.js +7 -13
  22. package/src/server/conversation/codexHelperThreadRecovery.js +2 -16
  23. package/src/server/conversation/codexHelperThreads.js +5 -6
  24. package/src/server/conversation/codexHistoryAdapter.js +32 -26
  25. package/src/server/conversation/codexMessageCommands.js +22 -22
  26. package/src/server/conversation/codexMessageDelivery.js +12 -7
  27. package/src/server/conversation/codexModelCatalog.js +97 -0
  28. package/src/server/conversation/codexNotificationObserver.js +24 -40
  29. package/src/server/conversation/codexNotificationQueue.js +2 -3
  30. package/src/server/conversation/codexOutput.js +20 -27
  31. package/src/server/conversation/codexProcess.js +123 -127
  32. package/src/server/conversation/codexProcessLeader.js +3 -3
  33. package/src/server/conversation/codexProvider.js +31 -614
  34. package/src/server/conversation/codexProviderOwner.js +2 -2
  35. package/src/server/conversation/codexProviderSelection.js +38 -40
  36. package/src/server/conversation/codexRenewalCommands.js +13 -10
  37. package/src/server/conversation/codexRenewalThreads.js +412 -0
  38. package/src/server/conversation/codexRunJournal.js +24 -42
  39. package/src/server/conversation/codexRuntimeLifecycle.js +19 -15
  40. package/src/server/conversation/codexSessionCleanup.js +11 -10
  41. package/src/server/conversation/codexSessionReconciliation.js +6 -6
  42. package/src/server/conversation/codexThreadReadiness.js +19 -33
  43. package/src/server/conversation/codexThreadStatus.js +3 -3
  44. package/src/server/conversation/codexTurn.js +124 -297
  45. package/src/server/conversation/codexTurnRecovery.js +49 -28
  46. package/src/server/conversation/codexTurnState.js +34 -13
  47. package/src/server/conversation/commandWrapper.js +3 -0
  48. package/src/server/conversation/deliveryTiming.js +14 -5
  49. package/src/server/conversation/index.js +1 -0
  50. package/src/server/conversation/localExecution.js +13 -17
  51. package/src/server/conversation/normalize.js +8 -3
  52. package/src/server/conversation/openCodeClient.js +9 -10
  53. package/src/server/conversation/openCodeProcess.js +9 -5
  54. package/src/server/conversation/openCodeRuntime.js +10 -11
  55. package/src/server/conversation/openCodeTurn.js +43 -29
  56. package/src/server/conversation/providers/claudeDriver.js +8 -16
  57. package/src/server/conversation/providers/codexDriver.js +25 -41
  58. package/src/server/conversation/providers/opencodeDriver.js +17 -42
  59. package/src/server/conversation/providers/scoped.js +48 -4
  60. package/src/server/conversation/runtime.js +50 -49
  61. package/src/server/conversation/runtimeStateUpgrade.js +34 -22
  62. package/src/server/conversation/transcript.js +10 -10
  63. package/src/server/hosts/codexConfiguration.js +1 -0
  64. package/src/server/hosts/codexProvider.js +8 -6
  65. package/src/server/index.js +2 -1
  66. package/src/server/lib/assistantToolLoop.js +1 -153
  67. package/src/server/lib/serviceToolCatalog.js +17 -42
  68. package/src/shared/assistantResource.js +4 -2
  69. package/src/shared/conversation/attachments.js +4 -0
  70. package/src/shared/conversation/longTextBlocks.js +54 -19
  71. package/src/shared/conversation/numberedQuestionSugar.js +17 -10
  72. package/src/shared/conversation/pagination.js +4 -6
  73. package/src/shared/conversation/patches.js +1 -4
  74. package/src/shared/conversation/streaming.js +3 -11
  75. package/src/shared/conversation/turns.js +11 -0
  76. package/src/shared/index.js +1 -0
  77. package/test/assistantBoundedToolLoop.test.js +2 -2
  78. package/test/assistantResource.test.js +4 -4
  79. package/test/assistantSettingsResource.test.js +2 -2
  80. package/test/claudeConversationSession.test.js +11 -1
  81. package/test/claudeTurn.test.js +35 -7
  82. package/test/codexClient.test.js +20 -0
  83. package/test/codexConversationSession.test.js +2 -2
  84. package/test/codexConversationsCheckpoint.test.js +51 -0
  85. package/test/codexModelCatalog.test.js +1 -1
  86. package/test/codexProviderHome.test.js +51 -0
  87. package/test/codexProviderSelection.test.js +71 -0
  88. package/test/codexTurn.test.js +222 -6
  89. package/test/conversationAgentRun.test.js +2 -1
  90. package/test/conversationAttachments.test.js +27 -2
  91. package/test/conversationDeliveryTiming.test.js +21 -0
  92. package/test/conversationNormalize.test.js +11 -0
  93. package/test/fixtures/nestedListQuestionMessage.md +37 -0
  94. package/test/frameworkActions.test.js +2 -2
  95. package/test/longTextBlocks.test.js +102 -0
  96. package/test/messageDelivery.test.js +7 -1
  97. package/test/openCodeClient.test.js +20 -0
  98. package/test/openCodeRuntime.test.js +1 -1
  99. package/test/openCodeTurn.test.js +90 -0
  100. package/test/opencodeConversationSession.test.js +1 -1
  101. package/test/serviceToolCatalog.test.js +27 -0
@@ -5,18 +5,30 @@ import { readClaudeJsonFrames } from "./claudeClient.js";
5
5
  import { createNativeHistoryExport, retireNativeConversation } from "./nativeHistoryExport.js";
6
6
 
7
7
  const UUID_PATTERN = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/iu;
8
+ const TRANSCRIPT_PATH_PATTERN = /\.jsonl(?:\.(?:superseded|orphaned)-.*)?$/u;
8
9
 
9
10
  function requireClaudeSessionId(id) {
10
11
  if (!UUID_PATTERN.test(id)) throw new TypeError("Invalid Claude conversation id.");
11
12
  return id;
12
13
  }
13
14
 
15
+ // Claude's project directory name for a canonical working directory.
16
+ function claudeProjectDirectoryName(canonical) {
17
+ return canonical.normalize("NFC").replace(/[^a-zA-Z0-9]/gu, "-");
18
+ }
19
+
20
+ // Text the person authored in a user frame: plain content or its text blocks.
21
+ function claudeUserText(content) {
22
+ return typeof content === "string" ? content : (Array.isArray(content) ? content : [])
23
+ .filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n\n");
24
+ }
25
+
14
26
  async function archivedClaudeProjectDirectory(workdir) {
15
27
  const canonical = await realpath(workdir).catch((error) => {
16
28
  if (error.code === "ENOENT") return workdir;
17
29
  throw error;
18
30
  });
19
- return canonical.normalize("NFC").replace(/[^a-zA-Z0-9]/gu, "-");
31
+ return claudeProjectDirectoryName(canonical);
20
32
  }
21
33
 
22
34
  function matchingClaudeProjectDirectories(names, directory) {
@@ -37,7 +49,7 @@ export async function listClaudeConversationStorage({ configRoot, binding }) {
37
49
  const entries = await readdir(path.join(projects, name), { withFileTypes: true });
38
50
  if (entries.length > 10000) throw new Error("Claude conversation inventory exceeds its limit.");
39
51
  for (const entry of entries) {
40
- const id = entry.name.replace(/\.jsonl(?:\.(?:superseded|orphaned)-.*)?$/u, "");
52
+ const id = entry.name.replace(TRANSCRIPT_PATH_PATTERN, "");
41
53
  if (UUID_PATTERN.test(id) && entry.isFile()) ids.add(id);
42
54
  }
43
55
  }
@@ -49,14 +61,13 @@ export async function listClaudeConversationStorage({ configRoot, binding }) {
49
61
  // lookup because its native and Node builds use different hash suffixes.
50
62
  async function claudeHistoryPath({ configRoot, workdir, conversationId }) {
51
63
  requireClaudeSessionId(conversationId);
52
- const canonical = (await realpath(workdir)).normalize("NFC");
53
- const directory = canonical.replace(/[^a-zA-Z0-9]/gu, "-");
64
+ const directory = claudeProjectDirectoryName(await realpath(workdir));
54
65
  const projects = path.join(configRoot, "projects");
55
- const candidates = directory.length <= 200 ? [directory] :
56
- (await readdir(projects).catch((error) => {
66
+ const candidates = directory.length <= 200 ? [directory] : matchingClaudeProjectDirectories(
67
+ await readdir(projects).catch((error) => {
57
68
  if (error.code === "ENOENT") return [];
58
69
  throw error;
59
- })).filter((name) => name.startsWith(`${directory.slice(0, 200)}-`));
70
+ }), directory);
60
71
  for (const name of candidates) {
61
72
  const candidate = path.join(projects, name, `${conversationId}.jsonl`);
62
73
  const info = await stat(candidate).catch((error) => {
@@ -132,6 +143,19 @@ function claudeHistoryFrames(file) {
132
143
  return reading;
133
144
  }
134
145
 
146
+ // The offset just after the last newline between start and size.
147
+ async function completeHistoryOffset(handle, start, size) {
148
+ const block = Buffer.alloc(64 * 1024);
149
+ for (let end = size; end > start;) {
150
+ const from = Math.max(start, end - block.length);
151
+ await handle.read(block, 0, end - from, from);
152
+ const newline = block.subarray(0, end - from).lastIndexOf(10);
153
+ if (newline !== -1) return from + newline + 1;
154
+ end = from;
155
+ }
156
+ return start;
157
+ }
158
+
135
159
  async function readClaudeHistoryFrames(file) {
136
160
  const cached = historyCache.get(file.path)?.value;
137
161
  const handle = await open(file.path, "r");
@@ -143,18 +167,16 @@ async function readClaudeHistoryFrames(file) {
143
167
  reusable = check.equals(cached.check);
144
168
  }
145
169
  const start = reusable ? cached.offset : 0;
146
- const appended = Buffer.alloc(file.size - start);
147
- if (appended.length) await handle.read(appended, 0, appended.length, start);
148
170
  // Only newline-terminated frames are complete; a writer may still be
149
171
  // writing the unfinished tail, which the next read parses.
150
- const complete = appended.lastIndexOf(10) + 1;
172
+ const offset = await completeHistoryOffset(handle, start, file.size);
151
173
  const added = [];
152
- if (complete) {
153
- for await (const frame of readClaudeJsonFrames([appended.subarray(0, complete)])) {
174
+ if (offset > start) {
175
+ // Streamed, so a long conversation's history is never held whole in memory.
176
+ for await (const frame of readClaudeJsonFrames(handle.createReadStream({ start, end: offset - 1, autoClose: false }))) {
154
177
  if (!frame.isSidechain) added.push(retainedClaudeFrame(frame));
155
178
  }
156
179
  }
157
- const offset = start + complete;
158
180
  const check = Buffer.alloc(Math.min(HISTORY_CHECK_BYTES, offset));
159
181
  await handle.read(check, 0, check.length, offset - check.length);
160
182
  const value = { dev: file.dev, ino: file.ino, offset, check,
@@ -179,7 +201,6 @@ async function readClaudeHistory(options) {
179
201
  ...(options.includeFinalCarriers ? { completedResults: [], failedResults: [], structuredOutputs: [] } : {})
180
202
  };
181
203
  if (!file || file.size === 0) return { exists: false, messages: [], userIds: [], text: "", ...inspected };
182
- if (file.size > 256 * 1024 * 1024) throw new Error("Claude history exceeds the supported read limit.");
183
204
  const messages = new Map();
184
205
  const userIds = [];
185
206
  let lastUserId = "";
@@ -283,10 +304,7 @@ async function readClaudeHistory(options) {
283
304
  userIds.push(frame.uuid);
284
305
  lastUserId = frame.uuid;
285
306
  if (options.includeUserMessages && !frame.parent_tool_use_id) {
286
- const content = frame.message.content;
287
- const text = typeof content === "string" ? content : content
288
- .filter(block => block.type === "text" && typeof block.text === "string").map(block => block.text).join("\n\n");
289
- inspected.userMessages.push({ id: frame.uuid, text });
307
+ inspected.userMessages.push({ id: frame.uuid, text: claudeUserText(frame.message.content) });
290
308
  }
291
309
  }
292
310
  if (options.includeFinalCarriers && !frame.parent_tool_use_id && !frame.isMeta) {
@@ -333,8 +351,6 @@ export async function deleteClaudeConversationHistory({ configRoot, workdir, con
333
351
 
334
352
  export { claudeHistoryPath, claudeMessageBlocks, readClaudeHistory, requireClaudeSessionId, archivedClaudeProjectDirectory, matchingClaudeProjectDirectories };
335
353
 
336
- const TRANSCRIPT_PATH_PATTERN = /\.jsonl(?:\.(?:superseded|orphaned)-.*)?$/u;
337
-
338
354
  export async function retireClaudeConversationHistory({ configRoot, binding, beforeDelete, requireIdle, signal }) {
339
355
  const id = requireClaudeSessionId(binding.conversationId);
340
356
  if (!path.isAbsolute(configRoot)) throw new TypeError("Claude retirement requires an absolute configuration root.");
@@ -411,9 +427,7 @@ export async function retireClaudeConversationHistory({ configRoot, binding, bef
411
427
  .filter((message) => message.role !== "thinking")
412
428
  .map((message) => ({ ...provenance, role: "assistant", text: message.text }));
413
429
  if (frame.type === "user") {
414
- const content = frame.message?.content;
415
- const user = typeof content === "string" ? content : (Array.isArray(content) ? content : [])
416
- .filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n\n");
430
+ const user = claudeUserText(frame.message?.content);
417
431
  if (user) text.push({ ...provenance, role: "user", text: user });
418
432
  }
419
433
  if (frame.attachment?.type === "goal_status" && typeof frame.attachment.condition === "string") {
@@ -1,4 +1,4 @@
1
- import { createHash } from "node:crypto";
1
+ import { jsonHash } from "./normalize.js";
2
2
  import { nativeAiProvider } from "../../shared/nativeProviders.js";
3
3
  import { Duplex } from "node:stream";
4
4
  import { stat } from "node:fs/promises";
@@ -83,7 +83,7 @@ async function readNativeClaudeCodeAuthStatus({ env, credentialHome, command, co
83
83
 
84
84
  /** Exact existing native connection pin, shared with stopped metadata conversion. */
85
85
  export function claudeConnectionIdentity(configRoot, providerId, baseUrl, apiKey) {
86
- return createHash("sha256").update(JSON.stringify([configRoot, providerId, baseUrl, apiKey])).digest("hex");
86
+ return jsonHash([configRoot, providerId, baseUrl, apiKey]);
87
87
  }
88
88
 
89
89
  /** Native provider routing; the application supplies only its authorized selection. */
@@ -5,13 +5,14 @@ import { setTimeout as delay } from "node:timers/promises";
5
5
  import { claudeMessageBlocks, deleteClaudeConversationHistory, readClaudeHistory, requireClaudeSessionId, retireClaudeConversationHistory } from "./claudeHistory.js";
6
6
  import { bindClaudeConversationAccount, claudeCodeArguments, createClaudeAccountQueries, createClaudeCodeProcess, stopClaudeCodeProcess } from "./claudeProcess.js";
7
7
  import { claudeApplicationToolResponse } from "./claudeTools.js";
8
+ import { normalizeNullableText } from "./normalize.js";
8
9
  import { createClaudeConversationAdapter } from "./providers/claude.js";
9
10
 
10
11
  function failure(message, code = "assistant_claude_turn_failed") {
11
12
  return Object.assign(new Error(message), { code });
12
13
  }
13
14
 
14
- const text = value => String(value ?? "").trim();
15
+ const text = normalizeNullableText;
15
16
  const now = () => new Date().toISOString();
16
17
 
17
18
  function hasPendingClaudeStartup(entry) {
@@ -166,6 +167,11 @@ export function createClaudeConversationOwner({
166
167
  let closing = false;
167
168
  const createProcess = process.create || createClaudeCodeProcess;
168
169
  const saveAccount = store.save;
170
+ // Two hosts share this owner. A bound store (one with releaseExecution) is a
171
+ // runtime binding: the native turn owns activity and execution release.
172
+ // Otherwise the host retains entries and their original checkpoint order.
173
+ const bound = Boolean(store.releaseExecution);
174
+ const isTurnActive = entry => bound ? entry.nativeTurn.read().active : Boolean(entry.turn?.active);
169
175
 
170
176
  function createAccountQueries({ accountIdentity, createProcess }) {
171
177
  return createClaudeAccountQueries({ accountIdentity,
@@ -249,13 +255,13 @@ export function createClaudeConversationOwner({
249
255
  }
250
256
  }
251
257
  if (entry.pausedGoal && entry.blockedGoal) throw createError("The saved Claude goal cannot be both paused and blocked.");
252
- entry.nativeTurn = createClaudeConversationTurn({ conversationId: id, entry,
258
+ entry.nativeTurn = createClaudeConversationTurn({ conversationId: id, entry, bound,
253
259
  onEvent: event => onEvent(entry, event),
254
260
  process: {
255
261
  stopExecution: process.stopExecution,
256
- ...(store.releaseExecution ? { releaseExecution: () => store.releaseExecution(entry) } : {}),
262
+ ...(bound ? { releaseExecution: () => store.releaseExecution(entry) } : {}),
257
263
  getProcess: () => entry.stopping ? null : entry.process,
258
- isActive: () => store.releaseExecution ? entry.nativeTurn.read().active : Boolean(entry.turn?.active),
264
+ isActive: () => isTurnActive(entry),
259
265
  startProcess: input => startProcess(entry, input),
260
266
  stopProcess: reason => entry.process || entry.executionId
261
267
  ? entry.nativeTurn.stopProcess(reason, { preparing: true }) : Promise.resolve()
@@ -271,7 +277,7 @@ export function createClaudeConversationOwner({
271
277
  try { await store.save(entry); }
272
278
  catch (error) { Object.assign(entry, previous); throw error; }
273
279
  },
274
- readState: () => ({ running: Boolean(entry.process), active: store.releaseExecution ? entry.nativeTurn.read().active : Boolean(entry.turn?.active) }),
280
+ readState: () => ({ running: Boolean(entry.process), active: isTurnActive(entry) }),
275
281
  interrupt: () => interrupt(entry), send: input => send(entry, input) });
276
282
  entries.set(key, entry);
277
283
  await store.save(entry);
@@ -312,7 +318,7 @@ export function createClaudeConversationOwner({
312
318
  async function ensureReady(entry) {
313
319
  if (entry.process) {
314
320
  // Status reads retain active instructions; apply changes at idle admission.
315
- if (store.releaseExecution ? entry.nativeTurn.read().active : entry.turn?.active) await bindAccount(entry);
321
+ if (isTurnActive(entry)) await bindAccount(entry);
316
322
  else await ensureProcess(entry);
317
323
  } else {
318
324
  await bindAccount(entry);
@@ -441,10 +447,10 @@ export function createClaudeConversationOwner({
441
447
  }
442
448
  await entry.nativeTurn.receive(frame);
443
449
  },
444
- ...(store.releaseExecution || options.applicationTools || entry.command?.tools ? {
450
+ ...(bound || options.applicationTools || entry.command?.tools ? {
445
451
  async onControlRequest(request, { signal }) {
446
452
  const active = entry.command;
447
- const suppliedTools = !store.releaseExecution && active?.tools;
453
+ const suppliedTools = !bound && active?.tools;
448
454
  const owner = active?.nativeOwner;
449
455
  const work = applicationCall.run({ entry, command: active, owner,
450
456
  toolUseId: request.message?.params?._meta?.["claudecode/toolUseId"] },
@@ -471,7 +477,7 @@ export function createClaudeConversationOwner({
471
477
  } : {}),
472
478
  onFailure: async error => {
473
479
  if (entry.localCommand?.processToken === processToken) entry.localCommand.reject(error);
474
- if (store.releaseExecution) {
480
+ if (bound) {
475
481
  entry.nativeTurn.invalidateInstructions();
476
482
  try { await entry.nativeTurn.stopProcess(error.message); }
477
483
  catch (cleanupError) { error = cleanupError; await context.reportFailure(cleanupError); }
@@ -492,7 +498,7 @@ export function createClaudeConversationOwner({
492
498
  await onEvent(entry, { type: "execution", context, executionId: entry.executionId });
493
499
  } catch (bindingError) { error.bindingError = bindingError; }
494
500
  }
495
- if (store.releaseExecution && error.stopProof?.scopeEmpty) {
501
+ if (bound && error.stopProof?.scopeEmpty) {
496
502
  await store.releaseExecution(entry);
497
503
  if (entry.startupCleanup?.executionId === error.executionId) entry.startupCleanup = null;
498
504
  }
@@ -960,7 +966,7 @@ export function createClaudeConversationOwner({
960
966
  }
961
967
 
962
968
  async function cancel(control, { context, acquire, reportFailure, entry }) {
963
- if (store.releaseExecution) {
969
+ if (bound) {
964
970
  await entry.nativeTurn.stopProcess("Work stopped.");
965
971
  return;
966
972
  }
@@ -981,7 +987,7 @@ export function createClaudeConversationOwner({
981
987
  }
982
988
 
983
989
  async function dispatch(control, command, { acquire, entry: selectedEntry, dispatch: nativeDispatch }) {
984
- if (store.releaseExecution) {
990
+ if (bound) {
985
991
  await send(selectedEntry, command.input, { command: nativeDispatch });
986
992
  return;
987
993
  }
@@ -1009,14 +1015,13 @@ export function createClaudeConversationOwner({
1009
1015
  }
1010
1016
 
1011
1017
  function steer(control, command, options) {
1012
- if (!store.releaseExecution) return dispatch(control, command, options).then(delivered => ({ value: delivered.value }));
1018
+ if (!bound) return dispatch(control, command, options).then(delivered => ({ value: delivered.value }));
1013
1019
  if (!control.current?.steer || !control.current.entry.nativeTurn.read().active) throw new Error("Claude has no active turn to steer.");
1014
1020
  return control.current.steer(command);
1015
1021
  }
1016
1022
 
1017
1023
  async function run(control, command, { acquire, reportFailure, entry: selectedEntry, maximumOutput, maximumToolCalls = 32, timeoutMs } = {}) {
1018
1024
  if (control.disposed || control.current) throw new Error("This Claude conversation is closed or already working.");
1019
- const binding = Boolean(store.releaseExecution);
1020
1025
  const { configuration, context, input, tools, signal, beforeDispatch, accept, onMessage, onEvent, onNativeToolUse } = command;
1021
1026
  const completion = Promise.withResolvers();
1022
1027
  completion.promise.catch(() => {});
@@ -1047,7 +1052,7 @@ export function createClaudeConversationOwner({
1047
1052
  let delivered;
1048
1053
  function abort() {
1049
1054
  if (cancellation) return;
1050
- if (binding) {
1055
+ if (bound) {
1051
1056
  cancellation = entry.nativeTurn.cancel(entry.process?.client, () => entry.nativeTurn.stopProcess("Work stopped."));
1052
1057
  cancellation.then(() => completion.reject(signal.reason), error => completion.reject(error));
1053
1058
  } else {
@@ -1056,7 +1061,7 @@ export function createClaudeConversationOwner({
1056
1061
  }
1057
1062
  }
1058
1063
  try {
1059
- if (binding) {
1064
+ if (bound) {
1060
1065
  entry = selectedEntry;
1061
1066
  if (entry.disposed || entry.command) throw new Error("This Claude conversation is closed or already working.");
1062
1067
  active.entry = entry;
@@ -1070,7 +1075,7 @@ export function createClaudeConversationOwner({
1070
1075
  let dispatchFailure;
1071
1076
  try {
1072
1077
  delivered = await dispatch(control, command, { acquire, entry, dispatch: nativeDispatch });
1073
- if (binding) ready.resolve();
1078
+ if (bound) ready.resolve();
1074
1079
  } catch (error) { dispatchFailure = error; }
1075
1080
  for (;;) {
1076
1081
  const settling = active.delivery;
@@ -1086,15 +1091,15 @@ export function createClaudeConversationOwner({
1086
1091
  catch (error) { problem = error; }
1087
1092
  }
1088
1093
  problem = dispatchFailure || failure || problem;
1089
- if (!problem && settling?.completion && (binding ? result.status !== "completed" : !result.ok)) {
1090
- problem = Object.assign(new Error(result.error || (binding ? "Claude interrupted this answer." : `Claude turn ${result.status}.`)), { status: result.status });
1094
+ if (!problem && settling?.completion && (bound ? result.status !== "completed" : !result.ok)) {
1095
+ problem = Object.assign(new Error(result.error || (bound ? "Claude interrupted this answer." : `Claude turn ${result.status}.`)), { status: result.status });
1091
1096
  }
1092
1097
  break;
1093
1098
  }
1094
1099
  } catch (error) { problem = error; }
1095
1100
  ready.reject(problem || new Error("Claude work ended."));
1096
1101
  signal.removeEventListener("abort", abort);
1097
- if (binding) {
1102
+ if (bound) {
1098
1103
  try {
1099
1104
  if (problem || signal.aborted) await (cancellation || entry.nativeTurn.stopProcess(problem?.message || "Work stopped."));
1100
1105
  } catch (error) {
@@ -1126,10 +1131,10 @@ export function createClaudeConversationOwner({
1126
1131
  }
1127
1132
  if (control.current === active) control.current = null;
1128
1133
  if (problem) {
1129
- if (binding && nativeDispatch.attempted && !nativeDispatch.admitted && problem.delivery !== "not-sent") problem.delivery = "uncertain";
1134
+ if (bound && nativeDispatch.attempted && !nativeDispatch.admitted && problem.delivery !== "not-sent") problem.delivery = "uncertain";
1130
1135
  throw problem;
1131
1136
  }
1132
- if (!binding) return { value: delivered.value };
1137
+ if (!bound) return { value: delivered.value };
1133
1138
  }
1134
1139
 
1135
1140
  return Object.freeze({ applicationToolsSupported: true, entries, closingSessions, get closing() { return closing; }, createAccountQueries, open, acquire, createConversation, snapshot, readRetainedTurn, readFinalAssistantResult, readHistory, ensureReady, ensureProcess, readContextUsage, compactContext, releaseRetainedGoal, stopForTerminal, prepareTerminal, send, startTurn,
@@ -1138,7 +1143,7 @@ export function createClaudeConversationOwner({
1138
1143
  }
1139
1144
 
1140
1145
  /** Native turn handling moved from the production session provider; hosts persist its events. */
1141
- export function createClaudeConversationTurn({ conversationId, onEvent = async () => {}, process, entry } = {}) {
1146
+ export function createClaudeConversationTurn({ conversationId, onEvent = async () => {}, process, entry, bound = false } = {}) {
1142
1147
  if (!conversationId) throw new TypeError("A Claude conversation id is required.");
1143
1148
  const instructions = process ? createClaudeConversationAdapter(process) : null;
1144
1149
  const publish = entry ? publishEntryEvent : onEvent;
@@ -1249,31 +1254,30 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1249
1254
  invalidateFinalAssistantResult();
1250
1255
  // Runtime bindings commit execution release before dropping the handle.
1251
1256
  // Retained host entries keep their original post-proof checkpoint order.
1252
- const binding = Boolean(process.releaseExecution);
1253
1257
  if (entry.stopPending) return entry.stopPending;
1254
1258
  const active = read().active;
1255
- if (!binding) entry.stopping = true;
1259
+ if (!bound) entry.stopping = true;
1256
1260
  const operation = async () => {
1257
1261
  const startup = entry.startupCleanup?.executionId === entry.executionId ? entry.startupCleanup : null;
1258
1262
  let proof;
1259
1263
  try {
1260
- if (!binding || entry.process || entry.executionId) {
1264
+ if (!bound || entry.process || entry.executionId) {
1261
1265
  proof = await stopClaudeCodeProcess({ process: entry.process || startup, executionId: entry.executionId,
1262
1266
  stopExecution: process.stopExecution });
1263
1267
  }
1264
1268
  } catch (error) {
1265
- if (binding || error.code !== "claude_stop_unconfirmed") throw error;
1269
+ if (bound || error.code !== "claude_stop_unconfirmed") throw error;
1266
1270
  entry.observationError = reason || "Claude process exit has not been confirmed. Try Stop again.";
1267
1271
  await onEvent({ type: "save" });
1268
1272
  await onEvent({ type: "stop-unconfirmed" });
1269
1273
  throw error;
1270
1274
  }
1271
- if (binding && (entry.process || entry.executionId)) await process.releaseExecution();
1275
+ if (bound && (entry.process || entry.executionId)) await process.releaseExecution();
1272
1276
  entry.process = null;
1273
1277
  entry.executionId = "";
1274
1278
  entry.exitProof = proof;
1275
- stop(binding ? reason : reason || "Claude stopped before acknowledging the prompt.");
1276
- if (binding) {
1279
+ stop(bound ? reason : reason || "Claude stopped before acknowledging the prompt.");
1280
+ if (bound) {
1277
1281
  if (entry.startupCleanup === startup) entry.startupCleanup = null;
1278
1282
  if (active) entry.completion?.resolve({ status: "interrupted", error: reason || "Work stopped." });
1279
1283
  return;
@@ -1295,7 +1299,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1295
1299
  if (entry.startupCleanup === startup) entry.startupCleanup = null;
1296
1300
  return entry.exitProof;
1297
1301
  };
1298
- entry.stopPending = operation().catch(error => { if (binding) error.cleanupFailed = true; throw error; })
1302
+ entry.stopPending = operation().catch(error => { if (bound) error.cleanupFailed = true; throw error; })
1299
1303
  .finally(() => { entry.stopPending = null; });
1300
1304
  return entry.stopPending;
1301
1305
  }
@@ -1303,9 +1307,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1303
1307
  async function publishEntryEvent(event, deferred = false) {
1304
1308
  if (entry.stopping) return;
1305
1309
  if (event.type === "settled" && event.generation !== generation) return;
1306
- const binding = Boolean(process.releaseExecution);
1307
1310
  const command = entry.command;
1308
- if (event.type === "settled" && !binding && !deferred && command?.finishIfCurrent && command !== checkpointedCommand) {
1311
+ if (event.type === "settled" && !bound && !deferred && command?.finishIfCurrent && command !== checkpointedCommand) {
1309
1312
  const delivery = command.delivery;
1310
1313
  const completion = entry.completion;
1311
1314
  const owner = acceptedInput;
@@ -1336,17 +1339,17 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1336
1339
  }
1337
1340
  if (event.type === "continued") {
1338
1341
  if (entry.completion === resolvedCompletion) entry.completion = Promise.withResolvers();
1339
- if (binding) setState("active");
1342
+ if (bound) setState("active");
1340
1343
  else await updateState("active");
1341
1344
  } else if (event.type === "admitted") {
1342
1345
  if (event.userEcho) entry.sent = true;
1343
- if (!binding) await onEvent({ type: "save" });
1346
+ if (!bound) await onEvent({ type: "save" });
1344
1347
  } else if (event.type === "phase") {
1345
1348
  entry.turn.phase = event.phase;
1346
- if (!binding) await updateState(entry.turn.state);
1349
+ if (!bound) await updateState(entry.turn.state);
1347
1350
  } else if (event.type === "settled") {
1348
1351
  const state = event.status === "interrupted" ? "interrupted" : event.status === "failed" ? "failed" : "completed";
1349
- if (binding) setState(state, event.error);
1352
+ if (bound) setState(state, event.error);
1350
1353
  else if (!await updateState(state, event.error, event.generation)) return;
1351
1354
  }
1352
1355
  const published = await onEvent(event);
@@ -1358,7 +1361,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1358
1361
  if (event.type === "settled") {
1359
1362
  if (event.generation !== generation) return;
1360
1363
  if (event.source === "result") await onEvent({ type: "provider-update", change: "goal" });
1361
- resolveCompletion(binding ? event : readResult());
1364
+ resolveCompletion(bound ? event : readResult());
1362
1365
  }
1363
1366
  return published;
1364
1367
  }
@@ -0,0 +1,104 @@
1
+ import path from "node:path";
2
+ import { codexAuthOutputRequiresReconnect } from "./codexConfiguration.js";
3
+ import {
4
+ assertExistingDirectory, codexAppServerCommandBaseEnv, codexAppServerCredentialHome,
5
+ codexAppServerProcessCwd, ensureWritablePrivateDirectory
6
+ } from "./codexProcess.js";
7
+ import { normalizePositiveInteger } from "@jskit-ai/kernel/shared/support/normalize";
8
+ import { normalizeNullableText as normalizeAgentText } from "./normalize.js";
9
+
10
+ // Codex auth preflight: one bounded `codex debug models` run whose
11
+ // reconnect-required output marks the stored credentials invalid.
12
+ const CODEX_AUTH_PREFLIGHT_TIMEOUT_MS = 15000;
13
+ const CODEX_AUTH_PREFLIGHT_OUTPUT_TAIL_BYTES = 4096;
14
+
15
+ function tailAppend(text = "", chunk = "", maxBytes = CODEX_AUTH_PREFLIGHT_OUTPUT_TAIL_BYTES) {
16
+ const next = `${String(text || "")}${String(chunk || "")}`;
17
+ return next.length > maxBytes ? next.slice(-maxBytes) : next;
18
+ }
19
+
20
+ function codexAuthPreflightArgs() {
21
+ return [
22
+ "-c",
23
+ "check_for_update_on_startup=false",
24
+ "debug",
25
+ "models"
26
+ ];
27
+ }
28
+
29
+ async function runCodexAuthPreflight({
30
+ codexCommand = "codex",
31
+ env = process.env,
32
+ executionRoot = "",
33
+ runtimeDir = "",
34
+ terminalEnv = {},
35
+ timeoutMs = CODEX_AUTH_PREFLIGHT_TIMEOUT_MS,
36
+ toolHomeSource = "",
37
+ workdir = ""
38
+ } = {}, execution) {
39
+ const normalizedToolHomeSource = normalizeAgentText(toolHomeSource);
40
+ if (normalizedToolHomeSource) {
41
+ await assertExistingDirectory(normalizedToolHomeSource, "Codex credential home");
42
+ }
43
+ const normalizedRuntimeDir = normalizeAgentText(runtimeDir);
44
+ if (normalizedRuntimeDir) {
45
+ await ensureWritablePrivateDirectory(path.resolve(normalizedRuntimeDir));
46
+ }
47
+ const baseEnv = codexAppServerCommandBaseEnv({
48
+ env,
49
+ terminalEnv
50
+ });
51
+ const processCwd = codexAppServerProcessCwd({
52
+ executionRoot,
53
+ runtimeDir,
54
+ workdir
55
+ });
56
+ try {
57
+ const result = await execution.run({
58
+ args: codexAuthPreflightArgs(),
59
+ baseEnv,
60
+ command: codexCommand,
61
+ credentialHome: codexAppServerCredentialHome(normalizedToolHomeSource, baseEnv),
62
+ cwd: processCwd,
63
+ inheritProcessEnv: false,
64
+ mode: "capture",
65
+ timeout: normalizePositiveInteger(timeoutMs, { fallback: CODEX_AUTH_PREFLIGHT_TIMEOUT_MS })
66
+ });
67
+ return {
68
+ code: result.exitCode,
69
+ ok: result.ok === true,
70
+ output: tailAppend("", result.output || [
71
+ result.stderr,
72
+ result.stdout
73
+ ].filter(Boolean).join("\n")),
74
+ signal: result.signal,
75
+ timedOut: result.timedOut === true
76
+ };
77
+ } catch (error) {
78
+ return {
79
+ error,
80
+ ok: false,
81
+ output: normalizeAgentText(error?.message || error)
82
+ };
83
+ }
84
+ }
85
+
86
+ export async function assertCodexAuthPreflightReady(options = {}, {
87
+ reason = "codex-auth-preflight", execution, credentials
88
+ } = {}) {
89
+ const result = await runCodexAuthPreflight(options, execution);
90
+ if (result.ok && !codexAuthOutputRequiresReconnect(result.output)) {
91
+ return result;
92
+ }
93
+ const observed = normalizeAgentText(result.output || result.error?.message || "Codex auth preflight failed.");
94
+ if (codexAuthOutputRequiresReconnect(observed)) {
95
+ await credentials.markInvalid( {
96
+ reason
97
+ });
98
+ throw credentials.reconnectError({
99
+ modelProviderId: "",
100
+ observed
101
+ });
102
+ }
103
+ throw new Error(observed || "Codex auth preflight failed.");
104
+ }
@@ -1,10 +1,13 @@
1
1
  import { createConnection } from "node:net";
2
2
  import WebSocket from "ws";
3
- import { normalizeText as normalizeAgentText } from "./normalize.js";
3
+ import { normalizePositiveInteger } from "@jskit-ai/kernel/shared/support/normalize";
4
+ import { normalizeNullableText as normalizeAgentText } from "./normalize.js";
4
5
  const CODEX_APP_SERVER_REQUEST_TIMEOUT_MS = 60_000;
5
- function normalizePositiveInteger(value, fallback) {
6
- const number = Number(value);
7
- return Number.isSafeInteger(number) && number > 0 ? number : fallback;
6
+
7
+ function messageTooLargeError() {
8
+ const error = new Error("Codex app-server message exceeded the configured transport limit.");
9
+ error.code = "assistant_codex_app_server_message_too_large";
10
+ return error;
8
11
  }
9
12
 
10
13
  function socketPathFromCodexAppServerEndpoint(endpoint = "") {
@@ -56,10 +59,8 @@ class CodexAppServerJsonRpcClient {
56
59
  WebSocketImpl = WebSocket
57
60
  } = {}) {
58
61
  this.endpoint = normalizeAgentText(endpoint);
59
- this.maxMessageBytes = Number.isSafeInteger(maxMessageBytes) && maxMessageBytes > 0
60
- ? maxMessageBytes
61
- : Number.POSITIVE_INFINITY;
62
- this.requestTimeoutMs = normalizePositiveInteger(requestTimeoutMs, CODEX_APP_SERVER_REQUEST_TIMEOUT_MS);
62
+ this.maxMessageBytes = normalizePositiveInteger(maxMessageBytes, { fallback: Number.POSITIVE_INFINITY });
63
+ this.requestTimeoutMs = normalizePositiveInteger(requestTimeoutMs, { fallback: CODEX_APP_SERVER_REQUEST_TIMEOUT_MS });
63
64
  this.WebSocketImpl = WebSocketImpl;
64
65
  this.onDisconnect = onDisconnect;
65
66
  this.nextRequestId = 1;
@@ -233,11 +234,7 @@ class CodexAppServerJsonRpcClient {
233
234
  throw new Error("Codex app-server connection is not open.");
234
235
  }
235
236
  const serialized = JSON.stringify(payload);
236
- if (Buffer.byteLength(serialized, "utf8") > this.maxMessageBytes) {
237
- const error = new Error("Codex app-server message exceeded the configured transport limit.");
238
- error.code = "assistant_codex_app_server_message_too_large";
239
- throw error;
240
- }
237
+ if (Buffer.byteLength(serialized, "utf8") > this.maxMessageBytes) throw messageTooLargeError();
241
238
  this.socket.send(serialized);
242
239
  }
243
240
 
@@ -246,9 +243,7 @@ class CodexAppServerJsonRpcClient {
246
243
  try {
247
244
  const text = socketMessageText(event, this.maxMessageBytes);
248
245
  if (text === null) {
249
- const error = new Error("Codex app-server message exceeded the configured transport limit.");
250
- error.code = "assistant_codex_app_server_message_too_large";
251
- this.disconnect(error);
246
+ this.disconnect(messageTooLargeError());
252
247
  return;
253
248
  }
254
249
  message = JSON.parse(text);