@jskit-ai/assistant-core 0.1.211 → 0.1.213

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.
@@ -8,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.1.211",
12
- "@jskit-ai/http-runtime": "0.1.231",
13
- "@jskit-ai/kernel": "0.1.233",
11
+ "@jskit-ai/assistant-core": "0.1.213",
12
+ "@jskit-ai/http-runtime": "0.1.233",
13
+ "@jskit-ai/kernel": "0.1.235",
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.50",
18
- "@jskit-ai/connectors-catalog": "0.1.50"
17
+ "@jskit-ai/connectors-core": "0.1.52",
18
+ "@jskit-ai/connectors-catalog": "0.1.52"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@vitejs/plugin-vue": "^6.0.7",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.1.211",
3
+ "version": "0.1.213",
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.175",
51
- "@jskit-ai/resource-crud-core": "0.1.175",
50
+ "@jskit-ai/resource-core": "0.1.177",
51
+ "@jskit-ai/resource-crud-core": "0.1.177",
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.231",
61
- "@jskit-ai/kernel": "0.1.233",
60
+ "@jskit-ai/http-runtime": "0.1.233",
61
+ "@jskit-ai/kernel": "0.1.235",
62
62
  "vue": "^3.5.13",
63
63
  "vuetify": "^4.0.0"
64
64
  },
@@ -147,6 +147,10 @@ export function claudeCodeArguments({
147
147
  } : {} }));
148
148
  if (!applicationTools) args.push("--disallowedTools", "mcp__*");
149
149
  if (!toolFree) args.push("--tools", "Bash,Read,Edit,Write,Glob,Grep,NotebookEdit");
150
+ } else if (applicationTools) {
151
+ args.push("--mcp-config", JSON.stringify({ mcpServers: {
152
+ [CLAUDE_APPLICATION_TOOL_SERVER]: { type: "sdk", name: CLAUDE_APPLICATION_TOOL_SERVER }
153
+ } }));
150
154
  }
151
155
  if (outputSchema) args.push("--json-schema", JSON.stringify(outputSchema));
152
156
  return args;
@@ -318,8 +322,14 @@ export async function createClaudeCodeProcess({
318
322
  } } : {});
319
323
  return { client, initialization, executionId: native.id, stop };
