@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
@@ -124,15 +124,26 @@ export function createCodexTurnRecovery({
124
124
  const delayMs = failureDetail
125
125
  ? Math.max(0, failureDetail.expiresAt - Date.now())
126
126
  : codexAppServerFinalizingRemainingMs({ completedAt, state: "finalizing", updatedAt });
127
- const timer = setTimeout(() => {
127
+ // One record per pending deadline: `recovery` is the in-flight expiry
128
+ // (retained-timer policy only), `expiresAt` the first deadline that a later
129
+ // failure-detail grace may not exceed, `failureDetail` a deferred provider
130
+ // failure, and `startedAt` the post-read clock that rereads must keep.
131
+ const entry = {
132
+ timer: null,
133
+ recovery: null,
134
+ expiresAt: failureDetailGraceMs > 0 ? Date.now() + delayMs : null,
135
+ failureDetail,
136
+ startedAt: !failureDetail && finalizingGraceAfterHistoryRead ? completedAt : ""
137
+ };
138
+ entry.timer = setTimeout(() => {
128
139
  if (failureDetail) {
129
140
  notificationQueue.run({ projectContext, provider: failureDetail.provider, sessionId: normalizedSessionId }, async () => {
130
- if (codexAppServerFinalizingTimers.get(key) !== timer) return;
141
+ if (codexAppServerFinalizingTimers.get(key) !== entry) return;
131
142
  try {
132
143
  return await stopCodexAppServerTurnWithProviderFailure(normalizedSessionId, normalizedThreadId, normalizedTurnId,
133
144
  { ...failureDetail, failureDetailReady: true });
134
145
  } finally {
135
- if (codexAppServerFinalizingTimers.get(key) === timer) {
146
+ if (codexAppServerFinalizingTimers.get(key) === entry) {
136
147
  clearCodexAppServerFinalizingTimer(normalizedSessionId, normalizedThreadId, normalizedTurnId);
137
148
  }
138
149
  }
@@ -151,11 +162,11 @@ export function createCodexTurnRecovery({
151
162
  )
152
163
  );
153
164
  if (retainFinalizingTimer) {
154
- timer.finalizingRecovery = recovery;
165
+ entry.recovery = recovery;
155
166
  void recovery.then(
156
- () => { if (timer.finalizingRecovery === recovery) delete timer.finalizingRecovery; },
167
+ () => { if (entry.recovery === recovery) entry.recovery = null; },
157
168
  error => {
158
- if (timer.finalizingRecovery === recovery) delete timer.finalizingRecovery;
169
+ if (entry.recovery === recovery) entry.recovery = null;
159
170
  debugLog("appServerFinalizingRecovery.error", {
160
171
  error: debugError(error), sessionId: normalizedSessionId,
161
172
  threadId: normalizedThreadId, turnId: normalizedTurnId
@@ -166,11 +177,8 @@ export function createCodexTurnRecovery({
166
177
  void recovery;
167
178
  }
168
179
  }, delayMs);
169
- if (failureDetailGraceMs > 0) timer.finalizingExpiresAt = Date.now() + delayMs;
170
- if (failureDetail) timer.failureDetail = failureDetail;
171
- else if (finalizingGraceAfterHistoryRead) timer.finalizingStartedAt = completedAt;
172
- timer.unref?.();
173
- codexAppServerFinalizingTimers.set(key, timer);
180
+ entry.timer.unref?.();
181
+ codexAppServerFinalizingTimers.set(key, entry);
174
182
  }
175
183
 
176
184
  async function reconcileCodexAppServerActiveTurn(session = {}, {
@@ -194,8 +202,8 @@ export function createCodexTurnRecovery({
194
202
  status: trackedTurn.status || "completed"
195
203
  }
196
204
  );
197
- const runtime = await createRuntime();
198
- return runtime.getSession(sessionId);
205
+ const activeRuntime = runtime || await createRuntime();
206
+ return activeRuntime.getSession(sessionId);
199
207
  }
200
208
  return session;
201
209
  }
@@ -313,12 +321,13 @@ export function createCodexTurnRecovery({
313
321
  scheduleCodexAppServerActiveRecovery(sessionId);
314
322
  return currentSession;
315
323
  }
316
- if (!promptDeliveryIsLocal && (!providerTurnId || !codexAppServerTurnStatusIsComplete(status))) {
317
- return failOrphanedCodexAppServerPromptDelivery(activeRuntime, currentSession, currentTurn);
318
- }
324
+ // A local prompt delivery owns the outcome of its own starting turn.
319
325
  if (promptDeliveryIsLocal) {
320
326
  return currentSession;
321
327
  }
328
+ if (!providerTurnId || !codexAppServerTurnStatusIsComplete(status)) {
329
+ return failOrphanedCodexAppServerPromptDelivery(activeRuntime, currentSession, currentTurn);
330
+ }
322
331
  await markCodexAppServerProviderTurnActive(sessionId, {
323
332
  source: "active_reconciliation",
324
333
  status: "inProgress",
@@ -603,7 +612,7 @@ export function createCodexTurnRecovery({
603
612
  const normalizedTurnId = normalizeCodexRunText(turnId);
604
613
  const key = codexAppServerResultFinalizationKey(normalizedSessionId, normalizedThreadId, normalizedTurnId);
605
614
  const priorTimer = retainFinalizingTimer ? codexAppServerFinalizingTimers.get(key) : null;
606
- if (!timerRecovery && priorTimer?.finalizingRecovery) return priorTimer.finalizingRecovery;
615
+ if (!timerRecovery && priorTimer?.recovery) return priorTimer.recovery;
607
616
  const runtime = await createRuntime();
608
617
  const session = await runtime.getSession(normalizedSessionId);
609
618
  const turn = turnState(session);
@@ -651,9 +660,9 @@ export function createCodexTurnRecovery({
651
660
  clearCodexAppServerFinalizingTimer(normalizedSessionId, normalizedThreadId, normalizedTurnId);
652
661
  return { ok: true, processed: false, reason: "goal_continuation_pending" };
653
662
  }
654
- if (!timerRecovery && timer?.finalizingRecovery) return timer.finalizingRecovery;
663
+ if (!timerRecovery && timer?.recovery) return timer.recovery;
655
664
  // This local clock never changes the native completion timestamp.
656
- graceTurn = { ...currentTurn, completedAt: timer?.finalizingStartedAt || readReturnedAt };
665
+ graceTurn = { ...currentTurn, completedAt: timer?.startedAt || readReturnedAt };
657
666
  }
658
667
  if (!codexAppServerFinalizingExpired(graceTurn)) {
659
668
  scheduleCodexAppServerFinalizingRecovery(normalizedSessionId, normalizedThreadId, normalizedTurnId, {
@@ -920,9 +929,11 @@ export function createCodexTurnRecovery({
920
929
  }
921
930
  }
922
931
  const runtime = await createRuntime();
923
- const session = await runtime.getSession(normalizedSessionId);
924
- const turn = turnState(session);
925
- if (!codexAppServerTurnCanReceiveProviderCompletion(turn, normalizedThreadId, normalizedTurnId)) {
932
+ const staleFailure = async () => {
933
+ const turn = turnState(await runtime.getSession(normalizedSessionId));
934
+ if (codexAppServerTurnCanReceiveProviderCompletion(turn, normalizedThreadId, normalizedTurnId)) {
935
+ return null;
936
+ }
926
937
  debugLog("appServerTurn.failure.stale", {
927
938
  currentState: turn.state,
928
939
  currentStatus: turn.status,
@@ -938,6 +949,10 @@ export function createCodexTurnRecovery({
938
949
  processed: false,
939
950
  reason: "stale_turn_state"
940
951
  };
952
+ };
953
+ const staleBeforeRecovery = await staleFailure();
954
+ if (staleBeforeRecovery) {
955
+ return staleBeforeRecovery;
941
956
  }
942
957
  const key = codexAppServerResultFinalizationKey(normalizedSessionId, normalizedThreadId, normalizedTurnId);
943
958
  const pendingTimer = codexAppServerFinalizingTimers.get(key);
@@ -949,7 +964,7 @@ export function createCodexTurnRecovery({
949
964
  failureDetail: { error, ok, outcome: resolvedOutcome, provider, status: normalizedStatus,
950
965
  usageLimitExceeded, verifyInactive,
951
966
  expiresAt: Math.min(observedAt + failureDetailGraceMs,
952
- pendingTimer?.finalizingExpiresAt ?? Infinity) }
967
+ pendingTimer?.expiresAt ?? Infinity) }
953
968
  });
954
969
  }
955
970
  return { ok: true, processed: false, reason: "failure_detail_pending", status: normalizedStatus };
@@ -972,8 +987,13 @@ export function createCodexTurnRecovery({
972
987
  return recovered;
973
988
  }
974
989
  }
975
- // History recovery awaited the provider. Confirm activity again before
976
- // publishing a failure, because the goal may have continued in that gap.
990
+ // History recovery awaited the provider. Confirm ownership and activity
991
+ // again before publishing a failure: a successor, Stop or goal
992
+ // continuation may own the run after that gap.
993
+ const staleAfterRecovery = await staleFailure();
994
+ if (staleAfterRecovery) {
995
+ return staleAfterRecovery;
996
+ }
977
997
  if (verifyInactive && await codexAppServerProviderBlocksTurnRelease(normalizedSessionId, provider, normalizedThreadId, normalizedTurnId, {
978
998
  source: "provider_failure"
979
999
  })) {
@@ -1018,9 +1038,6 @@ export function createCodexTurnRecovery({
1018
1038
  const normalizedThreadId = normalizeCodexRunText(threadId);
1019
1039
  const normalizedStatus = normalizeCodexRunText(status) || "completed";
1020
1040
  const normalizedTurnId = await resolveCodexAppServerTurnId(normalizedSessionId, normalizedThreadId, turnId);
1021
- const runtime = await createRuntime();
1022
- const currentSession = await runtime.getSession(normalizedSessionId);
1023
- const currentTurn = turnState(currentSession);
1024
1041
  const recovered = await recoverCodexAppServerFinalResponseBeforeOutcome(
1025
1042
  normalizedSessionId,
1026
1043
  normalizedThreadId,
@@ -1030,6 +1047,10 @@ export function createCodexTurnRecovery({
1030
1047
  if (recovered?.processed) {
1031
1048
  return recovered;
1032
1049
  }
1050
+ // The history read can outlive this turn: a successor or Stop may own the
1051
+ // run by now, so the outcome is decided against the run read after it.
1052
+ const runtime = await createRuntime();
1053
+ const currentTurn = turnState(await runtime.getSession(normalizedSessionId));
1033
1054
  if (!codexAppServerTurnCanReceiveProviderCompletion(currentTurn, normalizedThreadId, normalizedTurnId)) {
1034
1055
  codexAppServerFinalizedTurns.add(codexAppServerResultFinalizationKey(
1035
1056
  normalizedSessionId,
@@ -1,20 +1,21 @@
1
- import { normalizeText, isPlainObject } from "./normalize.js";
1
+ import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import { normalizeNullableText } from "./normalize.js";
2
3
  import { codexAppServerStatusFromValue, codexAppServerThreadRawValue } from "./codexEvents.js";
3
4
 
4
5
  function codexAppServerTurnStatusIsActive(status = "") {
5
- return normalizeText(status) === "inProgress";
6
+ return normalizeNullableText(status) === "inProgress";
6
7
  }
7
8
 
8
9
  function codexAppServerTurnStatusIsComplete(status = "") {
9
- return ["completed", "interrupted", "failed"].includes(normalizeText(status));
10
+ return ["completed", "interrupted", "failed"].includes(normalizeNullableText(status));
10
11
  }
11
12
 
12
13
  function codexAppServerTurnStatusIsSuccessfulComplete(status = "") {
13
- return normalizeText(status) === "completed";
14
+ return normalizeNullableText(status) === "completed";
14
15
  }
15
16
 
16
17
  function codexAppServerTurnStatusIsProviderFailure(status = "") {
17
- return ["failed", "interrupted"].includes(normalizeText(status));
18
+ return ["failed", "interrupted"].includes(normalizeNullableText(status));
18
19
  }
19
20
 
20
21
  function codexAppServerThreadStatus(thread = {}) {
@@ -26,7 +27,7 @@ function codexAppServerThreadStatus(thread = {}) {
26
27
  if (codexAppServerTurnStatusIsActive(liveStatus)) {
27
28
  return liveStatus;
28
29
  }
29
- if (isPlainObject(thread.observedTurn)) {
30
+ if (isRecord(thread.observedTurn)) {
30
31
  const observedStatus = codexAppServerStatusFromValue(thread.observedTurn.status);
31
32
  if (observedStatus) {
32
33
  return observedStatus;
@@ -38,7 +39,7 @@ function codexAppServerThreadStatus(thread = {}) {
38
39
  async function codexAppServerReadThreadStatus(provider = null, threadId = "", {
39
40
  observeLatestTurn = false
40
41
  } = {}) {
41
- const normalizedThreadId = normalizeText(threadId || "");
42
+ const normalizedThreadId = normalizeNullableText(threadId || "");
42
43
  if (!normalizedThreadId) {
43
44
  return null;
44
45
  }
@@ -54,7 +55,7 @@ async function codexAppServerReadThreadStatus(provider = null, threadId = "", {
54
55
  });
55
56
  const observedTurn = Array.isArray(response?.data) ? response.data[0] : null;
56
57
  if (provider.isControlProbeTurn?.(normalizedThreadId, observedTurn?.id)) return thread;
57
- return isPlainObject(observedTurn)
58
+ return isRecord(observedTurn)
58
59
  ? {
59
60
  ...thread,
60
61
  observedTurn
@@ -199,7 +200,9 @@ export function codexAppServerTurnState(session = {}, normalizeRunState = normal
199
200
  error: "",
200
201
  goalStatus: "",
201
202
  goalThreadId: "",
203
+ inputSource: "",
202
204
  outerTurnId: "",
205
+ phase: "",
203
206
  runId: "",
204
207
  runState: "",
205
208
  startedAt: "",
@@ -298,6 +301,12 @@ export function codexAppServerTurnKey(threadId = "", turnId = "") {
298
301
  return `${normalizeCodexRunText(threadId)}:${normalizeCodexRunText(turnId)}`;
299
302
  }
300
303
 
304
+ // Identity of a native outer turn. Terminal turns and persistent-conversation
305
+ // checkpoints must agree on it, so both build it here.
306
+ export function codexAppServerNativeOuterTurnId(threadId = "", turnId = "") {
307
+ return `codex:${threadId}:${turnId}`;
308
+ }
309
+
301
310
  export function codexAppServerAgentRunPatch({
302
311
  error = "",
303
312
  inputSource = "",
@@ -336,7 +345,7 @@ export function codexAppServerAgentRunPatch({
336
345
  // owner's id would checkpoint two different turns with one identity.
337
346
  const normalizedOuterTurnId = normalizeCodexRunText(outerTurnId) ||
338
347
  (normalizedInputSource === "terminal" && normalizedRunState === CODEX_APP_SERVER_RUN_STATE.ACTIVE && !sameActiveTurn
339
- ? `codex:${normalizeCodexRunText(threadId)}:${normalizeCodexRunText(turnId)}`
348
+ ? codexAppServerNativeOuterTurnId(normalizeCodexRunText(threadId), normalizeCodexRunText(turnId))
340
349
  : normalizeCodexRunText(currentRun?.outerTurnId));
341
350
  if (normalizedOuterTurnId) {
342
351
  patch.outerTurnId = normalizedOuterTurnId;
@@ -387,6 +396,18 @@ export function codexAppServerRunIdentityForPatch(session = {}, {
387
396
  };
388
397
  }
389
398
 
399
+ // A settling writer names the turn it settles. While another turn owns the
400
+ // active run, that write belongs to a predecessor and must not end the run.
401
+ export function codexAppServerRunPatchSettlesOtherActiveTurn(currentTurn = {}, patch = {}) {
402
+ const currentTurnId = normalizeCodexRunText(currentTurn.turnId);
403
+ const patchTurnId = normalizeCodexRunText(patch.providerTurnId);
404
+ return currentTurn.active === true &&
405
+ !codexAppServerRunStateIsActive(patch.state) &&
406
+ Boolean(currentTurnId) &&
407
+ Boolean(patchTurnId) &&
408
+ currentTurnId !== patchTurnId;
409
+ }
410
+
390
411
  export function codexAppServerRunPatchIsStaleAfterTerminalState(currentTurn = {}, patch = {}, normalizeRunState = normalizeCodexAppServerRunState) {
391
412
  const currentRunState = normalizeRunState(currentTurn.runState);
392
413
  const patchRunState = normalizeRunState(patch.state);
@@ -413,7 +434,7 @@ function codexAppServerErrorMessage(value, fallback = "Codex could not be prepar
413
434
  }
414
435
 
415
436
  export function codexAppServerSteerFailure(result = {}, steerFailedCode = "codex_turn_steer_failed") {
416
- if (!isPlainObject(result)) {
437
+ if (!isRecord(result)) {
417
438
  return null;
418
439
  }
419
440
  if (result.ok !== false) {
@@ -464,7 +485,7 @@ function codexAppServerMessageDeferred({
464
485
  }
465
486
 
466
487
  export function codexAppServerInterruptFailure(result = {}, interruptFailedCode = "codex_turn_interrupt_failed") {
467
- if (!isPlainObject(result)) {
488
+ if (!isRecord(result)) {
468
489
  return null;
469
490
  }
470
491
  if (result.ok !== false && result.interrupted !== false) {
@@ -522,14 +543,14 @@ export function codexAppServerMessageText(input = {}) {
522
543
  if (typeof input === "string") {
523
544
  return normalizeCodexRunText(input);
524
545
  }
525
- if (!isPlainObject(input)) {
546
+ if (!isRecord(input)) {
526
547
  return "";
527
548
  }
528
549
  return normalizeCodexRunText(input.message);
529
550
  }
530
551
 
531
552
  export function codexAppServerMessageDisplayText(input = {}, fallback = "") {
532
- if (!isPlainObject(input)) {
553
+ if (!isRecord(input)) {
533
554
  return normalizeCodexRunText(fallback || input);
534
555
  }
535
556
  return normalizeCodexRunText(input.displayMessage || input.message || fallback);
@@ -1,6 +1,9 @@
1
1
  import { isAbsolute } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
 
4
+ // Always single-quotes, unlike codexProvider's shellQuote, which leaves plain
5
+ // tokens bare. Wrapped hook commands keep their exact quoted form, which
6
+ // conversationCommandWrapper.test.js asserts, so the two are not merged.
4
7
  const shellQuote = value => `'${value.replaceAll("'", `'"'"'`)}'`;
5
8
 
6
9
  export function validateCommandWrapper(wrapper) {
@@ -1,21 +1,30 @@
1
- // Finer marks within each phase locate slow work. Hosts may mark git-actor
2
- // and genesis-rendered while preparing a native prompt.
1
+ // Finer marks within each phase locate slow work. Hosts declare any marks of
2
+ // their own preparation through hostStages.
3
3
  const stages = new Set([
4
4
  "received", "preparation-started", "authorized", "history-read", "state-read", "request-saved",
5
5
  "input-prepared", "provider-started", "duplicate-checked", "native-history-read", "process-ready",
6
- "git-actor", "genesis-rendered", "prompt-rendered", "metadata-ready", "runtime-ready", "dispatch-started",
6
+ "prompt-rendered", "metadata-ready", "runtime-ready", "dispatch-started",
7
7
  "native-acknowledged", "receipt-written", "state-active", "receipt-persisted", "admission-authorized",
8
8
  "turn-located", "accepted", "first-response", "failed"
9
9
  ]);
10
+ const hostStagePattern = /^[a-z][a-z-]{0,63}$/u;
11
+
12
+ export function normalizeConversationDeliveryHostStages(hostStages = []) {
13
+ if (!Array.isArray(hostStages) || hostStages.some(stage => typeof stage !== "string" || !hostStagePattern.test(stage))) {
14
+ throw new TypeError("Host delivery stages must be lowercase hyphenated names.");
15
+ }
16
+ return Object.freeze([...new Set(hostStages)]);
17
+ }
10
18
 
11
19
  // Observer results are never awaited and failures cannot change delivery.
12
- export function createConversationDeliveryTiming({ engine, conversationId, messageId, report, clock = () => performance.now() }) {
20
+ export function createConversationDeliveryTiming({ engine, conversationId, messageId, report, hostStages = [],
21
+ clock = () => performance.now() }) {
13
22
  const startedAt = clock();
14
23
  let previousAt = startedAt;
15
24
  let previousStage = "";
16
25
  const timings = {};
17
26
  return stage => {
18
- if (!report || !stages.has(stage) || Object.hasOwn(timings, stage)) return;
27
+ if (!report || !(stages.has(stage) || hostStages.includes(stage)) || Object.hasOwn(timings, stage)) return;
19
28
  const at = clock();
20
29
  timings[stage] = Math.max(0, at - startedAt);
21
30
  const event = { engine, conversationId, messageId, stage, previousStage,
@@ -14,3 +14,4 @@ export { createConversationHookBridge } from "./hookBridge.js";
14
14
  export { nativeConversationStoragePolicy } from "./nativeStorage.js";
15
15
  export { createConversationProcessIdentity } from "./localExecution.js";
16
16
  export { validateConversationOutputSchema } from "./structuredOutput.js";
17
+ export { conversationAttachmentIds } from "./attachments.js";
@@ -2,7 +2,8 @@ import { execFile, spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { closeSync, openSync } from "node:fs";
4
4
  import { mkdtemp, open, readFile, readdir, rm } from "node:fs/promises";
5
- import { isPlainObject, normalizeText } from "./normalize.js";
5
+ import { isRecord, normalizePositiveInteger } from "@jskit-ai/kernel/shared/support/normalize";
6
+ import { normalizeNullableText } from "./normalize.js";
6
7
  import { isAbsolute, join } from "node:path";
7
8
  import { tmpdir } from "node:os";
8
9
  import { setTimeout as delay } from "node:timers/promises";
@@ -197,11 +198,6 @@ export function createConversationProcessIdentity({
197
198
  ABSENT: "absent", AMBIGUOUS: "ambiguous", EXACT: "exact", INVALID: "invalid", MISMATCH: "mismatch"
198
199
  });
199
200
 
200
- function normalizePositiveInteger(value, fallback) {
201
- const number = Number(value);
202
- return Number.isSafeInteger(number) && number > 0 ? number : fallback;
203
- }
204
-
205
201
  function processMetadataIsIdentifiable(metadata = {}) {
206
202
  return Boolean(
207
203
  Number.isSafeInteger(Number(metadata.pid)) && Number(metadata.pid) > 0 &&
@@ -283,12 +279,12 @@ export function createConversationProcessIdentity({
283
279
  }
284
280
 
285
281
  function normalizeProcessIdentity(value = {}) {
286
- const normalized = isPlainObject(value) ? value : {};
282
+ const normalized = isRecord(value) ? value : {};
287
283
  return {
288
- commandHash: normalizeText(normalized.commandHash),
289
- platform: normalizeText(normalized.platform),
290
- runtimeToken: normalizeText(normalized.runtimeToken),
291
- startTimeTicks: normalizeText(normalized.startTimeTicks),
284
+ commandHash: normalizeNullableText(normalized.commandHash),
285
+ platform: normalizeNullableText(normalized.platform),
286
+ runtimeToken: normalizeNullableText(normalized.runtimeToken),
287
+ startTimeTicks: normalizeNullableText(normalized.startTimeTicks),
292
288
  version: Number(normalized.version || 0)
293
289
  };
294
290
  }
@@ -535,11 +531,11 @@ export function createConversationProcessIdentity({
535
531
  async function waitForProcessIdentityToSettle(metadata = {}, options = {}) {
536
532
  const timeoutMs = normalizePositiveInteger(
537
533
  options.processIdentitySettleTimeoutMs,
538
- PROCESS_IDENTITY_SETTLE_TIMEOUT_MS
534
+ { fallback: PROCESS_IDENTITY_SETTLE_TIMEOUT_MS }
539
535
  );
540
536
  const pollMs = normalizePositiveInteger(
541
537
  options.processIdentitySettlePollMs,
542
- PROCESS_IDENTITY_SETTLE_POLL_MS
538
+ { fallback: PROCESS_IDENTITY_SETTLE_POLL_MS }
543
539
  );
544
540
  const deadline = Date.now() + timeoutMs;
545
541
  let inspection = await inspectProcessIdentity(metadata, options);
@@ -648,7 +644,7 @@ export function createConversationProcessIdentity({
648
644
  let after = await waitForProcessIdentityExit(
649
645
  metadata,
650
646
  options,
651
- normalizePositiveInteger(options.termTimeoutMs, 3000)
647
+ normalizePositiveInteger(options.termTimeoutMs, { fallback: 3000 })
652
648
  );
653
649
  let signaledProcessGroups = [...term.signaledProcessGroups];
654
650
  if (after.status === PROCESS_IDENTITY_STATUS.EXACT) {
@@ -668,7 +664,7 @@ export function createConversationProcessIdentity({
668
664
  after = await waitForProcessIdentityExit(
669
665
  metadata,
670
666
  options,
671
- normalizePositiveInteger(options.killTimeoutMs, 1000)
667
+ normalizePositiveInteger(options.killTimeoutMs, { fallback: 1000 })
672
668
  );
673
669
  }
674
670
  const processExitVerified = [
@@ -702,8 +698,8 @@ export function createConversationProcessIdentity({
702
698
  reportedIdentity = null,
703
699
  runtimeToken = ""
704
700
  } = {}) {
705
- const expectedCommandHash = normalizeText(commandHash);
706
- const expectedRuntimeToken = normalizeText(runtimeToken);
701
+ const expectedCommandHash = normalizeNullableText(commandHash);
702
+ const expectedRuntimeToken = normalizeNullableText(runtimeToken);
707
703
  const reported = normalizeProcessIdentity(reportedIdentity);
708
704
  if (
709
705
  processIdentityIsWellFormed(reported) &&
@@ -1,7 +1,12 @@
1
- export function normalizeText(value) {
1
+ import { createHash } from "node:crypto";
2
+
3
+ // Unlike the kernel's normalizeText, only null and undefined become blank:
4
+ // 0 and false keep their text, as native protocol fields require.
5
+ export function normalizeNullableText(value) {
2
6
  return String(value ?? "").trim();
3
7
  }
4
8
 
5
- export function isPlainObject(value) {
6
- return Boolean(value && typeof value === "object" && !Array.isArray(value));
9
+ /** SHA-256 of the value's JSON text, used for stable configuration identities. */
10
+ export function jsonHash(value) {
11
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
7
12
  }
@@ -1,19 +1,16 @@
1
1
  import { pathToFileURL } from "node:url";
2
2
  import { Buffer } from "node:buffer";
3
3
  import path from "node:path";
4
+ import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
5
+ import { normalizeNullableText } from "./normalize.js";
4
6
  import { createNativeHistoryExport, readStoppedNativeDatabase, retireNativeConversation } from "./nativeHistoryExport.js";
5
7
 
6
8
  const OPENCODE_RESPONSE_LIMIT_BYTES = 2 * 1024 * 1024;
7
9
  const OPENCODE_CATALOG_LIMIT_BYTES = 32 * 1024 * 1024;
8
10
  const OPENCODE_EVENT_LIMIT_BYTES = 2 * 1024 * 1024;
9
11
 
10
- function text(value = "") {
11
- return String(value ?? "").trim();
12
- }
13
-
14
- function record(value = null) {
15
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
16
- }
12
+ const text = normalizeNullableText;
13
+ const record = value => isRecord(value) ? value : {};
17
14
 
18
15
  function openCodeServerError(message, {
19
16
  body = null,
@@ -594,12 +591,14 @@ function createOpenCodeServerClient({
594
591
  return statuses?.[text(sessionId)] || { type: "idle" };
595
592
  },
596
593
  async messages(sessionId = "", input = {}, { signal } = {}) {
597
- const result = await request(
594
+ // A limited read is the newest page; `before` continues with older rows.
595
+ const { source, headers } = await requestResponse(
598
596
  "GET",
599
- `${stableSessionPath(sessionId, "/message")}${queryString({ limit: input.limit })}`,
597
+ `${stableSessionPath(sessionId, "/message")}${queryString({ limit: input.limit, before: input.before })}`,
600
598
  { signal }
601
599
  );
602
- return { data: normalizedMessageRows(result) };
600
+ const nextCursor = headers.get("x-next-cursor");
601
+ return { data: normalizedMessageRows(parsedJson(source)), ...(nextCursor ? { nextCursor } : {}) };
603
602
  },
604
603
  async prompt(sessionId = "", input = {}, { signal } = {}) {
605
604
  const body = stablePromptBody(input);
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import { setTimeout as delay } from "node:timers/promises";
6
6
  import { createOpenCodeServerClient, readBoundedResponse } from "./openCodeClient.js";
7
7
  import { createLocalConversationExecution } from "./localExecution.js";
8
+ import { normalizeNullableText } from "./normalize.js";
8
9
  import { DISCOVERY_TOOL_DESCRIPTORS } from "../lib/serviceToolCatalog.js";
9
10
  export { createOpenCodeSharedRuntime, openCodeServerForDirectory, readOpenCodeEnvironments, openCodeEnvironmentForDirectory,
10
11
  openCodeEnvironmentForSession, assertOpenCodeModelProvider, limitOpenCodeModelOutput,
@@ -25,9 +26,7 @@ const OPENCODE_ZEN_MODELS_URL = "https://opencode.ai/zen/v1/models";
25
26
  const OPENCODE_ZEN_PROVIDER_ID = "opencode";
26
27
  const OPENCODE_ZEN_PUBLIC_API_KEY = "public";
27
28
 
28
- function text(value = "") {
29
- return String(value ?? "").trim();
30
- }
29
+ const text = normalizeNullableText;
31
30
 
32
31
  export function openCodeProcessEnvironment(baseEnv = {}, {
33
32
  cacheRoot = "", dbPath = "", inlineConfig = "", outputTokenMax = 0, password = ""
@@ -348,6 +347,11 @@ export async function readOpenCodeZenModelIds({
348
347
  }
349
348
  }
350
349
 
350
+ /**
351
+ * Read OpenCode's provider and agent catalogue through a temporary server.
352
+ * `createServerProcess({ dbPath, privateRoot, providerConnections, workdir })`
353
+ * starts the host's server; it must resolve to `{ client, stop }`.
354
+ */
351
355
  export async function readOpenCodeCatalog({
352
356
  createServerProcess,
353
357
  privateRoot = "",
@@ -380,8 +384,8 @@ export async function readOpenCodeCatalog({
380
384
  let readError;
381
385
  try {
382
386
  const [providers, agents] = await Promise.all([
383
- server.client.providers({ directory: server.workdir }),
384
- server.client.agents({ directory: server.workdir })
387
+ server.client.providers({ directory: normalizedWorkdir }),
388
+ server.client.agents({ directory: normalizedWorkdir })
385
389
  ]);
386
390
  catalog = { agents, providers };
387
391
  } catch (error) {
@@ -1,17 +1,19 @@
1
1
  import {
2
2
  OPENCODE_INTERRUPT_TIMEOUT_MS, waitForOpenCodeFinalResponse, openCodeLastAssistantResult, inspectOpenCodeMessageAdmission, observeOpenCodeEvents,
3
- openCodeMessageRows, openCodeRowsForInput, openCodePersistentConversationMessages, openCodeStructuredOutput, projectOpenCodeConversationMessages, waitForOpenCodeMessages, steerOpenCodeTurn, dispatchOpenCodeTurn, observeOpenCodeTurnCompletion, runOpenCodeConversationTurn
3
+ openCodeMessageRows, openCodeRowsForInput, readOpenCodeMessagesUntil, openCodePersistentConversationMessages, openCodeStructuredOutput, projectOpenCodeConversationMessages, waitForOpenCodeMessages, steerOpenCodeTurn, dispatchOpenCodeTurn, observeOpenCodeTurnCompletion, runOpenCodeConversationTurn
4
4
  } from "./openCodeTurn.js";
5
5
  import { openCodeAssistantMessageText as assistantMessageText } from "./openCodeClient.js";
6
+ import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
7
+ import { normalizeNullableText } from "./normalize.js";
6
8
  import { randomUUID } from "node:crypto";
7
9
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
8
10
  import path from "node:path";
9
11
 
10
- const text = value => String(value ?? "").trim();
12
+ const text = normalizeNullableText;
11
13
  const sharedRuntimes = new Map();
12
14
 
13
15
  function openCodeTurnSnapshot(turn = null, threadId = "") {
14
- const source = turn && typeof turn === "object" && !Array.isArray(turn) ? turn : {};
16
+ const source = isRecord(turn) ? turn : {};
15
17
  const active = source.active === true;
16
18
  return active || text(source.id)
17
19
  ? {
@@ -466,7 +468,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
466
468
  const targets = [...processes.values()];
467
469
  const providerId = text(input.modelProviderId);
468
470
  if (!providerId || targets.some(target => text(target.modelProviderId) !== providerId || !application.ownsTarget(target))) {
469
- throw Object.assign(new Error("Close other OpenCode chats and Colleague conversations, then retry this connection change. The saved connection has not changed."), {
471
+ throw Object.assign(new Error("Close the other OpenCode conversations that use this connection, then retry this connection change. The saved connection has not changed."), {
470
472
  code: "assistant_opencode_connection_in_use", statusCode: 409
471
473
  });
472
474
  }
@@ -734,11 +736,8 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
734
736
  }
735
737
 
736
738
  async function readConversation(target = {}, conversationId = "", tracked = null, options = {}, readError) {
737
- const messages = await target.server.client.messages(conversationId, {
738
- limit: 100,
739
- order: "desc"
740
- }, options);
741
- options.signal?.throwIfAborted();
739
+ const messages = await readOpenCodeMessagesUntil(target.server.client, conversationId,
740
+ rows => rows.some(row => row?.type === "assistant"), options);
742
741
  const result = openCodeLastAssistantResult(messages, { readError });
743
742
  return {
744
743
  conversationId,
@@ -829,7 +828,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
829
828
  : options.observe(observedTarget, turnAbort, signal);
830
829
  let admitted;
831
830
  try {
832
- admitted = await dispatchOpenCodeTurn(null, null, tracked, {
831
+ admitted = await dispatchOpenCodeTurn(tracked, {
833
832
  get agent() { return input.agent; },
834
833
  id: inputMessageId,
835
834
  get model() { return input.model; },
@@ -1160,7 +1159,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
1160
1159
  }, application.monitor);
1161
1160
  dispatchTurn = turns.get(key);
1162
1161
  }
1163
- admitted = await dispatchOpenCodeTurn(null, null, dispatchTurn, {
1162
+ admitted = await dispatchOpenCodeTurn(dispatchTurn, {
1164
1163
  get agent() { return prompt.agent; },
1165
1164
  delivery: currentMonitor ? "steer" : "queue",
1166
1165
  id: providerMessageId,