@jskit-ai/assistant-core 0.1.213 → 0.1.215

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/fixtures/responsive-assistant/ConversationFixture.vue +7 -1
  3. package/package.json +5 -5
  4. package/src/client/conversation/AssistantConversationElement.vue +5 -1
  5. package/src/client/conversation/AssistantTranscript.vue +10 -1
  6. package/src/server/conversation/agentRun.js +38 -11
  7. package/src/server/conversation/claudeHistory.js +40 -5
  8. package/src/server/conversation/claudeProcess.js +16 -2
  9. package/src/server/conversation/claudeTurn.js +75 -16
  10. package/src/server/conversation/codexConfiguration.js +1 -1
  11. package/src/server/conversation/codexHistoryAdapter.js +11 -4
  12. package/src/server/conversation/codexNotificationQueue.js +11 -2
  13. package/src/server/conversation/codexObservationControl.js +8 -8
  14. package/src/server/conversation/codexOutput.js +20 -15
  15. package/src/server/conversation/codexProvider.js +40 -3
  16. package/src/server/conversation/codexProviderOwner.js +110 -1
  17. package/src/server/conversation/codexTurn.js +3 -0
  18. package/src/server/conversation/continuity.js +24 -6
  19. package/src/server/conversation/index.js +1 -1
  20. package/src/server/conversation/nativeHistoryExport.js +97 -0
  21. package/src/server/conversation/openCodeClient.js +47 -1
  22. package/src/server/conversation/openCodeRuntime.js +132 -24
  23. package/src/server/conversation/openCodeTurn.js +65 -24
  24. package/src/server/conversation/providers/api.js +7 -0
  25. package/src/server/conversation/providers/claudeDriver.js +52 -7
  26. package/src/server/conversation/providers/codex.js +19 -2
  27. package/src/server/conversation/providers/codexDriver.js +92 -14
  28. package/src/server/conversation/providers/opencodeDriver.js +31 -9
  29. package/src/server/conversation/runtime.js +418 -64
  30. package/src/server/conversation/runtimeStateUpgrade.js +77 -2
  31. package/src/server/conversation/streams.js +25 -5
  32. package/src/server/conversation/tools.js +2 -2
  33. package/src/server/conversation/transcript.js +23 -8
  34. package/src/server/hosts/claudeHistory.js +1 -0
  35. package/src/server/hosts/claudeProcess.js +1 -0
  36. package/src/server/hosts/codexProvider.js +3 -0
  37. package/src/server/hosts/nativeHistory.js +1 -0
  38. package/src/server/hosts/openCodeClient.js +1 -0
  39. package/src/server/hosts/openCodeTurn.js +1 -0
  40. package/src/server/index.js +1 -1
  41. package/src/server/lib/assistantToolLoop.js +99 -2
  42. package/test/aiConnectionClient.test.js +57 -0
  43. package/test/assistantBoundedToolLoop.test.js +332 -0
  44. package/test/assistantScroll.browser.test.js +151 -0
  45. package/test/claudeConversationSession.test.js +1329 -3
  46. package/test/claudeProcess.test.js +101 -0
  47. package/test/claudeTurn.test.js +236 -1
  48. package/test/codexConversationSession.test.js +1257 -12
  49. package/test/codexHistoryAdapter.test.js +74 -1
  50. package/test/codexTurn.test.js +282 -1
  51. package/test/conversation.test.js +83 -0
  52. package/test/conversationAgentRun.test.js +181 -0
  53. package/test/conversationRuntime.test.js +95 -0
  54. package/test/conversationRuntimeStateUpgrade.test.js +89 -0
  55. package/test/conversationStreaming.test.js +85 -0
  56. package/test/conversationTools.test.js +112 -1
  57. package/test/openCodeClient.test.js +186 -0
  58. package/test/openCodeRuntime.test.js +78 -0
  59. package/test/opencodeConversationSession.test.js +637 -5
@@ -8,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.1.213",
12
- "@jskit-ai/http-runtime": "0.1.233",
13
- "@jskit-ai/kernel": "0.1.235",
11
+ "@jskit-ai/assistant-core": "0.1.215",
12
+ "@jskit-ai/http-runtime": "0.1.235",
13
+ "@jskit-ai/kernel": "0.1.237",
14
14
  "@mdi/js": "^7.4.47",
15
15
  "vue": "^3.5.13",
16
16
  "vuetify": "^4.0.0",
17
- "@jskit-ai/connectors-core": "0.1.52",
18
- "@jskit-ai/connectors-catalog": "0.1.52"
17
+ "@jskit-ai/connectors-core": "0.1.54",
18
+ "@jskit-ai/connectors-catalog": "0.1.54"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@vitejs/plugin-vue": "^6.0.7",
@@ -16,6 +16,8 @@ const previewCanTake = ref(true);
16
16
  const previewDelivery = createAssistantMessageDelivery();
17
17
  const avatarFixture = new URLSearchParams(location.search).has("avatar");
18
18
  const avatarToolsFixture = new URLSearchParams(location.search).has("avatarTools");
19
+ const updateErrorFixture = new URLSearchParams(location.search).has("update-errors");
20
+ const errorReloads = ref(0);
19
21
  const showAvatar = ref(avatarFixture);
20
22
  const avatarSize = ref("compact");
21
23
  const short = ref(avatarFixture);
