@jskit-ai/assistant-core 0.2.49 → 0.2.50

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - A Claude conversation keeps working when a Stop, a crashed Claude process or the next message arrives while Claude's previous turn still waits on its host (publishing, saving, storing an admission). The shared Claude turn now gives each native frame a lease on its turn: a Stop, a reset or the next turn's start supersedes it, and a superseded frame ends at that wait before it reads, changes or publishes anything of the turn that replaced it. Such a frame used to fail with "Cannot read properties of null (reading 'status')", publish the old turn's message, phase or diagnostic into the next turn, or complete the next turn with the old turn's result; a finished turn's goal refresh now resolves that turn's own completion. A steer to the same turn still lands, and a frame decides from the state it finds after each wait. A message being prepared for a turn that a Stop or the next message replaces meanwhile is no longer written to the stopped Claude, and its failure no longer stops the Claude that replaced it: it fails as not sent (`conversation_claude_turn_superseded`).
6
+ - A message lost with a failed Claude process no longer makes every later message fail as unconfirmed (`conversation_delivery_uncertain`, or the host's own changeover code). When the next message checks the earlier attempt, a Claude history without it and no live process that it was written to prove that it never arrived (`not-admitted`), and the conversation runtime now passes that proof to the changeover instead of reading every answer but `accepted` as unknown. A message Claude did record is accepted and never sent again, a prompt whose pipe write failed is reported `delivery: "not-sent"`, and a prompt written to a process still running stays unknown until that process settles it. The server records which process it wrote each prompt to, so a prompt it has no record of was written, if at all, by an earlier server, and only that server's Claude can still record it while its exit is unproven: a conversation already holding such an attempt clears it with its next message.
5
7
  - A Claude goal's running time no longer counts the time a server was stopped mid-turn. When Claude starts again after a request that was cut off, it saves a "No response requested." placeholder just before the goal continues, and the history reader counted the wait up to that placeholder as work: a restart in the middle of a turn added the whole downtime to the goal's clock. The wait before that placeholder now counts like the wait before an authored message; the cut-off request's unsaved part is not counted.
6
8
  - `AssistantGoalControl`'s chip says why a goal stopped in a few words, so it no longer runs across the message box: a goal stopped at its AI account's usage limit reads **Goal paused: usage limit**, or **Goal paused: no credit** when it is out of credit, with the orange paused light, instead of "Waiting: AI usage limit, resets at Wed 09:00" or "Out of credit: top up or switch AI account". Its details say what happened, when the limit resets and what to do. `jskit.assistant.goal.usageLimitedUntil` is replaced by `jskit.assistant.goal.usageLimitedUntilHint`, and `jskit.assistant.goal.outOfCreditHint` is new; `usageLimited`, `outOfCredit` and `usageLimitedHint` are reworded.
7
9
  - The Claude engine keeps an application tool result within its tool set's raised `maxToolResultBytes` whole. Claude Code saves an MCP tool result past 50,000 characters, or past its MCP output token cap, to a file and shows the model only a preview, which a tool-free conversation cannot read: a 128 KiB result reached the model as its first 2 KB. A tool set whose result limit is above the default 48 KiB now starts Claude with `MAX_MCP_OUTPUT_TOKENS` at that limit plus 1 KiB, and its application tools declare that many characters as their `anthropic/maxResultSizeChars` (Claude Code allows at most 500,000); a character and a token each cover at least one byte of the result. At the default limit Claude keeps its own. `createClaudeCodeProcess` takes `maximumToolResultBytes`, and conversation tools give engines `maximumResultBytes` beside `maximumArgumentBytes`.
@@ -8,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.2.49",
11
+ "@jskit-ai/assistant-core": "0.2.50",
12
12
  "@jskit-ai/http-runtime": "0.2.21",
13
13
  "@jskit-ai/kernel": "0.2.21",
14
14
  "@jskit-ai/material-web": "0.1.6",
15
15
  "vue": "^3.5.13",
16
16
  "vuetify": "^4.0.0",
17
- "@jskit-ai/connectors-core": "0.2.29",
18
- "@jskit-ai/connectors-catalog": "0.2.29"
17
+ "@jskit-ai/connectors-core": "0.2.30",
18
+ "@jskit-ai/connectors-catalog": "0.2.30"
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.2.49",
3
+ "version": "0.2.50",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "jskit test"
@@ -0,0 +1,84 @@
1
+ // Stopping a retained Claude entry's process, and the turn record its host saves.
2
+ import { stopClaudeCodeProcess } from "./claudeProcess.js";
3
+
4
+ /** Records the entry's turn state, as its host saves and shows it. */
5
+ export function setClaudeTurnState(entry, state, message = "") {
6
+ if (!entry.turn) return;
7
+ const active = ["starting", "active", "finalizing"].includes(state);
8
+ Object.assign(entry.turn, { active, state, error: message, updatedAt: new Date().toISOString(),
9
+ phase: active && !entry.observationError ? String(entry.turn.phase ?? "").trim() : "" });
10
+ }
11
+
12
+ /**
13
+ * Stops an entry's Claude process for the entry's turn (`turn` carries the
14
+ * entry, its host and the turn's own steps): proves the process exit, stops
15
+ * the turn and runs the host's interrupted checkpoint. A Stop while another
16
+ * is pending joins it.
17
+ */
18
+ export async function stopClaudeEntryProcess(turn, reason = "", { preparing = false } = {}) {
19
+ const { entry, bound, process, onEvent, read, stop, updateState, readResult, resolveCompletion } = turn;
20
+ clearTimeout(entry.goalToolStop?.timer);
21
+ entry.goalToolStop = null;
22
+ if (!preparing) entry.preparationRevision++;
23
+ turn.invalidateFinalAssistantResult();
24
+ // Runtime bindings commit execution release before dropping the handle.
25
+ // Retained host entries keep their original post-proof checkpoint order.
26
+ if (entry.stopPending) return entry.stopPending;
27
+ const active = read().active;
28
+ if (!bound) entry.stopping = true;
29
+ const operation = async () => {
30
+ const startup = entry.startupCleanup?.executionId === entry.executionId ? entry.startupCleanup : null;
31
+ let proof;
32
+ try {
33
+ if (!bound || entry.process || entry.executionId) {
34
+ proof = await stopClaudeCodeProcess({ process: entry.process || startup, executionId: entry.executionId,
35
+ stopExecution: process.stopExecution });
36
+ }
37
+ } catch (error) {
38
+ if (bound || error.code !== "claude_stop_unconfirmed") throw error;
39
+ entry.observationError = reason || "Claude process exit has not been confirmed. Try Stop again.";
40
+ await onEvent({ type: "save" });
41
+ await onEvent({ type: "stop-unconfirmed" });
42
+ throw error;
43
+ }
44
+ if (bound && (entry.process || entry.executionId)) await process.releaseExecution();
45
+ entry.process = null;
46
+ entry.executionId = "";
47
+ entry.exitProof = proof;
48
+ stop(bound ? reason : reason || "Claude stopped before acknowledging the prompt.");
49
+ if (bound) {
50
+ if (entry.startupCleanup === startup) entry.startupCleanup = null;
51
+ if (active) entry.completion?.resolve({ status: "interrupted", error: reason || "Work stopped." });
52
+ return;
53
+ }
54
+ if (entry.turn?.active && process.ownerClosing()) {
55
+ // At shutdown the turn's end belongs to the next server, as after a
56
+ // crash: its saved record keeps the execution and the active turn, so
57
+ // that server's recovery proves this exit, interrupts the turn and runs
58
+ // the host's checkpoint. Run here, that checkpoint (a Git snapshot, say)
59
+ // would hold the host's bounded shutdown for as long as it takes.
60
+ resolveCompletion({ ...readResult(), status: "interrupted", error: reason });
61
+ if (entry.startupCleanup === startup) entry.startupCleanup = null;
62
+ return entry.exitProof;
63
+ }
64
+ if (entry.turn?.active) {
65
+ try { await updateState("interrupted", reason); }
66
+ catch (error) {
67
+ // Native cleanup is verified even when the host refuses its checkpoint.
68
+ // Retain that refusal without publishing a completed answer or active work.
69
+ setClaudeTurnState(entry, "interrupted", error.message);
70
+ await onEvent({ type: "save" });
71
+ await onEvent({ type: "state", state: "interrupted", active: false, message: error.message });
72
+ resolveCompletion(readResult());
73
+ if (entry.startupCleanup === startup) entry.startupCleanup = null;
74
+ throw error;
75
+ }
76
+ } else await onEvent({ type: "save" });
77
+ resolveCompletion(readResult());
78
+ if (entry.startupCleanup === startup) entry.startupCleanup = null;
79
+ return entry.exitProof;
80
+ };
81
+ entry.stopPending = operation().catch(error => { if (bound) error.cleanupFailed = true; throw error; })
82
+ .finally(() => { entry.stopPending = null; });
83
+ return entry.stopPending;
84
+ }
@@ -1,6 +1,6 @@
1
1
  import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
2
2
  import { claudeMessageBlocks } from "./claudeHistory.js";
3
- import { stopClaudeCodeProcess } from "./claudeProcess.js";
3
+ import { setClaudeTurnState, stopClaudeEntryProcess } from "./claudeEntryStop.js";
4
4
  import { createClaudeConversationAdapter } from "./providers/claude.js";
5
5
  import { createClaudeSteeringBackground } from "./claudeSteeringBackground.js";
6
6
 
@@ -19,11 +19,19 @@ function steeringRefusal(message, { messageCode, finished }) {
19
19
 
20
20
  const CLAUDE_SIGNED_OUT_MESSAGE = "Claude isn't connected. Sign in to Claude, then send your message again.";
21
21
 
22
- function setClaudeTurnState(entry, state, message = "") {
23
- if (!entry.turn) return;
24
- const active = ["starting", "active", "finalizing"].includes(state);
25
- Object.assign(entry.turn, { active, state, error: message, updatedAt: new Date().toISOString(),
26
- phase: active && !entry.observationError ? String(entry.turn.phase ?? "").trim() : "" });
22
+ // Ends a frame whose turn a Stop, reset or the next turn's start superseded
23
+ // while the frame waited on its host. receive() catches it; it never escapes.
24
+ const SUPERSEDED_FRAME = new Error("A later Stop or turn superseded this Claude frame.");
25
+
26
+ // A failed pipe write is a definite non-delivery: Claude never read that
27
+ // message. The copy carries that verdict without changing the error other
28
+ // callers share (the client's failure is the same object for every write).
29
+ function unsentDelivery(error) {
30
+ return Object.assign(new Error(error.message, { cause: error }), {
31
+ ...(error.code ? { code: error.code } : {}),
32
+ ...(error.messageCode ? { messageCode: error.messageCode, messageParams: error.messageParams || {} } : {}),
33
+ delivery: "not-sent"
34
+ });
27
35
  }
28
36
 
29
37
  // The entry's turn result, with the text and messages the native view holds.
@@ -131,6 +139,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
131
139
  if (!conversationId) throw new TypeError("A Claude conversation id is required.");
132
140
  const instructions = process ? createClaudeConversationAdapter(process) : null;
133
141
  const publish = entry ? publishEntryEvent : onEvent;
142
+ // The steps of this turn that the entry's process stop runs.
143
+ const entryStop = { entry, bound, process, onEvent, read, stop, updateState, readResult, resolveCompletion, invalidateFinalAssistantResult };
134
144
  const messages = new Map();
135
145
  const pendingAssistantMessages = new Map();
136
146
  const assistantRoles = new Map();
@@ -154,6 +164,10 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
154
164
  let outcome;
155
165
  let active = false;
156
166
  let generation = 0;
167
+ // The lease every frame, and every message being prepared for this turn,
168
+ // holds while it waits on its host. stop(), begin() and reset() replace the
169
+ // turn and advance it.
170
+ let epoch = 0;
157
171
  let stopped = false;
158
172
  let cancelling = false;
159
173
  let maximumOutput = 4 * 1024 * 1024;
@@ -222,10 +236,10 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
222
236
  return claudeTurnResult(entry, read());
223
237
  }
224
238
 
225
- function resolveCompletion(result) {
226
- entry.completion?.resolve(result);
227
- resolvedCompletion = entry.completion;
228
- if (entry.command?.finishIfCurrent) entry.command.completion.resolve(result);
239
+ function resolveCompletion(result, completion = entry.completion, command = entry.command) {
240
+ completion?.resolve(result);
241
+ resolvedCompletion = completion;
242
+ if (command?.finishIfCurrent) command.completion.resolve(result);
229
243
  }
230
244
 
231
245
  async function updateState(state, message = "", expectedGeneration = generation) {
@@ -241,71 +255,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
241
255
  return expectedGeneration === generation;
242
256
  }
243
257
 
244
- async function stopProcess(reason = "", { preparing = false } = {}) {
245
- clearTimeout(entry.goalToolStop?.timer);
246
- entry.goalToolStop = null;
247
- if (!preparing) entry.preparationRevision++;
248
- invalidateFinalAssistantResult();
249
- // Runtime bindings commit execution release before dropping the handle.
250
- // Retained host entries keep their original post-proof checkpoint order.
251
- if (entry.stopPending) return entry.stopPending;
252
- const active = read().active;
253
- if (!bound) entry.stopping = true;
254
- const operation = async () => {
255
- const startup = entry.startupCleanup?.executionId === entry.executionId ? entry.startupCleanup : null;
256
- let proof;
257
- try {
258
- if (!bound || entry.process || entry.executionId) {
259
- proof = await stopClaudeCodeProcess({ process: entry.process || startup, executionId: entry.executionId,
260
- stopExecution: process.stopExecution });
261
- }
262
- } catch (error) {
263
- if (bound || error.code !== "claude_stop_unconfirmed") throw error;
264
- entry.observationError = reason || "Claude process exit has not been confirmed. Try Stop again.";
265
- await onEvent({ type: "save" });
266
- await onEvent({ type: "stop-unconfirmed" });
267
- throw error;
268
- }
269
- if (bound && (entry.process || entry.executionId)) await process.releaseExecution();
270
- entry.process = null;
271
- entry.executionId = "";
272
- entry.exitProof = proof;
273
- stop(bound ? reason : reason || "Claude stopped before acknowledging the prompt.");
274
- if (bound) {
275
- if (entry.startupCleanup === startup) entry.startupCleanup = null;
276
- if (active) entry.completion?.resolve({ status: "interrupted", error: reason || "Work stopped." });
277
- return;
278
- }
279
- if (entry.turn?.active && process.ownerClosing()) {
280
- // At shutdown the turn's end belongs to the next server, as after a
281
- // crash: its saved record keeps the execution and the active turn, so
282
- // that server's recovery proves this exit, interrupts the turn and runs
283
- // the host's checkpoint. Run here, that checkpoint (a Git snapshot, say)
284
- // would hold the host's bounded shutdown for as long as it takes.
285
- resolveCompletion({ ...readResult(), status: "interrupted", error: reason });
286
- if (entry.startupCleanup === startup) entry.startupCleanup = null;
287
- return entry.exitProof;
288
- }
289
- if (entry.turn?.active) {
290
- try { await updateState("interrupted", reason); }
291
- catch (error) {
292
- // Native cleanup is verified even when the host refuses its checkpoint.
293
- // Retain that refusal without publishing a completed answer or active work.
294
- setClaudeTurnState(entry, "interrupted", error.message);
295
- await onEvent({ type: "save" });
296
- await onEvent({ type: "state", state: "interrupted", active: false, message: error.message });
297
- resolveCompletion(readResult());
298
- if (entry.startupCleanup === startup) entry.startupCleanup = null;
299
- throw error;
300
- }
301
- } else await onEvent({ type: "save" });
302
- resolveCompletion(readResult());
303
- if (entry.startupCleanup === startup) entry.startupCleanup = null;
304
- return entry.exitProof;
305
- };
306
- entry.stopPending = operation().catch(error => { if (bound) error.cleanupFailed = true; throw error; })
307
- .finally(() => { entry.stopPending = null; });
308
- return entry.stopPending;
258
+ function stopProcess(reason, options) {
259
+ return stopClaudeEntryProcess(entryStop, reason, options);
309
260
  }
310
261
 
311
262
  async function publishEntryEvent(event, deferred = false) {
@@ -341,16 +292,17 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
341
292
  });
342
293
  return;
343
294
  }
295
+ // A state the host refused to save for a superseded turn is not published.
344
296
  if (event.type === "continued") {
345
297
  if (entry.completion === resolvedCompletion) entry.completion = Promise.withResolvers();
346
298
  if (bound) setClaudeTurnState(entry, "active");
347
- else await updateState("active");
299
+ else if (!await updateState("active")) return;
348
300
  } else if (event.type === "admitted") {
349
301
  if (event.userEcho) entry.sent = true;
350
302
  if (!bound) await onEvent({ type: "save" });
351
303
  } else if (event.type === "phase") {
352
304
  entry.turn.phase = event.phase;
353
- if (!bound) await updateState(entry.turn.state);
305
+ if (!bound && !await updateState(entry.turn.state)) return;
354
306
  } else if (event.type === "settled") {
355
307
  const state = event.status === "interrupted" ? "interrupted" : event.status === "failed" ? "failed" : "completed";
356
308
  if (bound) setClaudeTurnState(entry, state, event.error);
@@ -369,8 +321,12 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
369
321
  if (change) await onEvent({ type: "provider-update", change });
370
322
  if (event.type === "settled") {
371
323
  if (event.generation !== generation) return;
324
+ // The settled turn resolves its own completion: the next turn can begin
325
+ // while the goal refreshes, and it owns entry.completion by then.
326
+ const result = bound ? event : readResult();
327
+ const { completion, command: settledCommand } = entry;
372
328
  if (event.source === "result") await onEvent({ type: "provider-update", change: "goal" });
373
- resolveCompletion(bound ? event : readResult());
329
+ resolveCompletion(result, completion, settledCommand);
374
330
  }
375
331
  return published;
376
332
  }
@@ -381,13 +337,13 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
381
337
  }
382
338
  }