320
324
  } catch (error) {
321
- const proof = await stop();
322
- error.stopProof = proof;
325
+ // A rejected start may already own an execution and its cleanup proof.
326
+ // Only a returned native handle transfers that cleanup to this owner.
327
+ if (native) {
328
+ error.executionId = native.id;
329
+ try { error.stopProof = await stop(); }
330
+ catch (cleanupError) { error.cleanupError = cleanupError; error.stopProof = { scopeEmpty: false }; }
331
+ if (error.stopProof?.scopeEmpty !== true) error.cleanupFailed = true;
332
+ }
323
333
  throw error;
324
334
  } finally {
325
335
  signal?.removeEventListener("abort", abort);
@@ -4,7 +4,7 @@ export const CLAUDE_APPLICATION_TOOL_SERVER = "application";
4
4
 
5
5
  // The CLI carries this SDK MCP exchange over its existing control pipe. There
6
6
  // is no listening socket or separate tool process. The shared executor owns effects.
7
- export async function claudeApplicationToolResponse(request, { schemas, turn, signal }) {
7
+ export async function claudeApplicationToolResponse(request, { schemas, turn, signal, assertCurrent, onExecutionFailure }) {
8
8
  if (request?.subtype !== "mcp_message" || request.server_name !== CLAUDE_APPLICATION_TOOL_SERVER ||
9
9
  request.message?.jsonrpc !== "2.0") throw new Error("This Claude control request is not an application tool exchange.");
10
10
  const message = request.message;
@@ -31,7 +31,16 @@ export async function claudeApplicationToolResponse(request, { schemas, turn, si
31
31
  !isDeepStrictEqual(native.input, params.arguments || {})) {
32
32
  throw new Error("Claude's application call does not match an admitted native tool use. Update Claude if its tool identity is unavailable.");
33
33
  }
34
- const value = await turn.tools.execute({ id, name: params.name, arguments: JSON.stringify(params.arguments || {}) }, { signal, messageId: native.messageId });
34
+ // Optional supplied-owner checks run after the unchanged native-use
35
+ // validation. Protocol/stale refusals are not owned executor failures.
36
+ assertCurrent?.(native);
37
+ let value;
38
+ try {
39
+ value = await turn.tools.execute({ id, name: params.name, arguments: JSON.stringify(params.arguments || {}) }, { signal, messageId: native.messageId });
40
+ } catch (error) {
41
+ onExecutionFailure?.(error, native);
42
+ throw error;
43
+ }
35
44
  result = { content: [{ type: "text", text: JSON.stringify(value) }], isError: value.ok === false };
36
45
  break;
37
46
  }
@@ -149,8 +149,20 @@ export function createClaudeConversationOwner({
149
149
  phase: entry.turn.active && !entry.observationError ? text(entry.turn.phase) : "" } : null;
150
150
  }
151
151
 
152
+ function currentApplicationCommand(entry, command, owner = command?.nativeOwner) {
153
+ const native = entry?.nativeTurn.read();
154
+ return Boolean(owner && entry && !closing && !closingSessions.has(entry.context.key) &&
155
+ entries.get(entry.key) === entry && entry.command === command && command.entry === entry &&
156
+ command.admitted && !command.signal.aborted && !command.toolFailure &&
157
+ !entry.disposed && !entry.stopping && !entry.stopPending && !entry.observationError &&
158
+ entry.process === owner.process && entry.executionId === owner.executionId &&
159
+ entry.accountIdentity === owner.accountIdentity && entry.id === owner.threadId &&
160
+ entry.turn?.id === owner.turnId && entry.turn.active && native.turnId === owner.turnId && native.active);
161
+ }
162
+
152
163
  function readRetainedTurn(contextKey, conversationId, state) {
153
164
  const entry = entries.get(`${contextKey}\0${conversationId}`);
165
+ if (state.current) return currentApplicationCommand(entry, entry?.command) ? snapshot(entry) : null;
154
166
  const saved = state.saved;
155
167
  return state.nativeResult?.turn || (entry ? snapshot(entry) : saved?.turnId ? {
156
168
  id: saved.turnId, threadId: conversationId, state: saved.state,
@@ -158,6 +170,15 @@ export function createClaudeConversationOwner({
158
170
  } : null);
159
171
  }
160
172
 
173
+ // This lookup uses only the existing current entry and native receipt owner.
174
+ function readFinalAssistantResult(contextKey, conversationId = "", turnId = "") {
175
+ if (!text(contextKey) || !text(conversationId) || !text(turnId) || closing || closingSessions.has(contextKey)) return null;
176
+ const entry = entries.get(`${contextKey}\0${conversationId}`);
177
+ if (!entry || entry.disposed || entry.stopping || entry.observationError ||
178
+ entry.id !== conversationId || entry.context.key !== contextKey || entry.turn?.id !== turnId) return null;
179
+ return entry.nativeTurn.readFinalAssistantResult(conversationId, turnId);
180
+ }
181
+
161
182
  function readHistory(entry) {
162
183
  return readClaudeHistory({ configRoot, workdir: entry.nativeWorkdir, conversationId: entry.id });
163
184
  }
@@ -231,6 +252,7 @@ export function createClaudeConversationOwner({
231
252
  const account = preparation.account(entry);
232
253
  const identity = await account.identity;
233
254
  const providerId = account.providerId;
255
+ if (entry.accountIdentity !== identity) entry.nativeTurn.invalidateFinalAssistantResult();
234
256
  try {
235
257
  await bindClaudeConversationAccount(entry, { identity, providerId,
236
258
  stop: (entry, reason) => entry.nativeTurn.stopProcess(reason), save: saveAccount });
@@ -310,10 +332,27 @@ export function createClaudeConversationOwner({
310
332
  }
311
333
  await entry.nativeTurn.receive(frame);
312
334
  },
313
- ...(store.releaseExecution ? {
335
+ ...(store.releaseExecution || entry.command?.tools ? {
314
336
  async onControlRequest(request, { signal }) {
315
337
  const active = entry.command;
316
- const work = claudeApplicationToolResponse(request, { schemas: entry.toolSchemas || [], turn: active, signal });
338
+ const suppliedTools = !store.releaseExecution && active?.tools;
339
+ const owner = active?.nativeOwner;
340
+ const work = claudeApplicationToolResponse(request, { schemas: entry.toolSchemas || [], turn: active, signal,
341
+ ...(suppliedTools ? {
342
+ assertCurrent(native) {
343
+ if (!currentApplicationCommand(entry, active, owner) || active.nativeOwner !== owner ||
344
+ native.messageId !== active.messageId) throw new Error("Claude's application call belongs to a retired input.");
345
+ },
346
+ onExecutionFailure(error, native) {
347
+ // Already-invoked old work retains its original result. Only
348
+ // this still-current authored input can fail this native run.
349
+ if (currentApplicationCommand(entry, active, owner) && active.nativeOwner === owner &&
350
+ native.messageId === active.messageId) {
351
+ active.toolFailure = error;
352
+ active.failure.reject(error);
353
+ }
354
+ }
355
+ } : {}) });
317
356
  active?.toolWork.add(work);
318
357
  try { return await work; }
319
358
  finally { active?.toolWork.delete(work); }
@@ -331,13 +370,23 @@ export function createClaudeConversationOwner({
331
370
  await entry.nativeTurn.stopProcess(error.message);
332
371
  }
333
372
  }); } catch (error) {
373
+ if (error.executionId && error.stopProof?.scopeEmpty !== true && !entry.executionId) {
374
+ // Streaming startup can fail after host admission, before onStarted.
375
+ // Retain that custody through the same binding owner for Stop recovery.
376
+ error.cleanupFailed = true;
377
+ entry.executionId = error.executionId;
378
+ try {
379
+ await store.save(entry, { execution: true });
380
+ await onEvent(entry, { type: "execution", context, executionId: entry.executionId });
381
+ } catch (bindingError) { error.bindingError = bindingError; }
382
+ }
334
383
  if (store.releaseExecution && error.stopProof?.scopeEmpty) await store.releaseExecution(entry);
335
384
  throw error;
336
385
  }
337
386
  return entry.process;
338
387
  }
339
388
 
340
- async function send(entry, input = {}, { renewal = false, command } = {}) {
389
+ async function send(entry, input = {}, { renewal = false, command, applicationCommand } = {}) {
341
390
  const message = command ? input.text : text(input.message || input.prompt);
342
391
  if (!command && !message) throw createError("Enter a message for Claude.");
343
392
  const context = entry.context;
@@ -386,8 +435,17 @@ export function createClaudeConversationOwner({
386
435
  entry.onEvent = context.onEvent;
387
436
  entry.lastMessageId = uuid;
388
437
  const actorMetadata = command ? undefined : await onEvent(entry, { type: "message-metadata", input, context });
438
+ const nativeOwner = applicationCommand ? { process: native, executionId: entry.executionId,
439
+ accountIdentity: entry.accountIdentity, threadId: entry.id, turnId: entry.turn.id } : null;
389
440
  let conversationTurn;
441
+ function deliveryResult() {
442
+ return { ok: true, delivered: true, deliveryMode: steering ? "steer" : "new_turn",
443
+ thread: { id: entry.id }, turn: snapshot(entry), workdir: context.workdir, conversationTurn };
444
+ }
390
445
  async function accept() {
446
+ // The new native ACK retires old effect custody while its canonical
447
+ // admission/commit is awaited; it cannot retag a delayed old failure.
448
+ if (applicationCommand) applicationCommand.active.nativeOwner = null;
391
449
  if (command) {
392
450
  if (!steering) await onEvent(entry, { type: "binding-admitted" });
393
451
  await command.accept();
@@ -401,6 +459,22 @@ export function createClaudeConversationOwner({
401
459
  if (admission) conversationTurn = await admission;
402
460
  }
403
461
  if (!command) await entry.nativeTurn.updateState("active");
462
+ if (applicationCommand && conversationTurn) {
463
+ const value = deliveryResult();
464
+ applicationCommand.active.delivery = { value, completion: entry.completion?.promise };
465
+ // The original canonical admission and publication precede Core's same
466
+ // accepted-request gate; native tools cannot race an unaccepted user.
467
+ await applicationCommand.accept({ nativeResult: { value }, conversationTurn, nativeTurnId: entry.turn?.id });
468
+ if (entry.process !== nativeOwner.process || entry.executionId !== nativeOwner.executionId ||
469
+ entry.accountIdentity !== nativeOwner.accountIdentity || entry.id !== nativeOwner.threadId ||
470
+ entry.turn?.id !== nativeOwner.turnId || entry.disposed || entry.stopping || entry.stopPending) {
471
+ throw new Error("Claude's admitted tools no longer belong to this native process.");
472
+ }
473
+ applicationCommand.active.nativeOwner = nativeOwner;
474
+ applicationCommand.active.messageId = messageId;
475
+ if (steering) applicationCommand.active.nativeToolCount = 0;
476
+ applicationCommand.active.admitted = true;
477
+ }
404
478
  }
405
479
  try {
406
480
  if (command) command.attempted = true;
@@ -411,8 +485,7 @@ export function createClaudeConversationOwner({
411
485
  if (!command) await entry.nativeTurn.stopProcess(error.message);
412
486
  throw error;
413
487
  }
414
- return { ok: true, delivered: true, deliveryMode: steering ? "steer" : "new_turn",
415
- thread: { id: entry.id }, turn: snapshot(entry), workdir: context.workdir, conversationTurn };
488
+ return deliveryResult();
416
489
  }
417
490
 
418
491
  async function startTurn(entry, input = {}) {
@@ -641,10 +714,19 @@ export function createClaudeConversationOwner({
641
714
  }
642
715
  command.signal.throwIfAborted();
643
716
  const entry = await acquire(command.context);
644
- const value = await send(entry, { ...command.input.nativeMessage, onPromptSending: command.beforeDispatch });
717
+ const active = control.current;
718
+ const applicationCommand = active?.tools ? { active, accept: command.accept } : null;
719
+ if (applicationCommand) {
720
+ if (entry.disposed || entry.command && entry.command !== active) throw new Error("This Claude conversation is closed or already working.");
721
+ if (active.entry && active.entry !== entry) throw new Error("Claude's application tools belong to another native conversation.");
722
+ active.entry = entry;
723
+ entry.command = active;
724
+ entry.toolSchemas = active.tools.schemas;
725
+ }
726
+ const value = await send(entry, { ...command.input.nativeMessage, onPromptSending: command.beforeDispatch }, { applicationCommand });
645
727
  const completion = value.conversationTurn ? entry.completion?.promise : null;
646
728
  const delivered = { value, completion };
647
- if (value.delivered === true && value.conversationTurn) {
729
+ if (!applicationCommand && value.delivered === true && value.conversationTurn) {
648
730
  if (control.current) control.current.delivery = delivered;
649
731
  await command.accept({ nativeResult: { value }, conversationTurn: value.conversationTurn, nativeTurnId: value.turn?.id });
650
732
  }
@@ -734,12 +816,29 @@ export function createClaudeConversationOwner({
734
816
  if (binding) {
735
817
  try {
736
818
  if (problem || signal.aborted) await (cancellation || entry.nativeTurn.stopProcess(problem?.message || "Work stopped."));
737
- } catch (error) { problem = error; }
819
+ } catch (error) {
820
+ if (problem && error !== problem) error.cause ||= problem;
821
+ problem = error;
822
+ }
738
823
  finally {
739
824
  await Promise.allSettled([...active.toolWork]);
740
825
  if (entry?.command === active) entry.command = null;
741
826
  }
742
- } else await cancellation?.catch(() => {});
827
+ } else {
828
+ await cancellation?.catch(() => {});
829
+ if (tools && active.entry) {
830
+ try {
831
+ if (problem || signal.aborted) await active.entry.nativeTurn.stopProcess(problem?.message || "Work stopped.");
832
+ } catch (error) {
833
+ if (problem && error !== problem) error.cause ||= problem;
834
+ problem = error;
835
+ }
836
+ finally {
837
+ await Promise.allSettled([...active.toolWork]);
838
+ if (active.entry.command === active) active.entry.command = null;
839
+ }
840
+ }
841
+ }
743
842
  if (control.current === active) control.current = null;
744
843
  if (problem) {
745
844
  if (binding && nativeDispatch.attempted && !nativeDispatch.admitted) problem.delivery = "uncertain";
@@ -748,7 +847,7 @@ export function createClaudeConversationOwner({
748
847
  if (!binding) return { value: delivered.value };
749
848
  }
750
849
 
751
- return Object.freeze({ entries, closingSessions, get closing() { return closing; }, createAccountQueries, open, acquire, createConversation, snapshot, readRetainedTurn, readHistory, ensureReady, ensureProcess, stopForTerminal, prepareTerminal, send, startTurn,
850
+ return Object.freeze({ applicationToolsSupported: true, entries, closingSessions, get closing() { return closing; }, createAccountQueries, open, acquire, createConversation, snapshot, readRetainedTurn, readFinalAssistantResult, readHistory, ensureReady, ensureProcess, stopForTerminal, prepareTerminal, send, startTurn,
752
851
  interrupt, read, inspectAdmission, hasActiveTemporaryConversation, hasActiveConversation, retireConversationHistory, reconcileSessions, restoreSessionEntries, closeSession, closeProject, invalidateRuntimes, drain, forget, close, deleteConversation, recover, stopSelected, wait, stopConversation,
753
852
  readGoal, updateGoal, renewal, run, steer, cancel });
754
853
  }
@@ -773,6 +872,36 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
773
872
  let cancelling = false;
774
873
  let steering;
775
874
  let maximumOutput = 4 * 1024 * 1024;
875
+ let inputOwner = null;
876
+ let acceptedInput = null;
877
+ let publishedAssistant = null;
878
+ let finalAssistantResult = null;
879
+
880
+ function clearFinalAssistantResult() {
881
+ publishedAssistant = null;
882
+ finalAssistantResult = null;
883
+ }
884
+
885
+ function invalidateFinalAssistantResult() {
886
+ inputOwner = null;
887
+ acceptedInput = null;
888
+ clearFinalAssistantResult();
889
+ }
890
+
891
+ function currentReceiptOwner(owner) {
892
+ return Boolean(entry && owner && inputOwner === owner && acceptedInput === owner &&
893
+ owner.commandId === currentCommandId && entry.process && entry.process === owner.process &&
894
+ entry.executionId === owner.executionId && entry.accountIdentity === owner.accountIdentity &&
895
+ entry.turn?.id === turnId && !entry.stopping && !entry.stopPending && !entry.disposed &&
896
+ !entry.observationError && !entry.command?.toolWork.size && !entry.command?.toolFailure);
897
+ }
898
+
899
+ function readFinalAssistantResult(threadId, expectedTurnId) {
900
+ if (threadId !== conversationId || expectedTurnId !== turnId || !finalAssistantResult ||
901
+ active || stopped || cancelling || outcome?.status !== "completed" || outcome.error ||
902
+ inFlight.size || tasks.size || admissions.size || !currentReceiptOwner(acceptedInput)) return null;
903
+ return structuredClone(finalAssistantResult);
904
+ }
776
905
 
777
906
  function read() {
778
907
  return { turnId, active, text: result, phase, status: active ? "inProgress" : outcome?.status || "ready",
@@ -810,6 +939,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
810
939
  }
811
940
 
812
941
  async function stopProcess(reason = "") {
942
+ invalidateFinalAssistantResult();
813
943
  // Runtime bindings commit execution release before dropping the handle.
814
944
  // Retained host entries keep their original post-proof checkpoint order.
815
945
  const binding = Boolean(process.releaseExecution);
@@ -864,7 +994,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
864
994
  if (binding) setState(state, event.error);
865
995
  else await updateState(state, event.error);
866
996
  }
867
- await onEvent(event);
997
+ const published = await onEvent(event);
868
998
  if (event.type === "provider-event") {
869
999
  const frame = event.event;
870
1000
  const change = frame.type === "active_goal" ? "goal" : frame.type === "rate_limit_event" ? "usage" : "";
@@ -874,6 +1004,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
874
1004
  if (event.source === "result") await onEvent({ type: "provider-update", change: "goal" });
875
1005
  entry.completion?.resolve(binding ? event : readResult());
876
1006
  }
1007
+ return published;
877
1008
  }
878
1009
 
879
1010
  function checkOutput(value, role = "assistant") {
@@ -884,7 +1015,16 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
884
1015
 
885
1016
  async function publishMessage(message) {
886
1017
  messages.set(message.id, message);
887
- await publish({ type: "message", message });
1018
+ const owner = acceptedInput;
1019
+ const completeAssistant = message.complete && message.role === "assistant";
1020
+ if (completeAssistant) clearFinalAssistantResult();
1021
+ const conversationTurn = await publish({ type: "message", message });
1022
+ if (completeAssistant && message.text && currentReceiptOwner(owner) && !stopped && !cancelling &&
1023
+ conversationTurn && typeof conversationTurn === "object" && !Array.isArray(conversationTurn)) {
1024
+ publishedAssistant = { owner, itemId: message.id, outputId: message.outputId || message.id,
1025
+ text: message.text, conversationTurn: structuredClone(conversationTurn) };
1026
+ }
1027
+ return conversationTurn;
888
1028
  }
889
1029
 
890
1030
  async function receive(frame) {
@@ -908,9 +1048,12 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
908
1048
  clearTimeout(pending.timer);
909
1049
  try {
910
1050
  await pending.accept();
1051
+ if (inputOwner === pending.receiptOwner && !stopped && !cancelling) acceptedInput = pending.receiptOwner;
911
1052
  admissions.delete(uuid);
912
1053
  pending.resolve();
913
1054
  } catch (error) {
1055
+ acceptedInput = null;
1056
+ clearFinalAssistantResult();
914
1057
  pending.reject(error);
915
1058
  throw error;
916
1059
  }
@@ -988,12 +1131,22 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
988
1131
  outcome && inFlight.size === 0 && tasks.size === 0) {
989
1132
  active = false;
990
1133
  phase = "";
991
- await publish({ type: "settled", ...read(), source: frame.type });
1134
+ const candidate = publishedAssistant;
1135
+ finalAssistantResult = outcome.status === "completed" && !outcome.error && !stopped && !cancelling &&
1136
+ admissions.size === 0 && candidate && currentReceiptOwner(candidate.owner) && candidate.text === result
1137
+ ? { threadId: conversationId, turnId, inputMessageId: candidate.owner.commandId,
1138
+ itemId: candidate.itemId, outputId: candidate.outputId, text: candidate.text,
1139
+ conversationTurn: candidate.conversationTurn } : null;
1140
+ try { await publish({ type: "settled", ...read(), source: frame.type }); }
1141
+ catch (error) { clearFinalAssistantResult(); throw error; }
992
1142
  }
993
1143
  }
994
1144
 
995
1145
  function stop(reason = "Claude stopped before acknowledging the prompt.") {
996
1146
  stopped = true;
1147
+ inputOwner = null;
1148
+ acceptedInput = null;
1149
+ clearFinalAssistantResult();
997
1150
  for (const pending of admissions.values()) {
998
1151
  pending.reject(failure(reason));
999
1152
  }
@@ -1008,12 +1161,13 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1008
1161
 
1009
1162
  async function interrupt(client) {
1010
1163
  cancelling = true;
1164
+ clearFinalAssistantResult();
1011
1165
  if (client) await client.interrupt();
1012
1166
  // The process host still must drain its owned scope and call stop().
1013
1167
  }
1014
1168
 
1015
1169
  return Object.freeze({
1016
- receive, read, stop, interrupt,
1170
+ receive, read, stop, interrupt, readFinalAssistantResult, invalidateFinalAssistantResult,
1017
1171
  ...(entry ? { readResult, updateState, stopProcess } : {}),
1018
1172
  ...(instructions ? {
1019
1173
  prepare: instructions.prepare,
@@ -1024,6 +1178,9 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1024
1178
  begin({ id, maxOutputCharacters = 4 * 1024 * 1024 } = {}) {
1025
1179
  if (active) throw failure("This Claude turn is still working.");
1026
1180
  if (!id || !Number.isSafeInteger(maxOutputCharacters) || maxOutputCharacters < 1) throw new TypeError("Invalid Claude turn configuration.");
1181
+ inputOwner = null;
1182
+ acceptedInput = null;
1183
+ clearFinalAssistantResult();
1027
1184
  turnId = id;
1028
1185
  maximumOutput = maxOutputCharacters;
1029
1186
  messages.clear();
@@ -1041,10 +1198,18 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1041
1198
  messages.clear();
1042
1199
  result = "";
1043
1200
  outcome = null;
1201
+ inputOwner = null;
1202
+ acceptedInput = null;
1203
+ clearFinalAssistantResult();
1044
1204
  },
1045
1205
  async send(client, message, { messageId: uuid, accept, timeoutMs = 30_000 } = {}) {
1046
1206
  if (!active || stopped || cancelling) throw failure("Claude has no active turn for this message.");
1047
1207
  if (!uuid || typeof accept !== "function" || admissions.has(uuid)) throw new TypeError("A distinct message id and admission writer are required.");
1208
+ clearFinalAssistantResult();
1209
+ acceptedInput = null;
1210
+ inputOwner = entry ? { commandId: uuid, process: entry.process, executionId: entry.executionId,
1211
+ accountIdentity: entry.accountIdentity } : null;
1212
+ const receiptOwner = inputOwner;
1048
1213
  const admitted = Promise.withResolvers();
1049
1214
  void admitted.promise.catch(() => {});
1050
1215
  inFlight.add(uuid);
@@ -1052,9 +1217,12 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1052
1217
  failure("Claude has not acknowledged this prompt. Its delivery is uncertain.", "assistant_claude_admission_unknown"),
1053
1218
  { delivery: "uncertain" }
1054
1219
  )), timeoutMs);
1055
- admissions.set(uuid, { ...admitted, accept, timer });
1220
+ admissions.set(uuid, { ...admitted, accept, timer, receiptOwner });
1056
1221
  try {
1057
1222
  await Promise.all([client.send(message, { messageId: uuid, sessionId: conversationId }), admitted.promise]);
1223
+ } catch (error) {
1224
+ if (inputOwner === receiptOwner) invalidateFinalAssistantResult();
1225
+ throw error;
1058
1226
  } finally {
1059
1227
  clearTimeout(timer);
1060
1228
  }
@@ -1064,6 +1232,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1064
1232
  // Streaming user input otherwise queues behind the entire running turn.
1065
1233
  // Claude's supported steering path interrupts generation, then continues
1066
1234
  // the same native history with the new instruction.
1235
+ acceptedInput = null;
1236
+ clearFinalAssistantResult();
1067
1237
  steering = Promise.withResolvers();
1068
1238
  let timer;
1069
1239
  try {
@@ -908,13 +908,17 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
908
908
 
909
909
  async function writeConversationProjection(sessionId, messages = null, {
910
910
  inputMessageId = "",
911
- streaming = false
911
+ streaming = false,
912
+ captureFinalAssistantResult = false
912
913
  } = {}, projection) {
913
914
  let failure = "";
915
+ let finalAssistantResult = null;
914
916
  let providerApiFailure = false;
915
917
  const rows = (inputMessageId
916
918
  ? openCodeRowsForInput(messages, inputMessageId)
917
919
  : openCodeMessageRows(messages)).filter((message) => message?.type === "assistant");
920
+ const finalNativeMessage = captureFinalAssistantResult && !streaming
921
+ ? openCodeLastAssistantResult(rows, { readError: projection.readError }).message : null;
918
922
  for (const [index, message] of rows.entries()) {
919
923
  failure ||= projection.readError(message);
920
924
  providerApiFailure ||= openCodeProviderApiFailure(message.error);
@@ -967,9 +971,31 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
967
971
  });
968
972
  projection.store.completeConversationStreamMessage(sessionId, messageId);
969
973
  await projection.publishTurn(turn);
974
+ if (message === finalNativeMessage && !projection.readError(message) &&
975
+ (message.time?.completed || message.finish) && turn) {
976
+ finalAssistantResult = { inputMessageId: text(inputMessageId), itemId: text(message.id),
977
+ text: assistantText, conversationTurn: turn };
978
+ }
970
979
  }
971
980
  }
972
- return { failure, providerApiFailure };
981
+ return { failure, providerApiFailure, ...(captureFinalAssistantResult ? { finalAssistantResult } : {}) };
982
+ }
983
+
984
+ // Evidence belongs to the exact retained native turn. This read never creates,
985
+ // observes, resumes or projects a native conversation.
986
+ function readFinalAssistantResult(key, threadId = "", turnId = "") {
987
+ const turn = turns.get(key);
988
+ const result = turn?.finalAssistantResult;
989
+ const target = turn?.finalAssistantResultTarget;
990
+ if (!target || processes.get(key) !== target || target.abortController.signal.aborted ||
991
+ !turn.finalAssistantResultProcess || sharedProcess !== turn.finalAssistantResultProcess ||
992
+ target.upstreamSessionId !== turn.threadId) return null;
993
+ if (!text(threadId) || !text(turnId) || !result || turn.active || turn.state !== "completed" ||
994
+ turn.error || turn.observationError || turn.interruptRequested || turn.interruptAcknowledged ||
995
+ turn.threadId !== text(threadId) || turn.id !== text(turnId) ||
996
+ result.threadId !== text(threadId) || result.turnId !== text(turnId) ||
997
+ result.inputMessageId !== text(turn.inputMessageId) || !result.conversationTurn) return null;
998
+ return structuredClone(result);
973
999
  }
974
1000
 
975
1001
  async function sendMessage(key, input, options, application) {
@@ -1232,6 +1258,15 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
1232
1258
  observe, observation, eventReady, readiness, finalResponse,
1233
1259
  writeRun, projectMessages, completeResult, onRetired
1234
1260
  }) {
1261
+ delete turn.finalAssistantResult;
1262
+ delete turn.finalAssistantResultTarget;
1263
+ delete turn.finalAssistantResultProcess;
1264
+ const registeredTarget = processes.get(key);
1265
+ const finalAssistantResultTarget = registeredTarget &&
1266
+ registeredTarget.abortController === target.abortController &&
1267
+ registeredTarget.server === target.server &&
1268
+ registeredTarget.upstreamSessionId === target.upstreamSessionId ? registeredTarget : null;
1269
+ const finalAssistantResultProcess = finalAssistantResultTarget ? sharedProcess : null;
1235
1270
  turns.set(key, turn);
1236
1271
  const signal = AbortSignal.any([target.abortController.signal, turn.abortController.signal]);
1237
1272
  const monitor = Promise.resolve().then(async () => {
@@ -1243,6 +1278,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
1243
1278
  }
1244
1279
  let finalState = "completed";
1245
1280
  let failure = "";
1281
+ let finalAssistantResult = null;
1246
1282
  let credentialFailure = false;
1247
1283
  let providerApiFailure = false;
1248
1284
  try {
@@ -1285,7 +1321,8 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
1285
1321
  continue;
1286
1322
  }
1287
1323
  const projection = await projectMessages(completion.messages, {
1288
- inputMessageId: completion.inputMessageId
1324
+ inputMessageId: completion.inputMessageId,
1325
+ captureFinalAssistantResult: true
1289
1326
  });
1290
1327
  await turn.admission?.promise;
1291
1328
  signal.throwIfAborted();
@@ -1303,6 +1340,13 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
1303
1340
  } else if (turn.interruptRequested) {
1304
1341
  finalState = "interrupted";
1305
1342
  }
1343
+ const published = projection.finalAssistantResult;
1344
+ if (finalState === "completed" && !failure && published &&
1345
+ published.inputMessageId === completion.inputMessageId &&
1346
+ published.itemId === completion.result?.turnId &&
1347
+ text(published.text) === text(completion.result?.text)) {
1348
+ finalAssistantResult = { ...published, threadId: turn.threadId, turnId: turn.id };
1349
+ }
1306
1350
  break;
1307
1351
  }
1308
1352
  } catch (error) {
@@ -1341,6 +1385,9 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
1341
1385
  }
1342
1386
  turn.error = failure;
1343
1387
  turn.state = finalState;
1388
+ turn.finalAssistantResult = finalState === "completed" && !failure ? finalAssistantResult : null;
1389
+ turn.finalAssistantResultTarget = turn.finalAssistantResult ? finalAssistantResultTarget : null;
1390
+ turn.finalAssistantResultProcess = turn.finalAssistantResult ? finalAssistantResultProcess : null;
1344
1391
  turn.updatedAt = new Date().toISOString();
1345
1392
  await writeRun(turn, finalState, failure).catch(() => null);
1346
1393
  }
@@ -1615,7 +1662,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
1615
1662
  ensurePreparedSessionReadiness, inspectSessionReadiness, recoverSessionReadiness, needsObservationRecovery, recoverObservation,
1616
1663
  createConversation, selectConversation, existingConversation, runConversationTurn, readConversation, readPersistentConversation, waitForConversationTurn,
1617
1664
  stopConversation, deleteConversation, sendMessage, hasActiveTemporaryConversation, reconcileSessions, reconcilePreparedSession, recordStoppedObservation, beginMessageMonitor, beginMonitor, writeBindings,
1618
- writeConversationProjection,
1665
+ writeConversationProjection, readFinalAssistantResult,
1619
1666
  projectConversationMessages: projectOpenCodeConversationMessages,
1620
1667
  readSessionState, startPreparedTerminal, attachTerminal, closeTerminal, allowTerminalAttachments,
1621
1668
  turnSnapshot: openCodeTurnSnapshot, providerApiFailure: openCodeProviderApiFailure
@@ -910,7 +910,12 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
910
910
  // Calls already in flight retain their actor and turn. Keep application
911
911
  // mutations ordered across steering as well as within each request.
912
912
  const work = toolWork.then(() => request.tools.execute(input, options));
913
- toolWork = work.catch(error => { toolFailure ||= error; });
913
+ toolWork = work.catch(error => {
914
+ // Claude accepts a separate successor query; its predecessor's
915
+ // effect cannot fail that query. Other engines retain the original
916
+ // active-turn failure guard across steering.
917
+ if (engine !== "claude" || current === request) toolFailure ||= error;
918
+ });
914
919
  return work;
915
920
  }
916
921
  };