@@ -136,7 +138,8 @@ const adapter = reactive({
136
138
  assistant: { messageId: `answer-${index}`, outputId: `output-${index}`, role: "assistant",
137
139
  text: `Support answer ${index + 1}: Keep your recovery email available.\n\nRead the **account instructions** and follow the [recovery link](https://example.com/help). A long message wraps inside this narrow conversation without taking space from the composer.` }
138
140
  })) : [],
139
- visible: true, reloadable: avatarFixture, scrollKey: "fixture", welcomeMessage: "Composer responsiveness fixture"
141
+ visible: true, reloadable: avatarFixture && !updateErrorFixture, error: "", errorReloadable: updateErrorFixture,
142
+ scrollKey: "fixture", welcomeMessage: "Composer responsiveness fixture"
140
143
  },
141
144
  composer: {
142
145
  draft,
@@ -153,6 +156,7 @@ const adapter = reactive({
153
156
  models: capabilitiesEnabled ? models : undefined,
154
157
  questions: computed(() => showQuestions.value ? questionState : undefined),
155
158
  actions: {
159
+ reload() { errorReloads.value += 1; adapter.conversation.error = ""; },
156
160
  setDraft(value) { draft.value = value; },
157
161
  submit(payload) { attachmentReceipts.value = payload.attachments; attachments.clearAttachments(); submissions.value += 1; draft.value = ""; phase.value = "active"; },
158
162
  stop() { phase.value = "stopping"; }
@@ -166,6 +170,8 @@ const adapter = reactive({
166
170
  <button v-if="attribution" @click="assistantLabel = 'Next assistant'">Change assistant</button>
167
171
  <button v-for="state in ['idle', 'active', 'reconnecting', 'stopping', 'stopped']" :key="state" @click="phase = state">External {{ state }}</button>
168
172
  <button @click="narrow = !narrow">Resize pane</button>
173
+ <button v-if="updateErrorFixture" @click="adapter.conversation.error = 'Conversation updates are temporarily unavailable. Reload to reconnect; the assistant may still be working.'">Show update error</button>
174
+ <output v-if="updateErrorFixture">Error reloads: {{ errorReloads }}</output>
169
175
  <button @click="feedback = !feedback">Toggle action feedback</button>
170
176
  <button v-if="supportEnabled" @click="suggestionModel = 'other-model'">Change suggestion model</button>
171
177
  <button v-if="supportEnabled" @click="customActivity = !customActivity">Custom activity</button>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.1.213",
3
+ "version": "0.1.215",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -47,8 +47,8 @@
47
47
  "@ai-sdk/perplexity": "^4.0.43",
48
48
  "@ai-sdk/togetherai": "^3.0.49",
49
49
  "@ai-sdk/xai": "^4.0.58",
50
- "@jskit-ai/resource-core": "0.1.177",
51
- "@jskit-ai/resource-crud-core": "0.1.177",
50
+ "@jskit-ai/resource-core": "0.1.179",
51
+ "@jskit-ai/resource-crud-core": "0.1.179",
52
52
  "@mdi/js": "^7.4.47",
53
53
  "@openrouter/ai-sdk-provider": "^3.0.0",
54
54
  "ai": "^7.0.101",
@@ -57,8 +57,8 @@
57
57
  "ws": "^8.20.1"
58
58
  },
59
59
  "peerDependencies": {
60
- "@jskit-ai/http-runtime": "0.1.233",
61
- "@jskit-ai/kernel": "0.1.235",
60
+ "@jskit-ai/http-runtime": "0.1.235",
61
+ "@jskit-ai/kernel": "0.1.237",
62
62
  "vue": "^3.5.13",
63
63
  "vuetify": "^4.0.0"
64
64
  },
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <section ref="container" class="assistant-conversation" :aria-label="label">
2
+ <section ref="container" class="assistant-conversation" :class="{ 'assistant-conversation--error': conversation.error }" :aria-label="label">
3
3
  <section
4
4
  v-if="$slots.avatar" class="assistant-conversation__avatar"
5
5
  :class="{ 'assistant-conversation__avatar--hidden': avatarSize === 'hidden' }" aria-label="Conversation avatar"
@@ -389,7 +389,10 @@ defineExpose({ focus: () => input.value?.focus(), submit, stop });
389
389
  <style scoped>
390
390
  .assistant-conversation__preview-actions { display: flex; justify-content: flex-end; }
391
391
  .assistant-conversation { position: relative; display: flex; flex-direction: column; min-height: 0; min-width: 0; height: 100%; gap: 0; container: assistant-conversation / inline-size; }
392
+ .assistant-conversation--error { overflow-y: auto; }
392
393
  .assistant-conversation__avatar { position: absolute; top: 0; right: 0; z-index: 3; max-width: 100%; min-width: 0; }
394
+ .assistant-conversation--error .assistant-conversation__avatar { position: relative; align-self: flex-end; flex: 0 0 auto; }
395
+ .assistant-conversation--error .assistant-conversation__avatar-controls { position: static; }
393
396
  .assistant-conversation__avatar-controls { position: absolute; top: 0; right: 100%; display: flex; align-items: center; justify-content: flex-end; min-height: 44px; }
394
397
  .assistant-conversation__avatar--hidden .assistant-conversation__avatar-controls { right: 0; }
395
398
  .assistant-conversation__avatar-size { min-width: 44px; min-height: 44px; }
@@ -401,6 +404,7 @@ defineExpose({ focus: () => input.value?.focus(), submit, stop });
401
404
  .assistant-conversation__avatar-visual :deep(> *) { max-width: 100%; max-height: 100%; }
402
405
  .assistant-conversation__goal { display: flex; justify-content: flex-end; flex: 0 0 auto; }
403
406
  .assistant-conversation__transcript { flex: 1 1 auto; min-height: 0; }
407
+ .assistant-conversation--error .assistant-conversation__transcript { min-height: min-content; }
404
408
  .assistant-conversation__composer { flex: 0 0 auto; }
405
409
  .assistant-conversation__attachment-error { flex-basis: 100%; margin: 0; }
406
410
  .assistant-conversation__setting { min-width: 8rem; max-width: 16rem; }
@@ -440,6 +440,9 @@ const KEYBOARD_SCROLL_KEYS = new Set([
440
440
  "Spacebar"
441
441
  ]);
442
442
  const timeFormatter = new Intl.DateTimeFormat(undefined, {
443
+ year: "numeric",
444
+ month: "short",
445
+ day: "2-digit",
443
446
  hour: "2-digit",
444
447
  minute: "2-digit"
445
448
  });
@@ -1103,7 +1106,12 @@ watch(() => props.visible, (visible) => {
1103
1106
  }
1104
1107
 
1105
1108
  .assistant-transcript--error {
1106
- grid-template-rows: auto minmax(0, 1fr);
1109
+ grid-template-rows: max-content minmax(0, 1fr);
1110
+ min-height: min-content;
1111
+ }
1112
+
1113
+ .assistant-transcript--error .assistant-transcript__body {
1114
+ contain: size;
1107
1115
  }
1108
1116
 
1109
1117
  .assistant-transcript__error {
@@ -1115,6 +1123,7 @@ watch(() => props.visible, (visible) => {
1115
1123
  .assistant-transcript__error-content {
1116
1124
  align-items: flex-start;
1117
1125
  display: flex;
1126
+ flex-direction: column;
1118
1127
  gap: 0.5rem;
1119
1128
  }
1120
1129
 
@@ -1,5 +1,6 @@
1
1
  import { isPlainObject, normalizeText } from "./normalize.js";
2
2
  import { createReentrantConversationStorage } from "./storage.js";
3
+ import { isCompletedEnvelopeTurn } from "./transcript.js";
3
4
  import { normalizeCodexAppServerRunState, codexAppServerRunStateIsActive,
4
5
  codexAppServerRunStateIsTerminal } from "./codexTurn.js";
5
6
 
@@ -134,7 +135,7 @@ export function createCodexConversationStore({ storage, scope, segmentId, isCurr
134
135
  assertCurrent(sessionId);
135
136
  return transcript[name](sessionId, ...args);
136
137
  };
137
- async function nativeOrigin(transaction, identity) {
138
+ async function nativeAuthorship(transaction, identity) {
138
139
  if (!identity?.threadId || !identity.turnId) return undefined;
139
140
  const { runtime } = await transaction.readMetadata();
140
141
  const run = runtime.binding?.codexAppServerRun;
@@ -143,17 +144,32 @@ export function createCodexConversationStore({ storage, scope, segmentId, isCurr
143
144
  for (const turnId of (await transaction.listTurnIds()).reverse()) {
144
145
  const turn = await transaction.readTurn(turnId);
145
146
  if (turn?.metadata?.runtime?.segmentId !== segmentId) continue;
146
- if (turn.user?.messageId === run.outerTurnId) return "user";
147
- if (turn.system?.messageId === run.outerTurnId && turn.system.origin === "application") return "application";
147
+ if (turn.user?.messageId === run.outerTurnId) return { origin: "user", messageId: run.outerTurnId, turnId };
148
+ if (turn.system?.messageId === run.outerTurnId && turn.system.origin === "application") {
149
+ return { origin: "application", messageId: run.outerTurnId, turnId,
150
+ ...(isCompletedEnvelopeTurn(turn) ? { completedEnvelope: true } : {}) };
151
+ }
148
152
  }
149
153
  const request = runtime.request;
150
- if (request?.messageId === run.outerTurnId && ["user", "application"].includes(request.origin)) return request.origin;
154
+ if (request?.messageId === run.outerTurnId && ["user", "application"].includes(request.origin)) {
155
+ return { origin: request.origin, messageId: request.messageId,
156
+ ...(request.origin === "application" && request.completedEnvelope === true ? { completedEnvelope: true } : {}) };
157
+ }
151
158
  return undefined;
152
159
  }
153
- const writer = name => (sessionId, input) => runStore.mutateSession(sessionId, async transaction => {
154
- const written = await transcript[name](sessionId, input);
160
+ const writer = name => (sessionId, input = {}) => runStore.mutateSession(sessionId, async transaction => {
161
+ const authorship = await nativeAuthorship(transaction, input.nativeIdentity);
162
+ if (authorship?.completedEnvelope && !authorship.turnId) {
163
+ throw new Error("The native output's original turn no longer exists.");
164
+ }
165
+ // Queued human rows do not own an already admitted private native response.
166
+ // Corrections retain their original explicit target and message identity.
167
+ const written = await transcript[name](sessionId, {
168
+ ...input, ...(name !== "upsertConversationAssistantMessage" && authorship?.completedEnvelope && authorship.turnId
169
+ ? { turnId: authorship.turnId } : {})
170
+ });
155
171
  if (!written || written.metadata?.runtime?.segmentId) return written;
156
- const origin = await nativeOrigin(transaction, input.nativeIdentity);
172
+ const origin = authorship?.origin;
157
173
  await transaction.updateTurnMetadata(written.turnId, { runtime: {
158
174
  status: "complete", engine: "codex", segmentId, ...(origin ? { origin } : {}),
159
175
  ...(input.nativeIdentity?.turnId ? { nativeTurnId: input.nativeIdentity.turnId } : {})
@@ -191,6 +207,8 @@ export function createCodexConversationStore({ storage, scope, segmentId, isCurr
191
207
  ...(request.data !== undefined ? { data: request.data } : {}), ...(request.goal ? { goal: request.goal } : {}),
192
208
  turnMetadata: { ...input.turnMetadata, runtime: { ...input.turnMetadata?.runtime,
193
209
  status: "running", engine: "codex", segmentId, origin: request.origin || "user",
210
+ ...(metadata.runtime.request?.messageId === input.messageId && metadata.runtime.request.completedEnvelope === true
211
+ ? { completedEnvelope: true } : {}),
194
212
  ...(input.nativeIdentity?.turnId ? { nativeTurnId: input.nativeIdentity.turnId } : {}),
195
213
  ...(request.goal ? { goalMessageId: request.messageId } : {}) } }
196
214
  });
@@ -212,11 +230,20 @@ export function createCodexConversationStore({ storage, scope, segmentId, isCurr
212
230
  });
213
231
  },
214
232
  updateConversationStream(sessionId, input) {
215
- return runStore.mutateSession(sessionId, async transaction => streams.update(sessionId, {
216
- ...input, origin: await nativeOrigin(transaction, input.nativeIdentity)
217
- }));
233
+ return runStore.mutateSession(sessionId, async transaction => {
234
+ const authorship = await nativeAuthorship(transaction, input.nativeIdentity);
235
+ return streams.update(sessionId, { ...input, origin: authorship?.origin, authorship });
236
+ });
237
+ },
238
+ completeConversationStreamMessage(sessionId, messageId, options) {
239
+ assertCurrent(sessionId);
240
+ if (options?.nativeIdentity) return runStore.mutateSession(sessionId, async transaction => {
241
+ const authorship = await nativeAuthorship(transaction, options.nativeIdentity);
242
+ return streams.complete(sessionId, messageId, { ...options, authorship,
243
+ ...(!authorship ? { text: undefined } : {}) });
244
+ });
245
+ return streams.complete(sessionId, messageId, options);
218
246
  },
219
- completeConversationStreamMessage(sessionId, messageId) { assertCurrent(sessionId); return streams.complete(sessionId, messageId); },
220
247
  clearConversationStream(sessionId) { assertCurrent(sessionId); return streams.clear(sessionId); }
221
248
  });
222
249
  }
@@ -87,7 +87,11 @@ function claudeMessageBlocks(frame, { includeNested = false } = {}) {
87
87
 
88
88
  async function readClaudeHistory(options) {
89
89
  const file = await claudeHistoryPath(options);
90
- if (!file || file.size === 0) return { exists: false, messages: [], userIds: [], text: "" };
90
+ const inspected = {
91
+ ...(options.includeUserMessages ? { userMessages: [] } : {}),
92
+ ...(options.includeFinalCarriers ? { completedResults: [], failedResults: [], structuredOutputs: [] } : {})
93
+ };
94
+ if (!file || file.size === 0) return { exists: false, messages: [], userIds: [], text: "", ...inspected };
91
95
  if (file.size > 256 * 1024 * 1024) throw new Error("Claude history exceeds the supported read limit.");
92
96
  const messages = new Map();
93
97
  const userIds = [];
@@ -98,9 +102,10 @@ async function readClaudeHistory(options) {
98
102
  let leafUuid = "";
99
103
  let rewound = false;
100
104
  // Snapshot the file length. A writer can be in the middle of its last frame;
101
- // only this native-history read allows that unfinished tail.
105
+ // Live history reads allow that unfinished tail. Stopped maintenance can
106
+ // require the existing strict parser without changing live observation.
102
107
  for await (const frame of readClaudeJsonFrames(createReadStream(file.path, { end: file.size - 1 }), {
103
- allowIncompleteTail: true
108
+ allowIncompleteTail: options.allowIncompleteTail ?? true
104
109
  })) {
105
110
  if (frame.isSidechain) continue;
106
111
  frames.push(frame);
@@ -139,12 +144,42 @@ async function readClaudeHistory(options) {
139
144
  (typeof frame.message?.content === "string" || frame.message?.content?.some((block) => block.type === "text"))) {
140
145
  userIds.push(frame.uuid);
141
146
  lastUserId = frame.uuid;
147
+ if (options.includeUserMessages && !frame.parent_tool_use_id) {
148
+ const content = frame.message.content;
149
+ const text = typeof content === "string" ? content : content
150
+ .filter(block => block.type === "text" && typeof block.text === "string").map(block => block.text).join("\n\n");
151
+ inspected.userMessages.push({ id: frame.uuid, text });
152
+ }
153
+ }
154
+ if (options.includeFinalCarriers && !frame.parent_tool_use_id && !frame.isMeta) {
155
+ if (frame.type === "result" && frame.subtype === "success" && !frame.is_error &&
156
+ !["aborted_streaming", "aborted_tools"].includes(frame.terminal_reason)) {
157
+ // Same final normalization as the live native turn; this is an actual
158
+ // saved result, never a result invented from a tool-use candidate.
159
+ const text = typeof frame.structured_output === "object" ? JSON.stringify(frame.structured_output) : String(frame.result || "");
160
+ inspected.completedResults.push({ id: frame.uuid || "", userId: lastUserId, text });
161
+ } else if (frame.type === "result") {
162
+ // Preserve the live terminal-failure/interruption contradiction without
163
+ // copying native error details into stopped application inspection.
164
+ inspected.failedResults.push({ id: frame.uuid || "", userId: lastUserId });
165
+ }
166
+ if (frame.type === "assistant") {
167
+ for (const block of frame.message?.content || []) {
168
+ if (block.type === "tool_use" && block.name === "StructuredOutput" && typeof block.id === "string" && block.id &&
169
+ block.input && typeof block.input === "object" && !Array.isArray(block.input)) {
170
+ // Raw native carrier only. A stopped importer must independently
171
+ // verify the original completed receipt and exact final/user custody.
172
+ inspected.structuredOutputs.push({ id: frame.uuid || "", userId: lastUserId,
173
+ toolUseId: block.id, text: JSON.stringify(block.input) });
174
+ }
175
+ }
176
+ }
142
177
  }
143
178
  for (const message of claudeMessageBlocks(frame)) {
144
179
  messages.set(message.id, { ...message, userId: lastUserId });
145
180
  }
146
181
  }
147
- return { exists: true, messages: [...messages.values()], userIds, goal,
182
+ return { exists: true, messages: [...messages.values()], userIds, goal, ...inspected,
148
183
  text: [...messages.values()].filter((message) => message.role === "assistant" && message.userId === lastUserId)
149
184
  .map((message) => message.text).join("\n") };
150
185
  }
@@ -155,7 +190,7 @@ export async function deleteClaudeConversationHistory({ configRoot, workdir, con
155
190
  if (file) await rm(file.path, { force: true });
156
191
  }
157
192
 
158
- export { claudeMessageBlocks, readClaudeHistory, requireClaudeSessionId, archivedClaudeProjectDirectory, matchingClaudeProjectDirectories };
193
+ export { claudeHistoryPath, claudeMessageBlocks, readClaudeHistory, requireClaudeSessionId, archivedClaudeProjectDirectory, matchingClaudeProjectDirectories };
159
194
 
160
195
  const TRANSCRIPT_PATH_PATTERN = /\.jsonl(?:\.(?:superseded|orphaned)-.*)?$/u;
161
196
 
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { nativeAiProvider } from "../../shared/nativeProviders.js";
2
3
  import { Duplex } from "node:stream";
3
4
  import { stat } from "node:fs/promises";
@@ -65,6 +66,11 @@ async function readNativeClaudeCodeAuthStatus({ env, credentialHome, command, co
65
66
  }
66
67
  }
67
68
 
69
+ /** Exact existing native connection pin, shared with stopped metadata conversion. */
70
+ export function claudeConnectionIdentity(configRoot, providerId, baseUrl, apiKey) {
71
+ return createHash("sha256").update(JSON.stringify([configRoot, providerId, baseUrl, apiKey])).digest("hex");
72
+ }
73
+
68
74
  /** Native provider routing; the application supplies only its authorized selection. */
69
75
  export function claudeModelConfiguration({ providerId, model: modelId }, connection) {
70
76
  const provider = nativeAiProvider(providerId);
@@ -305,7 +311,7 @@ export async function createClaudeCodeProcess({
305
311
  signal?.throwIfAborted();
306
312
  native = await execution.start({ command, args: claudeCodeArguments(options), cwd: workdir,
307
313
  env: environment, stream: true, limits: executionLimits });
308
- await onStarted?.(native.id);
314
+ await onStarted?.(native.id, stop);
309
315
  signal?.throwIfAborted();
310
316
  client = createClaudeJsonClient({ stream: Duplex.from({ readable: native.stdout, writable: native.stdin }), onEvent, onFailure,
311
317
  onControlRequest(request, context) {
@@ -317,9 +323,17 @@ export async function createClaudeCodeProcess({
317
323
  }
318
324
  });
319
325
  signal?.addEventListener("abort", abort, { once: true });
320
- const initialization = await client.initialize(commandWrapper ? { hooks: {
326
+ const initializing = client.initialize(commandWrapper ? { hooks: {
321
327
  PreToolUse: [{ matcher: "^Bash$", hookCallbackIds: ["jskit-command-wrapper"], timeout: 30 }]
322
328
  } } : {});
329
+ const initialization = await (native.exited ? Promise.race([
330
+ initializing,
331
+ native.exited.then(processExit => {
332
+ throw Object.assign(new Error("Claude exited before initialization completed."), {
333
+ code: "assistant_claude_startup_exited", processExit
334
+ });
335
+ })
336
+ ]) : initializing);
323
337
  return { client, initialization, executionId: native.id, stop };
324
338
  } catch (error) {
325
339
  // A rejected start may already own an execution and its cleanup proof.
@@ -203,7 +203,7 @@ export function createClaudeConversationOwner({
203
203
  if (saved && typeof saved.then === "function") saved = await saved;
204
204
  if (!main && !saved && !create && !recoveringCleanup) throw createError("This Claude conversation is unavailable.");
205
205
  const state = saved ? JSON.parse(saved) : {};
206
- const entry = { id, key, main, persistent: state.persistent === true, context, process: null,
206
+ const entry = { id, key, main, persistent: state.persistent === true, context, process: null, startupCleanup: null,
207
207
  executionId: state.executionId || (recoveringCleanup ? cleanupExecutionId : ""),
208
208
  accountIdentities: state.accountIdentities || (state.accountIdentity ? { anthropic: state.accountIdentity } : {}),
209
209
  accountIdentity: state.accountIdentity || "", sent: state.sent === true, nativeWorkdir: state.nativeWorkdir || nativeWorkdir,
@@ -312,7 +312,9 @@ export function createClaudeConversationOwner({
312
312
  if (options && typeof options.then === "function") options = await options;
313
313
  try { entry.process = await createProcess({ ...options,
314
314
  workdir: entry.nativeWorkdir, sessionId: entry.id, resume: entry.sent,
315
- onStarted: async executionId => {
315
+ onStarted: async (executionId, stop) => {
316
+ if (typeof stop === "function") entry.startupCleanup = { executionId, stop };
317
+ if (entry.executionId === executionId) return;
316
318
  entry.executionId = executionId;
317
319
  await store.save(entry, { execution: true });
318
320
  await onEvent(entry, { type: "execution", context, executionId });
@@ -380,9 +382,13 @@ export function createClaudeConversationOwner({
380
382
  await onEvent(entry, { type: "execution", context, executionId: entry.executionId });
381
383
  } catch (bindingError) { error.bindingError = bindingError; }
382
384
  }
383
- if (store.releaseExecution && error.stopProof?.scopeEmpty) await store.releaseExecution(entry);
385
+ if (store.releaseExecution && error.stopProof?.scopeEmpty) {
386
+ await store.releaseExecution(entry);
387
+ if (entry.startupCleanup?.executionId === error.executionId) entry.startupCleanup = null;
388
+ }
384
389
  throw error;
385
390
  }
391
+ if (entry.startupCleanup?.executionId === entry.executionId) entry.startupCleanup = null;
386
392
  return entry.process;
387
393
  }
388
394
 
@@ -461,7 +467,14 @@ export function createClaudeConversationOwner({
461
467
  if (!command) await entry.nativeTurn.updateState("active");
462
468
  if (applicationCommand && conversationTurn) {
463
469
  const value = deliveryResult();
464
- applicationCommand.active.delivery = { value, completion: entry.completion?.promise };
470
+ const active = applicationCommand.active;
471
+ const previousCompletion = active.completion;
472
+ if (active.finishIfCurrent) {
473
+ active.completion = Promise.withResolvers();
474
+ active.completion.promise.catch(() => {});
475
+ }
476
+ active.delivery = { value, completion: active.finishIfCurrent ? active.completion.promise : entry.completion?.promise };
477
+ if (active.finishIfCurrent) previousCompletion.resolve();
465
478
  // The original canonical admission and publication precede Core's same
466
479
  // accepted-request gate; native tools cannot race an unaccepted user.
467
480
  await applicationCommand.accept({ nativeResult: { value }, conversationTurn, nativeTurnId: entry.turn?.id });
@@ -715,13 +728,13 @@ export function createClaudeConversationOwner({
715
728
  command.signal.throwIfAborted();
716
729
  const entry = await acquire(command.context);
717
730
  const active = control.current;
718
- const applicationCommand = active?.tools ? { active, accept: command.accept } : null;
731
+ const applicationCommand = active && (active.tools || active.finishIfCurrent) ? { active, accept: command.accept } : null;
719
732
  if (applicationCommand) {
720
733
  if (entry.disposed || entry.command && entry.command !== active) throw new Error("This Claude conversation is closed or already working.");
721
734
  if (active.entry && active.entry !== entry) throw new Error("Claude's application tools belong to another native conversation.");
722
735
  active.entry = entry;
723
736
  entry.command = active;
724
- entry.toolSchemas = active.tools.schemas;
737
+ entry.toolSchemas = active.tools?.schemas || [];
725
738
  }
726
739
  const value = await send(entry, { ...command.input.nativeMessage, onPromptSending: command.beforeDispatch }, { applicationCommand });
727
740
  const completion = value.conversationTurn ? entry.completion?.promise : null;
@@ -742,12 +755,12 @@ export function createClaudeConversationOwner({
742
755
  async function run(control, command, { acquire, reportFailure, entry: selectedEntry, maximumOutput, maximumToolCalls = 32, timeoutMs } = {}) {
743
756
  if (control.disposed || control.current) throw new Error("This Claude conversation is closed or already working.");
744
757
  const binding = Boolean(store.releaseExecution);
745
- const { configuration, context, input, tools, signal, beforeDispatch, accept, onMessage, onEvent } = command;
758
+ const { configuration, context, input, tools, signal, beforeDispatch, accept, onMessage, onEvent, onNativeToolUse } = command;
746
759
  const completion = Promise.withResolvers();
747
760
  completion.promise.catch(() => {});
748
- const active = { completion, onMessage, onEvent, signal, tools, admitted: false, nativeTools: new Map(),
761
+ const active = { completion, onMessage, onEvent, onNativeToolUse, signal, tools, admitted: false, nativeTools: new Map(),
749
762
  messageId: input.messageId, goal: input.goal, nativeToolCount: 0, toolWork: new Set(), maximumToolCalls,
750
- context, failure: Promise.withResolvers() };
763
+ context, finishIfCurrent: command.finishIfCurrent, failure: Promise.withResolvers() };
751
764
  let failure;
752
765
  active.failure.promise.catch(error => { failure = error; });
753
766
  control.current = active;
@@ -804,6 +817,10 @@ export function createClaudeConversationOwner({
804
817
  catch (error) { problem = error; }
805
818
  }
806
819
  if (command.finishIfCurrent && !await command.finishIfCurrent(() => active.delivery === settling)) continue;
820
+ if (!problem && typeof result === "function") {
821
+ try { result = await result(); }
822
+ catch (error) { problem = error; }
823
+ }
807
824
  problem = dispatchFailure || failure || problem;
808
825
  if (!problem && settling?.completion && (binding ? result.status !== "completed" : !result.ok)) {
809
826
  problem = Object.assign(new Error(result.error || (binding ? "Claude interrupted this answer." : `Claude turn ${result.status}.`)), { status: result.status });
@@ -826,7 +843,7 @@ export function createClaudeConversationOwner({
826
843
  }
827
844
  } else {
828
845
  await cancellation?.catch(() => {});
829
- if (tools && active.entry) {
846
+ if (active.entry) {
830
847
  try {
831
848
  if (problem || signal.aborted) await active.entry.nativeTurn.stopProcess(problem?.message || "Work stopped.");
832
849
  } catch (error) {
@@ -921,6 +938,11 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
921
938
  messages: current.messages, error: entry.turn?.error || "", status };
922
939
  }
923
940
 
941
+ function resolveCompletion(result) {
942
+ entry.completion?.resolve(result);
943
+ if (entry.command?.finishIfCurrent) entry.command.completion.resolve(result);
944
+ }
945
+
924
946
  async function updateState(state, message = "") {
925
947
  if (!entry.turn) return;
926
948
  const active = ["starting", "active", "finalizing"].includes(state);
@@ -947,10 +969,11 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
947
969
  const active = read().active;
948
970
  if (!binding) entry.stopping = true;
949
971
  const operation = async () => {
972
+ const startup = entry.startupCleanup?.executionId === entry.executionId ? entry.startupCleanup : null;
950
973
  let proof;
951
974
  try {
952
975
  if (!binding || entry.process || entry.executionId) {
953
- proof = await stopClaudeCodeProcess({ process: entry.process, executionId: entry.executionId,
976
+ proof = await stopClaudeCodeProcess({ process: entry.process || startup, executionId: entry.executionId,
954
977
  stopExecution: process.stopExecution });
955
978
  }
956
979
  } catch (error) {
@@ -966,12 +989,25 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
966
989
  entry.exitProof = proof;
967
990
  stop(binding ? reason : reason || "Claude stopped before acknowledging the prompt.");
968
991
  if (binding) {
992
+ if (entry.startupCleanup === startup) entry.startupCleanup = null;
969
993
  if (active) entry.completion?.resolve({ status: "interrupted", error: reason || "Work stopped." });
970
994
  return;
971
995
  }
972
- if (entry.turn?.active) await updateState("interrupted", reason);
973
- else await onEvent({ type: "save" });
974
- entry.completion?.resolve(readResult());
996
+ if (entry.turn?.active) {
997
+ try { await updateState("interrupted", reason); }
998
+ catch (error) {
999
+ // Native cleanup is verified even when the host refuses its checkpoint.
1000
+ // Retain that refusal without publishing a completed answer or active work.
1001
+ setState("interrupted", error.message);
1002
+ await onEvent({ type: "save" });
1003
+ await onEvent({ type: "state", state: "interrupted", active: false, message: error.message });
1004
+ resolveCompletion(readResult());
1005
+ if (entry.startupCleanup === startup) entry.startupCleanup = null;
1006
+ throw error;
1007
+ }
1008
+ } else await onEvent({ type: "save" });
1009
+ resolveCompletion(readResult());
1010
+ if (entry.startupCleanup === startup) entry.startupCleanup = null;
975
1011
  return entry.exitProof;
976
1012
  };
977
1013
  if (!binding) return operation();
@@ -980,9 +1016,32 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
980
1016
  return entry.stopPending;
981
1017
  }
982
1018
 
983
- async function publishEntryEvent(event) {
1019
+ async function publishEntryEvent(event, deferred = false) {
984
1020
  if (entry.stopping) return;
985
1021
  const binding = Boolean(process.releaseExecution);
1022
+ const command = entry.command;
1023
+ if (event.type === "settled" && !binding && !deferred && command?.finishIfCurrent) {
1024
+ const delivery = command.delivery;
1025
+ const completion = entry.completion;
1026
+ const owner = acceptedInput;
1027
+ // Release the native queue before the common run joins reserved steering.
1028
+ // That same accepted delivery then performs the original retained checkpoint.
1029
+ command.completion.resolve(async () => {
1030
+ if (entry.command !== command || command.delivery !== delivery || entry.completion !== completion ||
1031
+ command.signal.aborted || !currentReceiptOwner(owner)) {
1032
+ clearFinalAssistantResult();
1033
+ throw failure("Claude's completion belongs to a retired native input.");
1034
+ }
1035
+ try {
1036
+ await publishEntryEvent(event, true);
1037
+ return readResult();
1038
+ } catch (error) {
1039
+ clearFinalAssistantResult();
1040
+ throw error;
1041
+ }
1042
+ });
1043
+ return;
1044
+ }
986
1045
  if (event.type === "admitted") {
987
1046
  if (event.userEcho) entry.sent = true;
988
1047
  if (!binding) await onEvent({ type: "save" });
@@ -1002,7 +1061,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1002
1061
  }
1003
1062
  if (event.type === "settled") {
1004
1063
  if (event.source === "result") await onEvent({ type: "provider-update", change: "goal" });
1005
- entry.completion?.resolve(binding ? event : readResult());
1064
+ resolveCompletion(binding ? event : readResult());
1006
1065
  }
1007
1066
  return published;
1008
1067
  }
@@ -943,7 +943,7 @@ export function codexProviderModelCatalog({ models }) {
943
943
  supports_reasoning_summaries: false,
944
944
  default_reasoning_summary: "none",
945
945
  support_verbosity: false,
946
- truncation_policy: { mode: "bytes", limit: 10000 },
946
+ truncation_policy: { mode: "tokens", limit: 10000 },
947
947
  context_window: model.contextWindow,
948
948
  max_context_window: model.contextWindow,
949
949
  effective_context_window_percent: 95,
@@ -1,4 +1,5 @@
1
1
  import { open, realpath } from "node:fs/promises";
2
+ import { isUtf8 } from "node:buffer";
2
3
  import { createServer } from "node:http";
3
4
  import path from "node:path";
4
5
  import { Readable } from "node:stream";
@@ -120,8 +121,9 @@ function retainedUserRecord(item) {
120
121
  }
121
122
 
122
123
  // Scan a fixed snapshot without retaining the lifetime transcript. A single
123
- // record still has a transport-sized bound; incomplete native appends are ignored.
124
- async function* readCodexHistoryRows(file, start, end, signal) {
124
+ // record still has a transport-sized bound; recovery ignores incomplete appends.
125
+ // Stopped offline inspection can require complete rows without changing recovery.
126
+ async function* readCodexHistoryRows(file, start, end, signal, { strictComplete = false } = {}) {
125
127
  const chunk = Buffer.alloc(64 * 1024);
126
128
  let pending = [];
127
129
  let size = 0;
@@ -141,7 +143,11 @@ async function* readCodexHistoryRows(file, start, end, signal) {
141
143
  pending.push(Buffer.from(chunk.subarray(from, until)));
142
144
  if (complete) {
143
145
  signal.throwIfAborted();
144
- if (size) yield { row: JSON.parse(Buffer.concat(pending, size).toString("utf8")), offset: lineStart };
146
+ if (size) {
147
+ const bytes = Buffer.concat(pending, size);
148
+ if (strictComplete && !isUtf8(bytes)) throw compactionHistoryError("the saved history contains invalid UTF-8.");
149
+ yield { row: JSON.parse(bytes.toString("utf8")), offset: lineStart };
150
+ }
145
151
  pending = [];
146
152
  size = 0;
147
153
  lineStart = offset + newline + 1;
@@ -150,6 +156,7 @@ async function* readCodexHistoryRows(file, start, end, signal) {
150
156
  }
151
157
  offset += bytesRead;
152
158
  }
159
+ if (strictComplete && size) throw compactionHistoryError("the saved history has an incomplete final record.");
153
160
  }
154
161
 
155
162
  // Native compaction keeps the original rollout on disk. Foreign providers
@@ -456,4 +463,4 @@ async function prepareCodexHistory(params, client, { baseUrl, modelProviderId, .
456
463
  } };
457
464
  }
458
465
 
459
- export { startCodexHistoryAdapter, translateCodexHistory, prepareCodexHistory };
466
+ export { startCodexHistoryAdapter, translateCodexHistory, prepareCodexHistory, readCodexHistoryRows };
@@ -40,8 +40,7 @@ export function createCodexAppServerNotificationQueue({
40
40
  });
41
41
  }
42
42
 
43
- async function drainCodexAppServerNotificationTasks(sessionId = "") {
44
- const taskSessionKey = namespace(sessionId);
43
+ async function drainCodexAppServerNotificationTasksForKey(taskSessionKey) {
45
44
  while (true) {
46
45
  const task = codexAppServerNotificationTasks.get(taskSessionKey);
47
46
  if (!task) {
@@ -51,6 +50,15 @@ export function createCodexAppServerNotificationQueue({
51
50
  }
52
51
  }
53
52
 
53
+ async function drainCodexAppServerNotificationTasks(sessionId = "") {
54
+ return drainCodexAppServerNotificationTasksForKey(namespace(sessionId));
55
+ }
56
+
57
+ function captureCodexAppServerNotificationDrain(sessionId = "") {
58
+ const taskSessionKey = namespace(sessionId);
59
+ return () => drainCodexAppServerNotificationTasksForKey(taskSessionKey);
60
+ }
61
+
54
62
  function queueCodexAppServerStream(context, classification, deliver) {
55
63
  if (isClosing() || !classification.itemId || !classification.turnId) return;
56
64
  const pending = codexAppServerPendingStreams.get(context.sessionKey);
@@ -97,6 +105,7 @@ export function createCodexAppServerNotificationQueue({
97
105
  pendingReasoning: codexAppServerPendingReasoning,
98
106
  run: runCodexAppServerNotificationTask,
99
107
  drain: drainCodexAppServerNotificationTasks,
108
+ captureDrain: captureCodexAppServerNotificationDrain,
100
109
  stream: queueCodexAppServerStream,
101
110
  reasoning: queueCodexAppServerReasoning
102
111
  };