383
339
 
384
- async function publishMessage(message) {
340
+ async function publishMessage(message, emit) {
385
341
  if (!message.userId && currentCommandId) message = { ...message, userId: currentCommandId };
386
342
  messages.set(message.id, message);
387
343
  const owner = acceptedInput;
388
344
  const completeAssistant = message.complete && message.role === "assistant";
389
345
  if (completeAssistant) clearFinalAssistantResult();
390
- const conversationTurn = await publish({ type: "message", message });
346
+ const conversationTurn = await emit({ type: "message", message });
391
347
  if (completeAssistant && message.text && currentReceiptOwner(owner) && !stopped && !cancelling &&
392
348
  conversationTurn && typeof conversationTurn === "object" && !Array.isArray(conversationTurn)) {
393
349
  publishedAssistant = { owner, itemId: message.id, outputId: message.outputId || message.id,
@@ -396,24 +352,49 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
396
352
  return conversationTurn;
397
353
  }
398
354
 
399
- async function flushAssistantMessages(apiMessageId, role) {
355
+ async function flushAssistantMessages(apiMessageId, role, emit) {
400
356
  assistantRoles.set(apiMessageId, role);
401
357
  const pending = pendingAssistantMessages.get(apiMessageId) || [];
402
358
  pendingAssistantMessages.delete(apiMessageId);
403
359
  for (const block of pending) {
404
- await publishMessage({ ...block, role });
360
+ await publishMessage({ ...block, role }, emit);
405
361
  if (block.outputId) {
406
362
  if (block.outputId !== block.id) messages.delete(block.outputId);
407
- await publish({ type: "message-complete", messageId: block.outputId });
363
+ await emit({ type: "message-complete", messageId: block.outputId });
408
364
  }
409
365
  }
410
366
  }
411
367
 
368
+ /**
369
+ * One native frame. The process's reader hands over frames one at a time;
370
+ * Stop, a reset and the next turn's start are synchronous steps that advance
371
+ * the lease. So the turn changes under a frame only while the frame waits on
372
+ * its host, and the frame does all of that waiting through its lease: when a
373
+ * Stop, a reset or the next turn's start has superseded its turn, the frame
374
+ * ends at that wait, before it reads, changes or publishes anything of the
375
+ * turn that replaced it. A message being prepared holds the lease the same
376
+ * way (see holds()). Input to the same turn (a steer) may change it meanwhile,
377
+ * so a frame decides from the state it finds after each wait, never from
378
+ * earlier reads.
379
+ */
412
380
  async function receive(frame) {
413
381
  if (stopped) return;
414
382
  if (frame.session_id && frame.session_id !== conversationId) throw claudeTurnFailure("Claude returned a different conversation id.");
415
383
  if (frame.parent_tool_use_id) return; // Nested agents do not replace the main reply.
416
- await publish({ type: "provider-event", event: frame });
384
+ const lease = epoch;
385
+ const hold = () => { if (lease !== epoch) throw SUPERSEDED_FRAME; };
386
+ const emit = async event => {
387
+ hold();
388
+ const published = await publish(event);
389
+ hold();
390
+ return published;
391
+ };
392
+ try { await receiveFrame(frame, emit, hold); }
393
+ catch (error) { if (error !== SUPERSEDED_FRAME) throw error; }
394
+ }
395
+
396
+ async function receiveFrame(frame, emit, hold) {
397
+ await emit({ type: "provider-event", event: frame });
417
398
  if (continuesClaudeTurn(frame) && turnId && !cancelling) {
418
399
  const wasActive = active;
419
400
  if (outcome || !active) {
@@ -423,10 +404,10 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
423
404
  clearFinalAssistantResult();
424
405
  }
425
406
  active = true;
426
- if (!wasActive) await publish({ type: "continued", source: frame.type });
407
+ if (!wasActive) await emit({ type: "continued", source: frame.type });
427
408
  if (phase === "background") {
428
409
  phase = "";
429
- await publish({ type: "phase", phase });
410
+ await emit({ type: "phase", phase });
430
411
  }
431
412
  }
432
413
  if (frame.type === "command_lifecycle" || frame.type === "user") {
@@ -449,19 +430,22 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
449
430
  // Once acknowledged, storage owns the admission decision. Its write
450
431
  // must not race a native-ack timeout and a second submission.
451
432
  clearTimeout(pending.timer);
452
- try {
453
- await pending.accept();
454
- if (inputOwner === pending.receiptOwner && !stopped && !cancelling) acceptedInput = pending.receiptOwner;
455
- admissions.delete(uuid);
456
- pending.resolve();
457
- } catch (error) {
433
+ let failure;
434
+ try { await pending.accept(); }
435
+ catch (error) { failure = error; }
436
+ // A Stop while storage decided has already refused this admission.
437
+ hold();
438
+ if (failure) {
458
439
  acceptedInput = null;
459
440
  clearFinalAssistantResult();
460
- pending.reject(error);
461
- throw error;
441
+ pending.reject(failure);
442
+ throw failure;
462
443
  }
444
+ if (inputOwner === pending.receiptOwner && !stopped && !cancelling) acceptedInput = pending.receiptOwner;
445
+ admissions.delete(uuid);
446
+ pending.resolve();
463
447
  }
464
- await publish({ type: "admitted", messageId: uuid, userEcho: frame.type === "user" });
448
+ await emit({ type: "admitted", messageId: uuid, userEcho: frame.type === "user" });
465
449
  }
466
450
  } else if (frame.type === "system") {
467
451
  if (active && ["status", "compact_boundary"].includes(frame.subtype)) {
@@ -469,12 +453,12 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
469
453
  // An idle status does not end a wait that only background tasks own.
470
454
  if (nextPhase !== phase && (nextPhase || phase !== "background")) {
471
455
  phase = nextPhase;
472
- await publish({ type: "phase", phase });
456
+ await emit({ type: "phase", phase });
473
457
  }
474
458
  }
475
459
  if (frame.subtype === "local_command_output" && frame.content) {
476
460
  await publishMessage({ id: `claude_${frame.uuid}`, role: "commentary", text: frame.content, complete: true,
477
- data: { native: { engineId: "claude", type: "system", subtype: "local_command_output" } } });
461
+ data: { native: { engineId: "claude", type: "system", subtype: "local_command_output" } } }, emit);
478
462
  }
479
463
  const previousTaskCount = tasks.size;
480
464
  updateClaudeBackgroundTasks(tasks, frame);
@@ -487,16 +471,16 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
487
471
  if (tasks.size < previousTaskCount) outcome = null;
488
472
  if (phase === "background" && !outcome) {
489
473
  phase = "";
490
- await publish({ type: "phase", phase });
474
+ await emit({ type: "phase", phase });
491
475
  }
492
476
  if (["background_tasks_changed", "task_started", "task_notification", "task_updated"].includes(frame.subtype)) {
493
477
  if (tasks.size && turnId && !active && !cancelling) {
494
478
  generation++;
495
479
  active = true;
496
480
  clearFinalAssistantResult();
497
- await publish({ type: "continued", source: frame.subtype });
481
+ await emit({ type: "continued", source: frame.subtype });
498
482
  }
499
- await publish({ type: "diagnostic", stage: "background_tasks", nativeEvent: frame.subtype,
483
+ await emit({ type: "diagnostic", stage: "background_tasks", nativeEvent: frame.subtype,
500
484
  active, pendingCommands: inFlight.size, backgroundTasks: tasks.size });
501
485
  }
502
486
  } else if (frame.type === "stream_event") {
@@ -512,21 +496,21 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
512
496
  if (block && typeof delta === "string") {
513
497
  block.text += delta;
514
498
  checkOutput(block.text, block.role);
515
- await publishMessage(block);
516
- await publish({ type: block.role === "thinking" ? "thinking" : "text", text: delta, messageId: id });
499
+ await publishMessage(block, emit);
500
+ await emit({ type: block.role === "thinking" ? "thinking" : "text", text: delta, messageId: id });
517
501
  }
518
502
  } else if (event.type === "content_block_stop") {
519
503
  if (![...pendingAssistantMessages.values()].some(blocks => blocks.some(block => block.outputId === id))) {
520
504
  messages.delete(id);
521
- await publish({ type: "message-complete", messageId: id });
505
+ await emit({ type: "message-complete", messageId: id });
522
506
  }
523
507
  } else if (event.type === "message_delta" && event.delta?.stop_reason) {
524
- await flushAssistantMessages(messageId, event.delta.stop_reason === "tool_use" ? "commentary" : "assistant");
508
+ await flushAssistantMessages(messageId, event.delta.stop_reason === "tool_use" ? "commentary" : "assistant", emit);
525
509
  }
526
510
  } else if (frame.type === "assistant") {
527
511
  const apiMessageId = frame.message?.id;
528
512
  if (apiMessageId && frame.message.content?.some(block => block.type === "tool_use")) {
529
- await flushAssistantMessages(apiMessageId, "commentary");
513
+ await flushAssistantMessages(apiMessageId, "commentary", emit);
530
514
  }
531
515
  for (const block of frame.message?.content || []) steeringBackground.called(block);
532
516
  for (const block of claudeMessageBlocks(frame)) {
@@ -539,41 +523,43 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
539
523
  }
540
524
  if (apiMessageId && block.role === "assistant") {
541
525
  const role = assistantRoles.get(apiMessageId);
542
- if (role) await publishMessage({ ...block, role });
526
+ if (role) await publishMessage({ ...block, role }, emit);
543
527
  else {
544
528
  const pending = pendingAssistantMessages.get(apiMessageId) || [];
545
529
  pending.push(block);
546
530
  pendingAssistantMessages.set(apiMessageId, pending);
547
531
  }
548
- } else await publishMessage(block);
532
+ } else await publishMessage(block, emit);
549
533
  }
550
534
  if (apiMessageId && frame.message.stop_reason) {
551
- await flushAssistantMessages(apiMessageId, frame.message.stop_reason === "tool_use" ? "commentary" : "assistant");
535
+ await flushAssistantMessages(apiMessageId, frame.message.stop_reason === "tool_use" ? "commentary" : "assistant", emit);
552
536
  }
553
537
  } else if (frame.type === "result") {
554
538
  const failed = frame.is_error || frame.subtype !== "success";
555
539
  const interrupted = ["aborted_streaming", "aborted_tools"].includes(frame.terminal_reason);
556
540
  const role = failed || interrupted ? "commentary" : "assistant";
557
- for (const id of pendingAssistantMessages.keys()) await flushAssistantMessages(id, role);
541
+ for (const id of pendingAssistantMessages.keys()) await flushAssistantMessages(id, role, emit);
558
542
  const output = typeof frame.structured_output === "object" ? JSON.stringify(frame.structured_output) : String(frame.result || "");
559
543
  checkOutput(output);
560
544
  const message = !failed ? "" : claudeResultError(frame, output);
561
545
  result = failed ? "" : output;
562
546
  if (result && ![...messages.values()].some((block) => block.complete && block.role === "assistant" && block.text === result)) {
563
- await publishMessage(claudeResultMessage(frame, turnId, result));
547
+ await publishMessage(claudeResultMessage(frame, turnId, result), emit);
564
548
  }
565
549
  inFlight.delete(currentCommandId);
566
550
  for (const uuid of startedInputs) inFlight.delete(uuid);
567
551
  startedInputs.clear();
568
552
  if (cancelling) return;
569
- outcome = { status: interrupted ? "interrupted" : failed ? "failed" : "completed", error: message };
553
+ const status = interrupted ? "interrupted" : failed ? "failed" : "completed";
554
+ outcome = { status, error: message };
570
555
  // The reply is final, but native background tasks still own the turn
571
556
  // (and may continue it). Keep it active and say what it is waiting for.
572
557
  if (active && tasks.size && !inFlight.size && phase !== "background") {
573
558
  phase = "background";
574
- await publish({ type: "phase", phase });
559
+ await emit({ type: "phase", phase });
575
560
  }
576
- await publish({ type: "diagnostic", stage: "native_result", status: outcome.status,
561
+ // A steer prepared during that wait owns completion and cleared this outcome.
562
+ await emit({ type: "diagnostic", stage: "native_result", status,
577
563
  terminalReason: frame.terminal_reason, active, pendingCommands: inFlight.size, backgroundTasks: tasks.size });
578
564
  }
579
565
  if ((frame.type === "system" || frame.type === "result" || frame.type === "command_lifecycle" && frame.state === "completed") &&
@@ -587,13 +573,18 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
587
573
  itemId: candidate.itemId, outputId: candidate.outputId, text: candidate.text,
588
574
  conversationTurn: candidate.conversationTurn } : null;
589
575
  latestSettlement = { type: "settled", ...read(), generation, source: frame.type };
590
- try { await publish(latestSettlement); }
591
- catch (error) { clearFinalAssistantResult(); throw error; }
576
+ try { await emit(latestSettlement); }
577
+ catch (error) {
578
+ // A superseded frame leaves the final receipt to the turn that replaced it.
579
+ if (error !== SUPERSEDED_FRAME) clearFinalAssistantResult();
580
+ throw error;
581
+ }
592
582
  }
593
583
  }
594
584
 
595
585
  function stop(reason = "Claude stopped before acknowledging the prompt.") {
596
586
  generation++;
587
+ epoch++;
597
588
  stopped = true;
598
589
  invalidateFinalAssistantResult();
599
590
  for (const pending of admissions.values()) {
@@ -624,6 +615,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
624
615
  if (active) throw claudeTurnFailure("This Claude turn is still working.", { messageCode: "assistant.claude.turnStillWorking" });
625
616
  if (!id || !Number.isSafeInteger(maxOutputCharacters) || maxOutputCharacters < 1) throw new TypeError("Invalid Claude turn configuration.");
626
617
  generation++;
618
+ epoch++;
627
619
  clearTurnView();
628
620
  turnId = id;
629
621
  maximumOutput = maxOutputCharacters;
@@ -633,10 +625,17 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
633
625
  cancelling = false;
634
626
  stopped = false;
635
627
  active = true;
628
+ return epoch;
629
+ }
630
+
631
+ // Whether the turn a lease from begin() or prepareSteering() names is still this one.
632
+ function holds(lease) {
633
+ return lease === epoch;
636
634
  }
637
635
 
638
636
  function reset() {
639
637
  if (active || inFlight.size || tasks.size) throw claudeTurnFailure("Stop Claude before resetting its turn view.", { messageCode: "assistant.claude.stopBeforeReset" });
638
+ epoch++;
640
639
  clearTurnView();
641
640
  }
642
641
 
@@ -663,13 +662,14 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
663
662
  { delivery: "uncertain" }
664
663
  )), timeoutMs);
