@jskit-ai/assistant-core 0.1.223 → 0.1.224

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.
@@ -71,9 +71,17 @@ export async function runClaudeRenewalTurn(conversation, {
71
71
  let result;
72
72
  try {
73
73
  if (accepted) {
74
- result = { text: history.messages.filter((message) => message.userId === uuid && message.role === "assistant").map((message) => message.text).join("\n"),
75
- threadId: entry.id, turnId: uuid, reconciled: true };
76
- if (!result.text) throw createError("The accepted Claude renewal turn has no readable result; it will not be submitted again.", unreadableCode);
74
+ if (entry.turn?.id === uuid && entry.turn.active && entry.process) {
75
+ result = await conversation.wait({ conversationId: entry.id, timeoutMs });
76
+ if (result.status !== "completed") throw createError(result.error || "Claude did not complete the renewal turn.", failedCode);
77
+ } else {
78
+ if (entry.turn?.id !== uuid || entry.turn.state !== "completed" || entry.turn.active) {
79
+ throw createError("The accepted Claude renewal turn has no confirmed completion; it will not be submitted again.", unreadableCode);
80
+ }
81
+ result = { text: history.messages.filter((message) => message.userId === uuid && message.role === "assistant").map((message) => message.text).join("\n"),
82
+ threadId: entry.id, turnId: uuid, reconciled: true };
83
+ if (!result.text) throw createError("The accepted Claude renewal turn has no readable result; it will not be submitted again.", unreadableCode);
84
+ }
77
85
  } else {
78
86
  if (entry.turn?.active && entry.process) throw createError("Stop the current turn before renewing this conversation.");
79
87
  await conversation.send({ message: prompt, messageId: clientMessageId, outputSchema }, { renewal: true });
@@ -146,6 +154,7 @@ export function createClaudeConversationOwner({
146
154
 
147
155
  function snapshot(entry) {
148
156
  return entry?.turn ? { ...entry.turn, threadId: entry.id,
157
+ ...(entry.turn.active && entry.observationError ? { status: "observation_lost", error: entry.observationError } : {}),
149
158
  phase: entry.turn.active && !entry.observationError ? text(entry.turn.phase) : "" } : null;
150
159
  }
151
160
 
@@ -204,7 +213,7 @@ export function createClaudeConversationOwner({
204
213
  if (!main && !saved && !create && !recoveringCleanup) throw createError("This Claude conversation is unavailable.");
205
214
  const state = saved ? JSON.parse(saved) : {};
206
215
  const entry = { id, key, main, persistent: state.persistent === true, context, process: null, startupCleanup: null,
207
- executionId: state.executionId || (recoveringCleanup ? cleanupExecutionId : ""),
216
+ executionId: state.executionId || (recoveringCleanup ? cleanupExecutionId : ""), preparationRevision: 0,
208
217
  accountIdentities: state.accountIdentities || (state.accountIdentity ? { anthropic: state.accountIdentity } : {}),
209
218
  accountIdentity: state.accountIdentity || "", sent: state.sent === true, nativeWorkdir: state.nativeWorkdir || nativeWorkdir,
210
219
  lastMessageId: state.lastMessageId || "", turn: state.turnId ? {
@@ -219,7 +228,8 @@ export function createClaudeConversationOwner({
219
228
  getProcess: () => entry.stopping ? null : entry.process,
220
229
  isActive: () => store.releaseExecution ? entry.nativeTurn.read().active : Boolean(entry.turn?.active),
221
230
  startProcess: input => startProcess(entry, input),
222
- stopProcess: reason => entry.process || entry.executionId ? entry.nativeTurn.stopProcess(reason) : Promise.resolve()
231
+ stopProcess: reason => entry.process || entry.executionId
232
+ ? entry.nativeTurn.stopProcess(reason, { preparing: true }) : Promise.resolve()
223
233
  }
224
234
  });
225
235
  entry.goals = createClaudeConversationGoals({ conversationId: id,
@@ -255,7 +265,7 @@ export function createClaudeConversationOwner({
255
265
  if (entry.accountIdentity !== identity) entry.nativeTurn.invalidateFinalAssistantResult();
256
266
  try {
257
267
  await bindClaudeConversationAccount(entry, { identity, providerId,
258
- stop: (entry, reason) => entry.nativeTurn.stopProcess(reason), save: saveAccount });
268
+ stop: (entry, reason) => entry.nativeTurn.stopProcess(reason, { preparing: true }), save: saveAccount });
259
269
  } catch (failure) {
260
270
  if (failure.code !== "claude_account_changed") throw failure;
261
271
  throw createError(failure.message, account.changedCode || failure.code);
@@ -273,12 +283,17 @@ export function createClaudeConversationOwner({
273
283
  }
274
284
 
275
285
  async function ensureProcess(entry, input = {}) {
276
- if (preparation.checkConfiguration) {
277
- const application = preparation.checkConfiguration(entry, input);
278
- if (closing || closingSessions.has(entry.context.key) || application?.sessionClosing) throw createError("This session is closing.");
286
+ const revision = entry.preparationRevision;
287
+ const application = preparation.checkConfiguration?.(entry, input);
288
+ if (entry.disposed || closing || closingSessions.has(entry.context.key) || application?.sessionClosing) {
289
+ throw createError("This session is closing.");
279
290
  }
280
291
  if (preparation.account) await bindAccount(entry);
281
- return entry.nativeTurn.prepare(await preparation.configuration(entry, input));
292
+ const configuration = await preparation.configuration(entry, input);
293
+ if (revision !== entry.preparationRevision || entry.disposed || closing || closingSessions.has(entry.context.key)) {
294
+ throw createError("Claude preparation was stopped while this session was closing or changing.");
295
+ }
296
+ return entry.nativeTurn.prepare(configuration);
282
297
  }
283
298
 
284
299
  async function stopForTerminal(entry, application) {
@@ -301,6 +316,13 @@ export function createClaudeConversationOwner({
301
316
 
302
317
  async function startProcess(entry, configuration) {
303
318
  const context = entry.context;
319
+ const revision = entry.preparationRevision;
320
+ const assertPreparing = () => {
321
+ if (revision !== entry.preparationRevision || entry.disposed || closing || closingSessions.has(context.key)) {
322
+ throw createError("Claude preparation was stopped while this session was closing or changing.");
323
+ }
324
+ };
325
+ assertPreparing();
304
326
  let prepared = process.prepare ? process.prepare(entry, configuration, context) : { workdir: entry.nativeWorkdir };
305
327
  if (prepared && typeof prepared.then === "function") prepared = await prepared;
306
328
  entry.nativeWorkdir = prepared.workdir;
@@ -310,14 +332,17 @@ export function createClaudeConversationOwner({
310
332
  entry.profile = configuration.profile;
311
333
  let options = process.configure(entry, configuration, { context, prepared });
312
334
  if (options && typeof options.then === "function") options = await options;
335
+ assertPreparing();
313
336
  try { entry.process = await createProcess({ ...options,
314
337
  workdir: entry.nativeWorkdir, sessionId: entry.id, resume: entry.sent,
315
338
  onStarted: async (executionId, stop) => {
316
339
  if (typeof stop === "function") entry.startupCleanup = { executionId, stop };
317
- if (entry.executionId === executionId) return;
318
- entry.executionId = executionId;
319
- await store.save(entry, { execution: true });
320
- await onEvent(entry, { type: "execution", context, executionId });
340
+ if (entry.executionId !== executionId) {
341
+ entry.executionId = executionId;
342
+ await store.save(entry, { execution: true });
343
+ await onEvent(entry, { type: "execution", context, executionId });
344
+ }
345
+ assertPreparing();
321
346
  },
322
347
  async onEvent(frame) {
323
348
  const current = entry.command;
@@ -388,6 +413,11 @@ export function createClaudeConversationOwner({
388
413
  }
389
414
  throw error;
390
415
  }
416
+ try { assertPreparing(); }
417
+ catch (error) {
418
+ await entry.nativeTurn.stopProcess(error.message);
419
+ throw error;
420
+ }
391
421
  if (entry.startupCleanup?.executionId === entry.executionId) entry.startupCleanup = null;
392
422
  return entry.process;
393
423
  }
@@ -624,9 +654,13 @@ export function createClaudeConversationOwner({
624
654
  }
625
655
 
626
656
  async function drain(key) {
627
- await Promise.allSettled([...entries.values()].filter(entry => entry.context.key === key).map(entry => entry.nativeTurn.whenIdle()));
657
+ const selected = [...entries.values()].filter(entry => entry.context.key === key);
658
+ // Startup already lends cleanup before initialization completes. Stop it
659
+ // first; waiting for the preparation queue first can exhaust host shutdown.
660
+ for (const entry of selected) await entry.nativeTurn.stopProcess();
661
+ await Promise.allSettled(selected.map(entry => entry.nativeTurn.whenIdle()));
628
662
  const proofs = [];
629
- for (const entry of entries.values()) if (entry.context.key === key) proofs.push(await entry.nativeTurn.stopProcess());
663
+ for (const entry of selected) proofs.push(await entry.nativeTurn.stopProcess());
630
664
  return proofs;
631
665
  }
632
666
 
@@ -637,6 +671,8 @@ export function createClaudeConversationOwner({
637
671
  async function close(entry) {
638
672
  await entry.nativeTurn.stopProcess("Conversation closed.");
639
673
  entry.disposed = true;
674
+ await entry.nativeTurn.whenIdle().catch(() => {});
675
+ await entry.nativeTurn.stopProcess("Conversation closed.");
640
676
  if (entries.get(entry.key) === entry) entries.delete(entry.key);
641
677
  }
642
678
 
@@ -850,7 +886,11 @@ export function createClaudeConversationOwner({
850
886
  await cancellation?.catch(() => {});
851
887
  if (active.entry) {
852
888
  try {
853
- if (problem || signal.aborted) await active.entry.nativeTurn.stopProcess(problem?.message || "Work stopped.");
889
+ // A failed Stop already retained custody for the caller's retry.
890
+ // Do not race that retry with another implicit cleanup attempt.
891
+ if ((problem || signal.aborted) && !problem?.cleanupFailed) {
892
+ await active.entry.nativeTurn.stopProcess(problem?.message || "Work stopped.");
893
+ }
854
894
  } catch (error) {
855
895
  if (problem && error !== problem) error.cause ||= problem;
856
896
  problem = error;
@@ -880,6 +920,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
880
920
  const instructions = process ? createClaudeConversationAdapter(process) : null;
881
921
  const publish = entry ? publishEntryEvent : onEvent;
882
922
  const messages = new Map();
923
+ const pendingAssistantMessages = new Map();
924
+ const assistantRoles = new Map();
883
925
  const admissions = new Map();
884
926
  const inFlight = new Set();
885
927
  const tasks = new Set();
@@ -890,6 +932,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
890
932
  let phase = "";
891
933
  let outcome;
892
934
  let active = false;
935
+ let generation = 0;
893
936
  let stopped = false;
894
937
  let cancelling = false;
895
938
  let steering;
@@ -898,6 +941,11 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
898
941
  let acceptedInput = null;
899
942
  let publishedAssistant = null;
900
943
  let finalAssistantResult = null;
944
+ let resolvedCompletion;
945
+ let latestSettlement;
946
+ // A common command fences its first checkpoint; later native continuations
947
+ // keep the same input but cannot enqueue onto that already resolved command.
948
+ let checkpointedCommand;
901
949
 
902
950
  function clearFinalAssistantResult() {
903
951
  publishedAssistant = null;
@@ -945,17 +993,21 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
945
993
 
946
994
  function resolveCompletion(result) {
947
995
  entry.completion?.resolve(result);
996
+ resolvedCompletion = entry.completion;
948
997
  if (entry.command?.finishIfCurrent) entry.command.completion.resolve(result);
949
998
  }
950
999
 
951
- async function updateState(state, message = "") {
952
- if (!entry.turn) return;
1000
+ async function updateState(state, message = "", expectedGeneration = generation) {
1001
+ if (!entry.turn || expectedGeneration !== generation) return false;
953
1002
  const active = ["starting", "active", "finalizing"].includes(state);
954
1003
  const checkpoint = onEvent({ type: "before-state", state, active, message });
955
1004
  if (checkpoint) await checkpoint;
1005
+ if (expectedGeneration !== generation) return false;
956
1006
  setState(state, message);
957
1007
  await onEvent({ type: "save" });
1008
+ if (expectedGeneration !== generation) return false;
958
1009
  await onEvent({ type: "state", state, active, message });
1010
+ return expectedGeneration === generation;
959
1011
  }
960
1012
 
961
1013
  function setState(state, message = "") {
@@ -965,12 +1017,13 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
965
1017
  phase: active && !entry.observationError ? String(entry.turn.phase ?? "").trim() : "" });
966
1018
  }
967
1019
 
968
- async function stopProcess(reason = "") {
1020
+ async function stopProcess(reason = "", { preparing = false } = {}) {
1021
+ if (!preparing) entry.preparationRevision++;
969
1022
  invalidateFinalAssistantResult();
970
1023
  // Runtime bindings commit execution release before dropping the handle.
971
1024
  // Retained host entries keep their original post-proof checkpoint order.
972
1025
  const binding = Boolean(process.releaseExecution);
973
- if (binding && entry.stopPending) return entry.stopPending;
1026
+ if (entry.stopPending) return entry.stopPending;
974
1027
  const active = read().active;
975
1028
  if (!binding) entry.stopping = true;
976
1029
  const operation = async () => {
@@ -1015,39 +1068,50 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1015
1068
  if (entry.startupCleanup === startup) entry.startupCleanup = null;
1016
1069
  return entry.exitProof;
1017
1070
  };
1018
- if (!binding) return operation();
1019
- entry.stopPending = operation().catch(error => { error.cleanupFailed = true; throw error; })
1071
+ entry.stopPending = operation().catch(error => { if (binding) error.cleanupFailed = true; throw error; })
1020
1072
  .finally(() => { entry.stopPending = null; });
1021
1073
  return entry.stopPending;
1022
1074
  }
1023
1075
 
1024
1076
  async function publishEntryEvent(event, deferred = false) {
1025
1077
  if (entry.stopping) return;
1078
+ if (event.type === "settled" && event.generation !== generation) return;
1026
1079
  const binding = Boolean(process.releaseExecution);
1027
1080
  const command = entry.command;
1028
- if (event.type === "settled" && !binding && !deferred && command?.finishIfCurrent) {
1081
+ if (event.type === "settled" && !binding && !deferred && command?.finishIfCurrent && command !== checkpointedCommand) {
1029
1082
  const delivery = command.delivery;
1030
1083
  const completion = entry.completion;
1031
1084
  const owner = acceptedInput;
1032
1085
  // Release the native queue before the common run joins reserved steering.
1033
1086
  // That same accepted delivery then performs the original retained checkpoint.
1034
1087
  command.completion.resolve(async () => {
1035
- if (entry.command !== command || command.delivery !== delivery || entry.completion !== completion ||
1036
- command.signal.aborted || !currentReceiptOwner(owner)) {
1037
- clearFinalAssistantResult();
1038
- throw failure("Claude's completion belongs to a retired native input.");
1039
- }
1040
1088
  try {
1041
- await publishEntryEvent(event, true);
1042
- return readResult();
1089
+ for (;;) {
1090
+ if (entry.command !== command || command.delivery !== delivery || entry.completion !== completion ||
1091
+ command.signal.aborted || !currentReceiptOwner(owner)) {
1092
+ throw failure("Claude's completion belongs to a retired native input.");
1093
+ }
1094
+ // Follow-ups share this input, but can finish while its checkpoint
1095
+ // awaits storage. Apply the latest settlement after the same fence.
1096
+ if (active) return readResult();
1097
+ const settlement = latestSettlement;
1098
+ await publishEntryEvent(settlement, true);
1099
+ if (settlement === latestSettlement) return readResult();
1100
+ }
1043
1101
  } catch (error) {
1044
1102
  clearFinalAssistantResult();
1045
1103
  throw error;
1104
+ } finally {
1105
+ checkpointedCommand = command;
1046
1106
  }
1047
1107
  });
1048
1108
  return;
1049
1109
  }
1050
- if (event.type === "admitted") {
1110
+ if (event.type === "continued") {
1111
+ if (entry.completion === resolvedCompletion) entry.completion = Promise.withResolvers();
1112
+ if (binding) setState("active");
1113
+ else await updateState("active");
1114
+ } else if (event.type === "admitted") {
1051
1115
  if (event.userEcho) entry.sent = true;
1052
1116
  if (!binding) await onEvent({ type: "save" });
1053
1117
  } else if (event.type === "phase") {
@@ -1056,7 +1120,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1056
1120
  } else if (event.type === "settled") {
1057
1121
  const state = event.status === "interrupted" ? "interrupted" : event.status === "failed" ? "failed" : "completed";
1058
1122
  if (binding) setState(state, event.error);
1059
- else await updateState(state, event.error);
1123
+ else if (!await updateState(state, event.error, event.generation)) return;
1060
1124
  }
1061
1125
  const published = await onEvent(event);
1062
1126
  if (event.type === "provider-event") {
@@ -1065,6 +1129,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1065
1129
  if (change) await onEvent({ type: "provider-update", change });
1066
1130
  }
1067
1131
  if (event.type === "settled") {
1132
+ if (event.generation !== generation) return;
1068
1133
  if (event.source === "result") await onEvent({ type: "provider-update", change: "goal" });
1069
1134
  resolveCompletion(binding ? event : readResult());
1070
1135
  }
@@ -1091,11 +1156,38 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1091
1156
  return conversationTurn;
1092
1157
  }
1093
1158
 
1159
+ async function flushAssistantMessages(apiMessageId, role) {
1160
+ assistantRoles.set(apiMessageId, role);
1161
+ const pending = pendingAssistantMessages.get(apiMessageId) || [];
1162
+ pendingAssistantMessages.delete(apiMessageId);
1163
+ for (const block of pending) {
1164
+ await publishMessage({ ...block, role });
1165
+ if (block.outputId) {
1166
+ if (block.outputId !== block.id) messages.delete(block.outputId);
1167
+ await publish({ type: "message-complete", messageId: block.outputId });
1168
+ }
1169
+ }
1170
+ }
1171
+
1094
1172
  async function receive(frame) {
1095
1173
  if (stopped) return;
1096
1174
  if (frame.session_id && frame.session_id !== conversationId) throw failure("Claude returned a different conversation id.");
1097
1175
  if (frame.parent_tool_use_id) return; // Nested agents do not replace the main reply.
1098
1176
  await publish({ type: "provider-event", event: frame });
1177
+ const continuation = (frame.type === "user" && frame.origin?.kind === "task-notification") ||
1178
+ (frame.type === "command_lifecycle" && frame.state === "started") ||
1179
+ (frame.type === "stream_event" && frame.event?.type === "message_start") || frame.type === "assistant";
1180
+ if (continuation && turnId && !cancelling) {
1181
+ const wasActive = active;
1182
+ if (outcome || !active) {
1183
+ generation++;
1184
+ outcome = null;
1185
+ result = "";
1186
+ clearFinalAssistantResult();
1187
+ }
1188
+ active = true;
1189
+ if (!wasActive) await publish({ type: "continued", source: frame.type });
1190
+ }
1099
1191
  if (frame.type === "command_lifecycle" || frame.type === "user") {
1100
1192
  const uuid = frame.type === "user" ? frame.uuid : frame.command_uuid;
1101
1193
  if (frame.type === "user" && !inFlight.has(uuid) && !admissions.has(uuid)) return;
@@ -1135,9 +1227,23 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1135
1227
  if (frame.subtype === "local_command_output" && frame.content) {
1136
1228
  await publishMessage({ id: `claude_${frame.uuid}`, role: "commentary", text: frame.content, complete: true });
1137
1229
  }
1138
- if (frame.subtype === "task_started" && ["local_agent", "local_workflow"].includes(frame.task_type)) tasks.add(frame.task_id);
1230
+ if (frame.subtype === "background_tasks_changed") {
1231
+ tasks.clear();
1232
+ for (const task of frame.tasks || []) if (task.task_id && !task.ambient) tasks.add(task.task_id);
1233
+ }
1234
+ if (frame.subtype === "task_started" && frame.task_id && !frame.ambient) tasks.add(frame.task_id);
1139
1235
  if (["task_notification", "task_updated"].includes(frame.subtype) &&
1140
1236
  ["completed", "failed", "stopped", "killed"].includes(frame.status || frame.patch?.status)) tasks.delete(frame.task_id);
1237
+ if (["background_tasks_changed", "task_started", "task_notification", "task_updated"].includes(frame.subtype)) {
1238
+ if (tasks.size && turnId && !active && !cancelling) {
1239
+ generation++;
1240
+ active = true;
1241
+ clearFinalAssistantResult();
1242
+ await publish({ type: "continued", source: frame.subtype });
1243
+ }
1244
+ await publish({ type: "diagnostic", stage: "background_tasks", nativeEvent: frame.subtype,
1245
+ active, pendingCommands: inFlight.size, backgroundTasks: tasks.size });
1246
+ }
1141
1247
  } else if (frame.type === "stream_event") {
1142
1248
  const event = frame.event || {};
1143
1249
  if (event.type === "message_start") messageId = event.message?.id || frame.uuid;
@@ -1155,10 +1261,18 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1155
1261
  await publish({ type: block.role === "thinking" ? "thinking" : "text", text: delta, messageId: id });
1156
1262
  }
1157
1263
  } else if (event.type === "content_block_stop") {
1158
- messages.delete(id);
1159
- await publish({ type: "message-complete", messageId: id });
1264
+ if (![...pendingAssistantMessages.values()].some(blocks => blocks.some(block => block.outputId === id))) {
1265
+ messages.delete(id);
1266
+ await publish({ type: "message-complete", messageId: id });
1267
+ }
1268
+ } else if (event.type === "message_delta" && event.delta?.stop_reason) {
1269
+ await flushAssistantMessages(messageId, event.delta.stop_reason === "tool_use" ? "commentary" : "assistant");
1160
1270
  }
1161
1271
  } else if (frame.type === "assistant") {
1272
+ const apiMessageId = frame.message?.id;
1273
+ if (apiMessageId && frame.message.content?.some(block => block.type === "tool_use")) {
1274
+ await flushAssistantMessages(apiMessageId, "commentary");
1275
+ }
1162
1276
  for (const block of claudeMessageBlocks(frame)) {
1163
1277
  checkOutput(block.text, block.role);
1164
1278
  // Native split frames arrive before their block's stop event. Keep that
@@ -1171,12 +1285,26 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1171
1285
  Number.isSafeInteger(frame.apiBlockIndex) && frame.apiBlockIndex >= 0 &&
1172
1286
  live[0].id === `${prefix}${frame.apiBlockIndex}`)) block.outputId = live[0].id;
1173
1287
  }
1174
- await publishMessage(block);
1288
+ if (apiMessageId && block.role === "assistant") {
1289
+ const role = assistantRoles.get(apiMessageId);
1290
+ if (role) await publishMessage({ ...block, role });
1291
+ else {
1292
+ const pending = pendingAssistantMessages.get(apiMessageId) || [];
1293
+ pending.push(block);
1294
+ pendingAssistantMessages.set(apiMessageId, pending);
1295
+ }
1296
+ } else await publishMessage(block);
1297
+ }
1298
+ if (apiMessageId && frame.message.stop_reason) {
1299
+ await flushAssistantMessages(apiMessageId, frame.message.stop_reason === "tool_use" ? "commentary" : "assistant");
1175
1300
  }
1176
1301
  } else if (frame.type === "result") {
1302
+ const failed = frame.is_error || frame.subtype !== "success";
1303
+ const interrupted = ["aborted_streaming", "aborted_tools"].includes(frame.terminal_reason);
1304
+ const role = failed || interrupted ? "commentary" : "assistant";
1305
+ for (const id of pendingAssistantMessages.keys()) await flushAssistantMessages(id, role);
1177
1306
  const output = typeof frame.structured_output === "object" ? JSON.stringify(frame.structured_output) : String(frame.result || "");
1178
1307
  checkOutput(output);
1179
- const failed = frame.is_error || frame.subtype !== "success";
1180
1308
  const message = failed ? (frame.errors || []).join("; ") || output || frame.subtype : "";
1181
1309
  result = failed ? "" : output;
1182
1310
  if (result && ![...messages.values()].some((block) => block.complete && block.role === "assistant" && block.text === result)) {
@@ -1188,11 +1316,12 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1188
1316
  return;
1189
1317
  }
1190
1318
  if (cancelling) return;
1191
- const interrupted = ["aborted_streaming", "aborted_tools"].includes(frame.terminal_reason);
1192
1319
  outcome = { status: interrupted ? "interrupted" : failed ? "failed" : "completed", error: message };
1320
+ await publish({ type: "diagnostic", stage: "native_result", status: outcome.status,
1321
+ terminalReason: frame.terminal_reason, active, pendingCommands: inFlight.size, backgroundTasks: tasks.size });
1193
1322
  }
1194
1323
  if ((frame.type === "system" || frame.type === "result") &&
1195
- outcome && inFlight.size === 0 && tasks.size === 0) {
1324
+ active && !cancelling && outcome && inFlight.size === 0 && tasks.size === 0) {
1196
1325
  active = false;
1197
1326
  phase = "";
1198
1327
  const candidate = publishedAssistant;
@@ -1201,12 +1330,14 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1201
1330
  ? { threadId: conversationId, turnId, inputMessageId: candidate.owner.commandId,
1202
1331
  itemId: candidate.itemId, outputId: candidate.outputId, text: candidate.text,
1203
1332
  conversationTurn: candidate.conversationTurn } : null;
1204
- try { await publish({ type: "settled", ...read(), source: frame.type }); }
1333
+ latestSettlement = { type: "settled", ...read(), generation, source: frame.type };
1334
+ try { await publish(latestSettlement); }
1205
1335
  catch (error) { clearFinalAssistantResult(); throw error; }
1206
1336
  }
1207
1337
  }
1208
1338
 
1209
1339
  function stop(reason = "Claude stopped before acknowledging the prompt.") {
1340
+ generation++;
1210
1341
  stopped = true;
1211
1342
  inputOwner = null;
1212
1343
  acceptedInput = null;
@@ -1217,6 +1348,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1217
1348
  admissions.clear();
1218
1349
  inFlight.clear();
1219
1350
  tasks.clear();
1351
+ pendingAssistantMessages.clear();
1352
+ assistantRoles.clear();
1220
1353
  steering?.reject(failure(reason));
1221
1354
  if (active) outcome = { status: "interrupted", error: reason };
1222
1355
  active = false;
@@ -1242,12 +1375,15 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1242
1375
  begin({ id, maxOutputCharacters = 4 * 1024 * 1024 } = {}) {
1243
1376
  if (active) throw failure("This Claude turn is still working.");
1244
1377
  if (!id || !Number.isSafeInteger(maxOutputCharacters) || maxOutputCharacters < 1) throw new TypeError("Invalid Claude turn configuration.");
1378
+ generation++;
1245
1379
  inputOwner = null;
1246
1380
  acceptedInput = null;
1247
1381
  clearFinalAssistantResult();
1248
1382
  turnId = id;
1249
1383
  maximumOutput = maxOutputCharacters;
1250
1384
  messages.clear();
1385
+ pendingAssistantMessages.clear();
1386
+ assistantRoles.clear();
1251
1387
  result = "";
1252
1388
  phase = "";
1253
1389
  messageId = "";
@@ -1260,6 +1396,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1260
1396
  reset() {
1261
1397
  if (active || inFlight.size || tasks.size) throw failure("Stop Claude before resetting its turn view.");
1262
1398
  messages.clear();
1399
+ pendingAssistantMessages.clear();
1400
+ assistantRoles.clear();
1263
1401
  result = "";
1264
1402
  outcome = null;
1265
1403
  inputOwner = null;
@@ -1293,6 +1431,12 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1293
1431
  },
1294
1432
  async interruptGeneration(client, { timeoutMs = 30_000 } = {}) {
1295
1433
  if (!active || steering || cancelling) throw failure("This Claude turn cannot accept steering now.");
1434
+ // A completed main loop can still own background work. There is no
1435
+ // generation left to interrupt or second terminal result to wait for.
1436
+ if (outcome && inFlight.size === 0) {
1437
+ await publish({ type: "diagnostic", stage: "steering_background_only", backgroundTasks: tasks.size });
1438
+ return;
1439
+ }
1296
1440
  // Streaming user input otherwise queues behind the entire running turn.
1297
1441
  // Claude's supported steering path interrupts generation, then continues
1298
1442
  // the same native history with the new instruction.
@@ -1301,12 +1445,17 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1301
1445
  steering = Promise.withResolvers();
1302
1446
  let timer;
1303
1447
  try {
1448
+ await publish({ type: "diagnostic", stage: "steering_requested", pendingCommands: inFlight.size, backgroundTasks: tasks.size });
1304
1449
  await Promise.all([client.interrupt(), Promise.race([
1305
1450
  steering.promise,
1306
1451
  new Promise((_, reject) => {
1307
1452
  timer = setTimeout(() => reject(failure("Claude did not confirm the interrupted generation.")), timeoutMs);
1308
1453
  })
1309
1454
  ])]);
1455
+ await publish({ type: "diagnostic", stage: "steering_confirmed", pendingCommands: inFlight.size, backgroundTasks: tasks.size });
1456
+ } catch (error) {
1457
+ await publish({ type: "diagnostic", stage: "steering_failed", pendingCommands: inFlight.size, backgroundTasks: tasks.size });
1458
+ throw error;
1310
1459
  }
1311
1460
  finally { clearTimeout(timer); steering = null; }
1312
1461
  },
@@ -1320,6 +1320,7 @@ export class CodexAppServerAgentProvider {
1320
1320
  this.connectPromise = null;
1321
1321
  this.connectionGeneration = 0;
1322
1322
  this.notificationSubscribers = new Set();
1323
+ this.settingsWaiters = new Set();
1323
1324
  this.helperAuth = null;
1324
1325
  this.helperAuthBlocked = false;
1325
1326
  this.initializeResult = null;
@@ -1335,7 +1336,7 @@ export class CodexAppServerAgentProvider {
1335
1336
  runRequest: (run, reason) => this.runRequest(run, reason),
1336
1337
  timeoutMs: options.threadControlTimeoutMs,
1337
1338
  environment: options.threadEnv,
1338
- prepareEnvironment: (environment) => this.options.prepareThreadEnvironment(environment),
1339
+ prepareEnvironment: (environment) => this.options.prepareThreadEnvironment?.(environment) || environment,
1339
1340
  prepareParameters: (params) => this.options.prepareThreadParams?.(params),
1340
1341
  prepareResume: (id, params, context) => this.options.prepareThreadResumeParams?.(id, params, context),
1341
1342
  beforeResume: (id) => this.options.beforeResumeThread?.(id),
@@ -1353,6 +1354,7 @@ export class CodexAppServerAgentProvider {
1353
1354
  for (const pending of probe.pending) this.publishNotification(pending);
1354
1355
  }
1355
1356
  },
1357
+ publishGoal: (threadId, goal) => this.publishNotification({ method: "thread/goal/updated", params: { threadId, goal } }),
1356
1358
  log: (event, fields, context) => host.log?.(event, fields, {
1357
1359
  ...context, connectionGeneration: this.connectionGeneration
1358
1360
  })
@@ -1707,7 +1709,10 @@ export class CodexAppServerAgentProvider {
1707
1709
  const client = new CodexAppServerJsonRpcClient({
1708
1710
  endpoint: runtime.endpoint,
1709
1711
  onDisconnect: (error) => {
1710
- if (this.client === client) this.failObservation(error);
1712
+ if (this.client === client) {
1713
+ for (const reject of this.settingsWaiters) reject(error);
1714
+ this.failObservation(error);
1715
+ }
1711
1716
  },
1712
1717
  ...(this.isHelperProvider()
1713
1718
  ? { maxMessageBytes: CODEX_APP_SERVER_HELPER_MAX_MESSAGE_BYTES }
@@ -1793,6 +1798,10 @@ export class CodexAppServerAgentProvider {
1793
1798
 
1794
1799
  publishNotification(notification) {
1795
1800
  const threadId = notification.params?.threadId;
1801
+ if (notification.method === "thread/goal/updated") {
1802
+ notification = { ...notification, params: { ...notification.params,
1803
+ goal: this.conversationRuntime.projectGoal(threadId, notification.params.goal) } };
1804
+ }
1796
1805
  const turnId = notification.params?.turnId;
1797
1806
  const item = notification.params?.item;
1798
1807
  if (item?.type === "commandExecution" && item.processId && item.source === "unifiedExecStartup") {
@@ -2209,10 +2218,63 @@ export class CodexAppServerAgentProvider {
2209
2218
 
2210
2219
  async readGoal(threadId = "", { signal } = {}) {
2211
2220
  const client = await this.activeClient();
2212
- return this.runRequest(
2221
+ const result = await this.runRequest(
2213
2222
  () => client.request("thread/goal/get", { threadId: normalizeAgentText(threadId) }, { signal }),
2214
2223
  "codex-app-server-goal-read"
2215
2224
  );
2225
+ return { ...result, goal: this.conversationRuntime.projectGoal(threadId, result.goal) };
2226
+ }
2227
+
2228
+ async updateThreadSettings(threadId, { model, effort } = {}, { signal } = {}) {
2229
+ if (!normalizeAgentText(threadId) || !normalizeAgentText(model)) {
2230
+ throw new TypeError("Codex settings require an existing thread and a model.");
2231
+ }
2232
+ const client = await this.activeClient();
2233
+ threadId = normalizeAgentText(threadId);
2234
+ const requested = { model, effort, ...(effort ? { config: { model_reasoning_effort: effort } } : {}) };
2235
+ const params = await this.options.prepareThreadParams?.(requested) || requested;
2236
+ const update = async selected => {
2237
+ const current = await this.activeClient();
2238
+ const applied = Promise.withResolvers();
2239
+ // A successful RPC only queues the update. Observe its applied snapshot
2240
+ // before resuming a goal or accepting a dependent settings operation.
2241
+ const deadline = AbortSignal.any([AbortSignal.timeout(this.options.requestTimeoutMs || 10_000), ...(signal ? [signal] : [])]);
2242
+ const abort = () => applied.reject(deadline.reason);
2243
+ const unsubscribe = current.subscribe(notification => {
2244
+ const settings = notification.params?.threadSettings;
2245
+ if (notification.method === "thread/settings/updated" && notification.params?.threadId === threadId &&
2246
+ settings?.model === selected.model && (!selected.effort || settings.effort === selected.effort)) applied.resolve();
2247
+ });
2248
+ this.settingsWaiters.add(applied.reject);
2249
+ deadline.addEventListener("abort", abort, { once: true });
2250
+ void applied.promise.catch(() => {});
2251
+ try {
2252
+ deadline.throwIfAborted();
2253
+ const { thread } = await this.runRequest(() => current.request("thread/read", {
2254
+ threadId, includeTurns: false
2255
+ }, { signal: deadline }), "codex-app-server-settings-read");
2256
+ // Native no-op updates may emit no settings event. A current snapshot
2257
+ // also confirms values already applied by the provider reload.
2258
+ if (thread?.model === selected.model && (!selected.effort || thread.reasoningEffort === selected.effort) &&
2259
+ (!selected.modelProvider || thread.modelProvider === selected.modelProvider)) return {};
2260
+ const result = await this.runRequest(() => current.request("thread/settings/update", {
2261
+ threadId, model: selected.model, ...(selected.effort ? { effort: selected.effort } : {})
2262
+ }, { signal: deadline }), "codex-app-server-settings-update");
2263
+ await applied.promise;
2264
+ return result;
2265
+ } finally {
2266
+ unsubscribe();
2267
+ deadline.removeEventListener("abort", abort);
2268
+ this.settingsWaiters.delete(applied.reject);
2269
+ }
2270
+ };
2271
+ if (params.modelProvider || this.conversationRuntime.hasPendingSettings(threadId)) {
2272
+ const { thread } = await client.request("thread/read", { threadId, includeTurns: false }, { signal });
2273
+ if (params.modelProvider !== thread?.modelProvider || this.conversationRuntime.hasPendingSettings(threadId)) {
2274
+ return this.conversationRuntime.scheduleSettings(threadId, params, update);
2275
+ }
2276
+ }
2277
+ return update(params);
2216
2278
  }
2217
2279
 
2218
2280
  async setGoal(threadId, { objective, tokenBudget } = {}, { signal } = {}) {
@@ -2752,6 +2814,8 @@ export class CodexAppServerAgentProvider {
2752
2814
 
2753
2815
  close() {
2754
2816
  this.options.onClose?.();
2817
+ for (const reject of this.settingsWaiters) reject(new Error("The assistant connection closed before confirming its settings."));
2818
+ this.settingsWaiters.clear();
2755
2819
  this.conversationRuntime.close();
2756
2820
  this.threadControlProbes.clear();
2757
2821
  this.commandExecutions.clear();