665
664
  admissions.set(uuid, { ...admitted, accept, timer, receiptOwner });
665
+ let unwritten = false;
666
666
  try {
667
667
  await Promise.all([client.send(message, { messageId: uuid, sessionId: conversationId })
668
- .then(() => { if (steer) backgroundForegroundWork(); }), admitted.promise]);
668
+ .then(() => { if (steer) backgroundForegroundWork(); }, error => { unwritten = true; throw error; }), admitted.promise]);
669
669
  } catch (error) {
670
670
  waitingSteers.delete(uuid);
671
671
  if (inputOwner === receiptOwner) invalidateFinalAssistantResult();
672
- throw error;
672
+ throw unwritten && !error.delivery ? unsentDelivery(error) : error;
673
673
  } finally {
674
674
  clearTimeout(timer);
675
675
  }
@@ -691,7 +691,9 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
691
691
  outcome = null;
692
692
  acceptedInput = null;
693
693
  clearFinalAssistantResult();
694
+ const lease = epoch;
694
695
  await publish({ type: "diagnostic", stage: "steering_queued", pendingCommands: inFlight.size, backgroundTasks: tasks.size });
696
+ return lease;
695
697
  }
696
698
 
697
699
  async function cancel(client, stopProcess) {
@@ -721,6 +723,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
721
723
  invalidateInstructions: instructions.invalidate
722
724
  } : {}),
723
725
  begin,
726
+ holds,
724
727
  reset,
725
728
  send,
726
729
  prepareSteering,