@jskit-ai/assistant-core 0.1.226 → 0.1.228

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/examples/conversation/package.json +1 -1
  2. package/fixtures/responsive-assistant/ConversationFixture.vue +5 -3
  3. package/fixtures/responsive-assistant/DeliveryFixture.vue +9 -0
  4. package/package.json +1 -1
  5. package/src/client/conversation/AssistantConversationElement.vue +7 -4
  6. package/src/client/conversation/AssistantGoalControl.vue +31 -17
  7. package/src/client/conversation/AssistantTranscript.vue +14 -7
  8. package/src/client/conversation/useAssistantAttachments.js +6 -3
  9. package/src/server/conversation/claudeClient.js +22 -5
  10. package/src/server/conversation/claudeHistory.js +62 -5
  11. package/src/server/conversation/claudeProcess.js +7 -6
  12. package/src/server/conversation/claudeTurn.js +284 -57
  13. package/src/server/conversation/codexConfiguration.js +24 -1
  14. package/src/server/conversation/codexGoalCommands.js +8 -3
  15. package/src/server/conversation/codexTurnRecovery.js +18 -8
  16. package/src/server/conversation/continuity.js +54 -17
  17. package/src/server/conversation/providers/claudeDriver.js +6 -5
  18. package/src/server/conversation/providers/codexDriver.js +3 -3
  19. package/src/server/conversation/providers/scoped.js +31 -10
  20. package/src/server/conversation/runtime.js +48 -13
  21. package/src/server/conversation/streams.js +7 -3
  22. package/src/server/conversation/transcript.js +2 -1
  23. package/src/server/hosts/codexConfiguration.js +1 -0
  24. package/src/shared/conversation/longTextBlocks.js +0 -4
  25. package/test/assistantScroll.browser.test.js +76 -3
  26. package/test/claudeClient.test.js +73 -0
  27. package/test/claudeConversationSession.test.js +120 -4
  28. package/test/claudeProcess.test.js +61 -0
  29. package/test/claudeTurn.test.js +1326 -10
  30. package/test/codexConversationSession.test.js +9 -1
  31. package/test/codexGoal.test.js +47 -0
  32. package/test/codexModelCatalog.test.js +55 -0
  33. package/test/codexTurn.test.js +228 -0
  34. package/test/conversationAttachments.test.js +17 -0
  35. package/test/conversationRuntime.test.js +40 -0
  36. package/test/conversationStreaming.test.js +18 -0
  37. package/test/messageDelivery.browser.test.js +30 -0
@@ -1,5 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
2
3
  import path from "node:path";
4
+ import { setTimeout as delay } from "node:timers/promises";
3
5
  import { claudeMessageBlocks, deleteClaudeConversationHistory, readClaudeHistory, requireClaudeSessionId, retireClaudeConversationHistory } from "./claudeHistory.js";
4
6
  import { bindClaudeConversationAccount, claudeCodeArguments, createClaudeAccountQueries, createClaudeCodeProcess, stopClaudeCodeProcess } from "./claudeProcess.js";
5
7
  import { claudeApplicationToolResponse } from "./claudeTools.js";
@@ -12,6 +14,10 @@ function failure(message, code = "assistant_claude_turn_failed") {
12
14
  const text = value => String(value ?? "").trim();
13
15
  const now = () => new Date().toISOString();
14
16
 
17
+ function hasPendingClaudeStartup(entry) {
18
+ return entry.startupCleanup?.pending === true && entry.startupCleanup.executionId === entry.executionId;
19
+ }
20
+
15
21
  export function claudeNativeMessageId(id) {
16
22
  const value = createHash("sha256").update(String(id)).digest("hex");
17
23
  return `${value.slice(0, 8)}-${value.slice(8, 12)}-4${value.slice(13, 16)}-8${value.slice(17, 20)}-${value.slice(20, 32)}`;
@@ -23,7 +29,7 @@ export async function waitForClaudeConversationTurn(conversation, input = {}, {
23
29
  interruptedMessage = "Claude was interrupted when the application disconnected."
24
30
  } = {}) {
25
31
  const entry = conversation.state;
26
- if (entry.executionId && !entry.process) await conversation.stop(interruptedMessage);
32
+ if (entry.executionId && !entry.process && !hasPendingClaudeStartup(entry)) await conversation.stop(interruptedMessage);
27
33
  if (!entry.turn?.active) return conversation.read(input);
28
34
  const timeoutMs = Math.min(
29
35
  Number(input.timeoutMs) > 0 ? Number(input.timeoutMs) : defaultTimeoutMs,
@@ -98,29 +104,42 @@ export async function runClaudeRenewalTurn(conversation, {
98
104
  }
99
105
  }
100
106
 
101
- export function createClaudeConversationGoals({ conversationId, readHistory, readState, interrupt, send }) {
102
- const goalError = message => failure(message, "assistant_claude_goal_failed");
107
+ export function createClaudeConversationGoals({ conversationId, readHistory, readState, interrupt, send, readRetainedGoal, saveRetainedGoal }) {
108
+ // These checks run before interrupt/send. Native execution and receipt
109
+ // failures keep their own codes and must not be treated as safe rejections.
110
+ const goalError = message => Object.assign(failure(message, "conversation_invalid_goal"), {
111
+ statusCode: 400, delivery: "not-sent"
112
+ });
103
113
 
104
- async function read() {
105
- const { goal = null } = await readHistory();
114
+ async function read(history = null) {
115
+ const { goal = null } = history || await readHistory();
116
+ const retained = readRetainedGoal?.();
117
+ if (retained) return { ...retained };
106
118
  const state = readState();
107
- return goal ? { ...goal, status: goal.status === "complete" ? "complete" :
119
+ return goal ? { ...goal, status: ["complete", "blocked"].includes(goal.status) ? goal.status :
108
120
  state.running && state.active ? "active" : "paused" } : null;
109
121
  }
110
122
 
111
- async function update(input = {}, { interrupt: interruptGoal = interrupt, send: sendGoal = send } = {}) {
123
+ async function update(input = {}, { interrupt: interruptGoal = interrupt, send: sendGoal = send, signal } = {}) {
112
124
  const goal = await read();
125
+ signal?.throwIfAborted();
113
126
  const action = input.action;
114
127
  if (action !== "set" && (!goal || input.threadId !== conversationId ||
115
128
  input.createdAt !== goal.createdAt || input.objective !== goal.objective)) {
116
129
  throw goalError("The Claude goal changed. Refresh before trying again.");
117
130
  }
118
131
  if (input.tokenBudget != null) throw goalError("Claude goals do not support a token budget.");
119
- if (action === "pause") {
132
+ if (action === "pause" || action === "block") {
133
+ const reason = typeof input.reason === "string" ? input.reason.trim() : "";
134
+ if (action === "block" && (!reason || reason.length > 1000 || goal.status !== "active")) {
135
+ throw goalError("An active Claude goal needs a blocker reason of up to 1,000 characters.");
136
+ }
137
+ if (goal.status !== "complete") await saveRetainedGoal({ ...goal,
138
+ status: action === "block" ? "blocked" : "paused", ...(action === "block" ? { reason } : {}) });
120
139
  await interruptGoal();
121
140
  } else if (action === "cancel") {
122
141
  await interruptGoal();
123
- await sendGoal({ message: "/goal clear" });
142
+ await sendGoal({ message: "/goal clear", signal });
124
143
  } else if (action === "set" || action === "resume") {
125
144
  const objective = String((action === "set" ? input.objective : goal.objective) ?? "").trim();
126
145
  if (!objective || objective.length > 4000 || /^(clear|stop|off|reset|none|cancel)$/iu.test(objective)) {
@@ -129,7 +148,7 @@ export function createClaudeConversationGoals({ conversationId, readHistory, rea
129
148
  if (action === "set" && goal && goal.status !== "complete") {
130
149
  throw goalError("Cancel the existing goal before setting another.");
131
150
  }
132
- await sendGoal({ message: `/goal ${objective}` });
151
+ await sendGoal({ message: `/goal ${objective}`, signal });
133
152
  } else throw goalError("Unknown Claude goal action.");
134
153
  }
135
154
 
@@ -142,6 +161,7 @@ export function createClaudeConversationOwner({
142
161
  disconnectedMessage = "Claude was interrupted when the application disconnected."
143
162
  }) {
144
163
  const entries = new Map();
164
+ const applicationCall = new AsyncLocalStorage();
145
165
  const closingSessions = new Set();
146
166
  let closing = false;
147
167
  const createProcess = process.create || createClaudeCodeProcess;
@@ -163,7 +183,7 @@ export function createClaudeConversationOwner({
163
183
  return Boolean(owner && entry && !closing && !closingSessions.has(entry.context.key) &&
164
184
  entries.get(entry.key) === entry && entry.command === command && command.entry === entry &&
165
185
  command.admitted && !command.signal.aborted && !command.toolFailure &&
166
- !entry.disposed && !entry.stopping && !entry.stopPending && !entry.observationError &&
186
+ !entry.disposed && !entry.stopping && !entry.stopPending && !entry.goalToolStop && !entry.observationError &&
167
187
  entry.process === owner.process && entry.executionId === owner.executionId &&
168
188
  entry.accountIdentity === owner.accountIdentity && entry.id === owner.threadId &&
169
189
  entry.turn?.id === owner.turnId && entry.turn.active && native.turnId === owner.turnId && native.active);
@@ -216,10 +236,19 @@ export function createClaudeConversationOwner({
216
236
  executionId: state.executionId || (recoveringCleanup ? cleanupExecutionId : ""), preparationRevision: 0,
217
237
  accountIdentities: state.accountIdentities || (state.accountIdentity ? { anthropic: state.accountIdentity } : {}),
218
238
  accountIdentity: state.accountIdentity || "", sent: state.sent === true, nativeWorkdir: state.nativeWorkdir || nativeWorkdir,
219
- lastMessageId: state.lastMessageId || "", turn: state.turnId ? {
239
+ lastMessageId: state.lastMessageId || "", pausedGoal: state.pausedGoal || null, blockedGoal: state.blockedGoal || null, turn: state.turnId ? {
220
240
  id: state.turnId, active: ["starting", "active", "finalizing"].includes(state.state),
221
241
  state: state.state, startedAt: now(), updatedAt: now()
222
242
  } : null };
243
+ for (const [field, status] of [["pausedGoal", "paused"], ["blockedGoal", "blocked"]]) {
244
+ const retained = entry[field];
245
+ if (retained && (retained.threadId !== id || typeof retained.objective !== "string" ||
246
+ !retained.objective.trim() || !Number.isFinite(retained.createdAt) || retained.status !== status ||
247
+ status === "blocked" && (typeof retained.reason !== "string" || !retained.reason.trim() || retained.reason.length > 1000))) {
248
+ throw createError("The retained Claude goal is invalid. Restore its saved conversation state.");
249
+ }
250
+ }
251
+ if (entry.pausedGoal && entry.blockedGoal) throw createError("The saved Claude goal cannot be both paused and blocked.");
223
252
  entry.nativeTurn = createClaudeConversationTurn({ conversationId: id, entry,
224
253
  onEvent: event => onEvent(entry, event),
225
254
  process: {
@@ -234,6 +263,14 @@ export function createClaudeConversationOwner({
234
263
  });
235
264
  entry.goals = createClaudeConversationGoals({ conversationId: id,
236
265
  readHistory: () => readHistory(entry),
266
+ readRetainedGoal: () => entry.pausedGoal || entry.blockedGoal,
267
+ async saveRetainedGoal(goal) {
268
+ const previous = { pausedGoal: entry.pausedGoal, blockedGoal: entry.blockedGoal };
269
+ entry.pausedGoal = goal.status === "paused" ? goal : null;
270
+ entry.blockedGoal = goal.status === "blocked" ? goal : null;
271
+ try { await store.save(entry); }
272
+ catch (error) { Object.assign(entry, previous); throw error; }
273
+ },
237
274
  readState: () => ({ running: Boolean(entry.process), active: store.releaseExecution ? entry.nativeTurn.read().active : Boolean(entry.turn?.active) }),
238
275
  interrupt: () => interrupt(entry), send: input => send(entry, input) });
239
276
  entries.set(key, entry);
@@ -273,10 +310,13 @@ export function createClaudeConversationOwner({
273
310
  }
274
311
 
275
312
  async function ensureReady(entry) {
276
- if (entry.process) await ensureProcess(entry);
277
- else {
313
+ if (entry.process) {
314
+ // Status reads retain active instructions; apply changes at idle admission.
315
+ if (store.releaseExecution ? entry.nativeTurn.read().active : entry.turn?.active) await bindAccount(entry);
316
+ else await ensureProcess(entry);
317
+ } else {
278
318
  await bindAccount(entry);
279
- if (entry.executionId) await entry.nativeTurn.stopProcess("Claude's previous process exit could not be confirmed.");
319
+ if (entry.executionId && !hasPendingClaudeStartup(entry)) await entry.nativeTurn.stopProcess("Claude's previous process exit could not be confirmed.");
280
320
  // The first Send prepares host hooks before starting the native process.
281
321
  }
282
322
  return { ok: true, thread: { id: entry.id }, turn: snapshot(entry), workdir: entry.context.workdir };
@@ -296,6 +336,24 @@ export function createClaudeConversationOwner({
296
336
  return entry.nativeTurn.prepare(configuration);
297
337
  }
298
338
 
339
+ async function readContextUsage(entry, input = {}) {
340
+ input.signal?.throwIfAborted();
341
+ const native = await ensureProcess(entry, input);
342
+ input.signal?.throwIfAborted();
343
+ const usage = await native.client.request({ subtype: "get_context_usage", detail: "summary" });
344
+ input.signal?.throwIfAborted();
345
+ const usedTokens = usage?.totalTokens;
346
+ const maximum = usage?.maxTokens;
347
+ const threshold = usage?.autoCompactThreshold ?? maximum;
348
+ if (!Number.isSafeInteger(usedTokens) || usedTokens < 0 ||
349
+ !Number.isSafeInteger(maximum) || maximum < 1 ||
350
+ !Number.isSafeInteger(threshold) || threshold < 1 || typeof usage.model !== "string" || !usage.model) {
351
+ throw createError("Claude's current context usage is unavailable.");
352
+ }
353
+ return { model: usage.model, usedTokens,
354
+ windowTokens: usage.isAutoCompactEnabled === false ? maximum : Math.min(maximum, threshold) };
355
+ }
356
+
299
357
  async function stopForTerminal(entry, application) {
300
358
  if (entry.turn?.active) throw createError("Stop the current turn before opening the Claude Code terminal.");
301
359
  if (closing || closingSessions.has(entry.context.key) || application?.sessionClosing) throw createError("This session is closing.");
@@ -326,17 +384,23 @@ export function createClaudeConversationOwner({
326
384
  let prepared = process.prepare ? process.prepare(entry, configuration, context) : { workdir: entry.nativeWorkdir };
327
385
  if (prepared && typeof prepared.then === "function") prepared = await prepared;
328
386
  entry.nativeWorkdir = prepared.workdir;
329
- const history = await readHistory(entry);
330
- entry.sent ||= history.exists;
387
+ if (!entry.sent) entry.sent = (await readHistory(entry)).exists;
331
388
  entry.stopping = false;
332
389
  entry.profile = configuration.profile;
333
390
  let options = process.configure(entry, configuration, { context, prepared });
334
391
  if (options && typeof options.then === "function") options = await options;
335
392
  assertPreparing();
393
+ let startupCleanup;
394
+ let retainedGoalClear;
395
+ let startedExecutionId;
336
396
  try { entry.process = await createProcess({ ...options,
337
397
  workdir: entry.nativeWorkdir, sessionId: entry.id, resume: entry.sent,
338
398
  onStarted: async (executionId, stop) => {
339
- if (typeof stop === "function") entry.startupCleanup = { executionId, stop };
399
+ startedExecutionId = executionId;
400
+ if (typeof stop === "function") {
401
+ startupCleanup = { executionId, stop, pending: true };
402
+ entry.startupCleanup = startupCleanup;
403
+ }
340
404
  if (entry.executionId !== executionId) {
341
405
  entry.executionId = executionId;
342
406
  await store.save(entry, { execution: true });
@@ -345,6 +409,23 @@ export function createClaudeConversationOwner({
345
409
  assertPreparing();
346
410
  },
347
411
  async onEvent(frame) {
412
+ const pending = entry.goalToolStop;
413
+ if (pending && pending.executionId === startedExecutionId && entry.process === pending.process &&
414
+ frame.type === "user" && !frame.parent_tool_use_id && Array.isArray(frame.message?.content) &&
415
+ frame.message.content.some(block => block.type === "tool_result" && block.tool_use_id === pending.toolUseId)) {
416
+ // The CLI has consumed the tool response. Do not join process cleanup
417
+ // on its event reader: interrupt/exit also needs that reader to drain.
418
+ pending.stop();
419
+ }
420
+ // A retained Pause disarms only the native goal hook before a new
421
+ // authored input is admitted. This local command is not an AI turn.
422
+ if (retainedGoalClear) {
423
+ if (frame.type === "result" && frame.user_message_uuid === retainedGoalClear.id) {
424
+ if (frame.local_command === "goal" && frame.is_error !== true && frame.subtype === "success") retainedGoalClear.resolve();
425
+ else retainedGoalClear.reject(createError("Claude could not suspend its retained goal checker."));
426
+ }
427
+ return;
428
+ }
348
429
  const current = entry.command;
349
430
  if (current && frame.type === "assistant" && !frame.parent_tool_use_id) {
350
431
  for (const tool of frame.message?.content || []) if (tool.type === "tool_use" && tool.id && tool.name?.startsWith("mcp__application__")) {
@@ -359,12 +440,14 @@ export function createClaudeConversationOwner({
359
440
  }
360
441
  await entry.nativeTurn.receive(frame);
361
442
  },
362
- ...(store.releaseExecution || entry.command?.tools ? {
443
+ ...(store.releaseExecution || options.applicationTools || entry.command?.tools ? {
363
444
  async onControlRequest(request, { signal }) {
364
445
  const active = entry.command;
365
446
  const suppliedTools = !store.releaseExecution && active?.tools;
366
447
  const owner = active?.nativeOwner;
367
- const work = claudeApplicationToolResponse(request, { schemas: entry.toolSchemas || [], turn: active, signal,
448
+ const work = applicationCall.run({ entry, command: active, owner,
449
+ toolUseId: request.message?.params?._meta?.["claudecode/toolUseId"] },
450
+ () => claudeApplicationToolResponse(request, { schemas: entry.toolSchemas || [], turn: active, signal,
368
451
  ...(suppliedTools ? {
369
452
  assertCurrent(native) {
370
453
  if (!currentApplicationCommand(entry, active, owner) || active.nativeOwner !== owner ||
@@ -379,13 +462,14 @@ export function createClaudeConversationOwner({
379
462
  active.failure.reject(error);
380
463
  }
381
464
  }
382
- } : {}) });
465
+ } : {}) }));
383
466
  active?.toolWork.add(work);
384
467
  try { return await work; }
385
468
  finally { active?.toolWork.delete(work); }
386
469
  }
387
470
  } : {}),
388
471
  onFailure: async error => {
472
+ retainedGoalClear?.reject(error);
389
473
  if (store.releaseExecution) {
390
474
  entry.nativeTurn.invalidateInstructions();
391
475
  try { await entry.nativeTurn.stopProcess(error.message); }
@@ -412,6 +496,8 @@ export function createClaudeConversationOwner({
412
496
  if (entry.startupCleanup?.executionId === error.executionId) entry.startupCleanup = null;
413
497
  }
414
498
  throw error;
499
+ } finally {
500
+ if (startupCleanup) startupCleanup.pending = false;
415
501
  }
416
502
  try { assertPreparing(); }
417
503
  catch (error) {
@@ -419,6 +505,27 @@ export function createClaudeConversationOwner({
419
505
  throw error;
420
506
  }
421
507
  if (entry.startupCleanup?.executionId === entry.executionId) entry.startupCleanup = null;
508
+ if (entry.pausedGoal || entry.blockedGoal) {
509
+ const completion = Promise.withResolvers();
510
+ completion.promise.catch(() => {});
511
+ retainedGoalClear = { ...completion, id: randomUUID() };
512
+ const timer = setTimeout(() => completion.reject(createError("Claude timed out suspending its retained goal checker.")), 30_000);
513
+ try {
514
+ await entry.process.client.send("/goal clear", { messageId: retainedGoalClear.id, sessionId: entry.id });
515
+ await completion.promise;
516
+ const cleared = await readHistory(entry);
517
+ if (cleared.goal || cleared.goalCommandId !== retainedGoalClear.id) {
518
+ throw createError("Claude has not saved the retained goal's native clear receipt.");
519
+ }
520
+ assertPreparing();
521
+ } catch (error) {
522
+ await entry.nativeTurn.stopProcess(error.message);
523
+ throw Object.assign(error, { delivery: "not-sent" });
524
+ } finally {
525
+ clearTimeout(timer);
526
+ retainedGoalClear = null;
527
+ }
528
+ }
422
529
  return entry.process;
423
530
  }
424
531
 
@@ -438,9 +545,26 @@ export function createClaudeConversationOwner({
438
545
  return { ok: true, delivered: true, duplicate: true, thread: { id: entry.id }, turn: snapshot(entry) };
439
546
  }
440
547
  }
441
- const native = command ? command.steering ? entry.process : await entry.nativeTurn.prepare(command.prepared) : await ensureProcess(entry, input);
548
+ let native;
549
+ try {
550
+ if (!command && entry.main && entry.turn?.active && entry.process) {
551
+ // Main steering keeps the active instruction installation, just like
552
+ // command steering. Refresh guidance on the next idle admission.
553
+ // Account replacement must still confirm cleanup before any new send.
554
+ if (preparation.account) await bindAccount(entry);
555
+ native = entry.process || await ensureProcess(entry, input);
556
+ } else {
557
+ native = command ? command.steering ? entry.process : await entry.nativeTurn.prepare(command.prepared) : await ensureProcess(entry, input);
558
+ }
559
+ } catch (error) {
560
+ // Preparation may start or resume the process, but has not written this
561
+ // authored input. Keep its failure distinct from a lost native receipt.
562
+ throw Object.assign(error, { delivery: "not-sent" });
563
+ }
442
564
  if (command && !command.steering) command.signal.throwIfAborted();
443
565
  const steering = command ? command.steering === true : Boolean(entry.turn?.active);
566
+ const sendNow = steering && native.client.supportsSendNow?.() === true;
567
+ if (input.signal?.aborted) throw Object.assign(createError("The goal command was stopped before delivery."), { delivery: "not-sent" });
444
568
  if (steering) {
445
569
  if (command) {
446
570
  await command.beforeDispatch({ threadId: entry.id });
@@ -448,11 +572,13 @@ export function createClaudeConversationOwner({
448
572
  }
449
573
  try {
450
574
  await entry.nativeTurn.interruptGeneration(command ? entry.process.client : native.client,
451
- command ? { timeoutMs: command.timeoutMs } : undefined);
452
- if (command) command.interrupted = true;
575
+ { ...(command ? { timeoutMs: command.timeoutMs } : {}), sendNow });
576
+ if (command) command.interrupted = !sendNow;
453
577
  } catch (error) {
454
578
  if (!command) await entry.nativeTurn.stopProcess(error.message);
455
- throw error;
579
+ // Interruption precedes this input's pipe write. Preserve that definite
580
+ // non-delivery so a failed interrupt cannot strand an attempted receipt.
581
+ throw Object.assign(error, { delivery: "not-sent" });
456
582
  }
457
583
  }
458
584
  const prompt = command ? undefined : await preparation.message(entry, input, { context, message, steering, renewal });
@@ -524,8 +650,9 @@ export function createClaudeConversationOwner({
524
650
  try {
525
651
  if (command) command.attempted = true;
526
652
  else await input.onPromptSending?.({ threadId: entry.id, displayAttachments: input.displayAttachments, turnMetadata: actorMetadata });
653
+ if (input.signal?.aborted) throw Object.assign(createError("The goal command was stopped before delivery."), { delivery: "not-sent" });
527
654
  await entry.nativeTurn.send(command ? entry.process.client : native.client, command ? preparation.message(entry, input) : prompt,
528
- { messageId: uuid, accept, ...(command ? { timeoutMs: command.timeoutMs } : {}) });
655
+ { messageId: uuid, accept, sendNow, ...(command ? { timeoutMs: command.timeoutMs } : {}) });
529
656
  } catch (error) {
530
657
  if (!command) await entry.nativeTurn.stopProcess(error.message);
531
658
  throw error;
@@ -547,12 +674,26 @@ export function createClaudeConversationOwner({
547
674
  }
548
675
 
549
676
  async function read(entry, input = {}) {
550
- if (entry.executionId && !entry.process) await entry.nativeTurn.stopProcess(disconnectedMessage);
677
+ if (entry.executionId && !entry.process && !hasPendingClaudeStartup(entry)) await entry.nativeTurn.stopProcess(disconnectedMessage);
551
678
  const history = await readHistory(entry);
552
679
  const messages = new Map(history.messages.map(message => [message.id, message]));
680
+ const savedOutputs = new Map(history.messages.filter(message => message.outputId).map(message => [message.outputId, message]));
553
681
  const current = entry.nativeTurn.read();
554
- for (const message of current.messages) messages.set(message.id, message);
555
- return { ...entry.nativeTurn.readResult(), ok: true, messages: [...messages.values()], text: current.text || history.text,
682
+ for (const message of current.messages) {
683
+ const saved = savedOutputs.get(message.outputId);
684
+ if (saved && saved.id !== message.id && saved.userId === message.userId) {
685
+ // Native history can contain the completed block before its frame has
686
+ // drained through live callbacks. Keep that text once, retaining a
687
+ // verified Stop's commentary classification rather than inventing a FINAL.
688
+ messages.set(saved.id, { ...saved,
689
+ ...(current.status === "interrupted" && message.complete && message.role === "commentary" ? { role: "commentary" } : {}) });
690
+ } else messages.set(message.id, { ...messages.get(message.id), ...message });
691
+ }
692
+ const requestIds = new Map((input.requestMessageIds || [input.messageId]).filter(id => typeof id === "string" && id)
693
+ .map(id => [claudeNativeMessageId(id), id]));
694
+ return { ...entry.nativeTurn.readResult(), ok: true,
695
+ messages: [...messages.values()].map(message => ({ ...message,
696
+ ...(message.userId ? { requestMessageId: requestIds.get(message.userId) || "" } : {}) })), text: current.text || history.text,
556
697
  admitted: history.userIds.includes(claudeNativeMessageId(input.messageId || "")) };
557
698
  }
558
699
 
@@ -686,7 +827,7 @@ export function createClaudeConversationOwner({
686
827
 
687
828
  async function recover(restoredEntries) {
688
829
  for (const entry of restoredEntries) {
689
- if (!entry.process && (entry.executionId || entry.turn?.active)) await entry.nativeTurn.stopProcess(disconnectedMessage);
830
+ if (!entry.process && !hasPendingClaudeStartup(entry) && (entry.executionId || entry.turn?.active)) await entry.nativeTurn.stopProcess(disconnectedMessage);
690
831
  }
691
832
  }
692
833
 
@@ -715,18 +856,58 @@ export function createClaudeConversationOwner({
715
856
 
716
857
  async function readGoal(entry) {
717
858
  await bindAccount(entry);
718
- return { status: "available", threadId: entry.id, goal: await entry.goals.read() };
859
+ const history = await readHistory(entry);
860
+ return { status: "available", threadId: entry.id, goal: await entry.goals.read(history), goalCommandId: history.goalCommandId || null };
719
861
  }
720
862
 
721
863
  async function updateGoal(entry, input, operations) {
722
864
  await bindAccount(entry);
723
- try { await entry.goals.update(input, operations); }
724
- catch (error) {
725
- if (error.code !== "assistant_claude_goal_failed") throw error;
726
- throw createError(error.message);
865
+ const call = applicationCall.getStore();
866
+ const selfStop = ["pause", "block"].includes(input.action) && call?.entry === entry &&
867
+ call.toolUseId && currentApplicationCommand(entry, call.command, call.owner);
868
+ const messageId = input.messageId || randomUUID();
869
+ await entry.goals.update(input, { ...operations,
870
+ ...(selfStop ? { interrupt: async () => {} } : {}),
871
+ send: command => operations?.send ? operations.send(command) : send(entry, { ...command, messageId }) });
872
+ if (selfStop && currentApplicationCommand(entry, call.command, call.owner)) {
873
+ // This scope identifies the already-authorized native invocation; it does
874
+ // not grant tool authority. Retain its exact process until its tool_result
875
+ // arrives instead of interrupting the MCP request that is returning here.
876
+ const pending = { process: call.owner.process, executionId: call.owner.executionId, toolUseId: call.toolUseId };
877
+ pending.stop = (reason = "") => {
878
+ if (entry.goalToolStop !== pending || pending.stopping) return;
879
+ clearTimeout(pending.timer);
880
+ if (entry.process !== pending.process || entry.executionId !== pending.executionId) {
881
+ entry.goalToolStop = null;
882
+ return;
883
+ }
884
+ pending.stopping = true;
885
+ const work = reason || !entry.turn?.active ? entry.nativeTurn.stopProcess(reason) : interrupt(entry);
886
+ void work.catch(error => call.command.failure.reject(error));
887
+ };
888
+ entry.goalToolStop = pending;
889
+ pending.timer = setTimeout(() => pending.stop("Claude did not acknowledge its goal tool response before the time limit."), 30_000);
890
+ pending.timer.unref();
891
+ } else if (!selfStop && ["pause", "block"].includes(input.action)) await entry.nativeTurn.stopProcess();
892
+ let result;
893
+ if (!["pause", "block"].includes(input.action)) {
894
+ const deadline = Date.now() + (operations?.attachmentTimeoutMs ?? 30_000);
895
+ for (;;) {
896
+ const history = await readHistory(entry);
897
+ if (history.goalCommandId === claudeNativeMessageId(messageId)) {
898
+ if (entry.pausedGoal || entry.blockedGoal) { entry.pausedGoal = null; entry.blockedGoal = null; await store.save(entry); }
899
+ result = { status: "available", threadId: entry.id, goal: await entry.goals.read(history), goalCommandId: history.goalCommandId };
900
+ break;
901
+ }
902
+ if (operations?.signal?.aborted) throw operations.signal.reason;
903
+ if (Date.now() >= deadline) throw Object.assign(failure(
904
+ "Claude accepted the goal command, but its goal attachment is not available yet. Check its original receipt before trying again.",
905
+ "assistant_claude_goal_attachment_pending"), { delivery: "accepted", statusCode: 503 });
906
+ await delay(Math.min(250, Math.max(1, deadline - Date.now())), undefined, { signal: operations?.signal });
907
+ }
727
908
  }
728
909
  await onEvent(entry, { type: "goal-updated" });
729
- return { ok: true, status: "available", threadId: entry.id, goal: await entry.goals.read() };
910
+ return { ok: true, ...(result || await readGoal(entry)) };
730
911
  }
731
912
 
732
913
  function renewal(entry, input, options, { context, acquire }) {
@@ -774,7 +955,7 @@ export function createClaudeConversationOwner({
774
955
  entry.command = active;
775
956
  entry.toolSchemas = active.tools?.schemas || [];
776
957
  }
777
- const value = await send(entry, { ...command.input.nativeMessage, onPromptSending: command.beforeDispatch,
958
+ const value = await send(entry, { ...command.input.nativeMessage, signal: command.input.signal, onPromptSending: command.beforeDispatch,
778
959
  onNativeReceipt: command.onNativeReceipt, onReceiptPersisted: command.onReceiptPersisted }, { applicationCommand });
779
960
  const completion = value.conversationTurn ? entry.completion?.promise : null;
780
961
  const delivered = { value, completion };
@@ -811,7 +992,7 @@ export function createClaudeConversationOwner({
811
992
  timeoutMs, steering: true, interrupted: false, attempted: false, admitted: false };
812
993
  try { await dispatch(control, { input, context, signal }, { acquire, entry, dispatch: nativeDispatch }); }
813
994
  catch (error) {
814
- if (nativeDispatch.attempted && !nativeDispatch.admitted) error.delivery = "uncertain";
995
+ if (nativeDispatch.attempted && !nativeDispatch.admitted && error.delivery !== "not-sent") error.delivery = "uncertain";
815
996
  if (nativeDispatch.interrupted || nativeDispatch.attempted) completion.reject(error);
816
997
  throw error;
817
998
  }
@@ -903,13 +1084,13 @@ export function createClaudeConversationOwner({
903
1084
  }
904
1085
  if (control.current === active) control.current = null;
905
1086
  if (problem) {
906
- if (binding && nativeDispatch.attempted && !nativeDispatch.admitted) problem.delivery = "uncertain";
1087
+ if (binding && nativeDispatch.attempted && !nativeDispatch.admitted && problem.delivery !== "not-sent") problem.delivery = "uncertain";
907
1088
  throw problem;
908
1089
  }
909
1090
  if (!binding) return { value: delivered.value };
910
1091
  }
911
1092
 
912
- 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,
1093
+ return Object.freeze({ applicationToolsSupported: true, entries, closingSessions, get closing() { return closing; }, createAccountQueries, open, acquire, createConversation, snapshot, readRetainedTurn, readFinalAssistantResult, readHistory, ensureReady, ensureProcess, readContextUsage, stopForTerminal, prepareTerminal, send, startTurn,
913
1094
  interrupt, read, inspectAdmission, hasActiveTemporaryConversation, hasActiveConversation, retireConversationHistory, reconcileSessions, restoreSessionEntries, closeSession, closeProject, invalidateRuntimes, drain, forget, close, deleteConversation, recover, stopSelected, wait, stopConversation,
914
1095
  readGoal, updateGoal, renewal, run, steer, cancel });
915
1096
  }
@@ -1018,6 +1199,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1018
1199
  }
1019
1200
 
1020
1201
  async function stopProcess(reason = "", { preparing = false } = {}) {
1202
+ clearTimeout(entry.goalToolStop?.timer);
1203
+ entry.goalToolStop = null;
1021
1204
  if (!preparing) entry.preparationRevision++;
1022
1205
  invalidateFinalAssistantResult();
1023
1206
  // Runtime bindings commit execution release before dropping the handle.
@@ -1143,6 +1326,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1143
1326
  }
1144
1327
 
1145
1328
  async function publishMessage(message) {
1329
+ if (!message.userId && currentCommandId) message = { ...message, userId: currentCommandId };
1146
1330
  messages.set(message.id, message);
1147
1331
  const owner = acceptedInput;
1148
1332
  const completeAssistant = message.complete && message.role === "assistant";
@@ -1191,7 +1375,10 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1191
1375
  if (frame.type === "command_lifecycle" || frame.type === "user") {
1192
1376
  const uuid = frame.type === "user" ? frame.uuid : frame.command_uuid;
1193
1377
  if (frame.type === "user" && !inFlight.has(uuid) && !admissions.has(uuid)) return;
1194
- if (frame.state === "started" || frame.type === "user") currentCommandId = uuid;
1378
+ // Native notifications have their own lifecycle UUIDs, including work
1379
+ // folded into this input. They must not steal its receipt/result owner.
1380
+ if ((frame.state === "started" || frame.type === "user") && inFlight.has(uuid)) currentCommandId = uuid;
1381
+ if (frame.state === "completed") inFlight.delete(uuid);
1195
1382
  if (frame.state === "cancelled" && steering) {
1196
1383
  inFlight.delete(uuid);
1197
1384
  steering.resolve();
@@ -1225,15 +1412,23 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1225
1412
  }
1226
1413
  }
1227
1414
  if (frame.subtype === "local_command_output" && frame.content) {
1228
- await publishMessage({ id: `claude_${frame.uuid}`, role: "commentary", text: frame.content, complete: true });
1415
+ await publishMessage({ id: `claude_${frame.uuid}`, role: "commentary", text: frame.content, complete: true,
1416
+ data: { native: { engineId: "claude", type: "system", subtype: "local_command_output" } } });
1229
1417
  }
1418
+ const previousTaskCount = tasks.size;
1230
1419
  if (frame.subtype === "background_tasks_changed") {
1231
1420
  tasks.clear();
1232
1421
  for (const task of frame.tasks || []) if (task.task_id && !task.ambient) tasks.add(task.task_id);
1233
1422
  }
1234
1423
  if (frame.subtype === "task_started" && frame.task_id && !frame.ambient) tasks.add(frame.task_id);
1235
1424
  if (["task_notification", "task_updated"].includes(frame.subtype) &&
1236
- ["completed", "failed", "stopped", "killed"].includes(frame.status || frame.patch?.status)) tasks.delete(frame.task_id);
1425
+ ["completed", "failed", "stopped", "killed"].includes(frame.status || frame.patch?.status)) {
1426
+ tasks.delete(frame.task_id);
1427
+ }
1428
+ // Inventory removal can precede the task notification and the main model's
1429
+ // response to it. A foreground result written while that worker was live
1430
+ // cannot complete this follow-up or retire its admitted application tools.
1431
+ if (tasks.size < previousTaskCount) outcome = null;
1237
1432
  if (["background_tasks_changed", "task_started", "task_notification", "task_updated"].includes(frame.subtype)) {
1238
1433
  if (tasks.size && turnId && !active && !cancelling) {
1239
1434
  generation++;
@@ -1249,7 +1444,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1249
1444
  if (event.type === "message_start") messageId = event.message?.id || frame.uuid;
1250
1445
  const id = `claude_${messageId}_${event.index}`;
1251
1446
  if (event.type === "content_block_start" && ["text", "thinking"].includes(event.content_block?.type)) {
1252
- messages.set(id, { id, outputId: id, complete: false, role: event.content_block.type === "thinking" ? "thinking" : "assistant",
1447
+ messages.set(id, { id, outputId: id, userId: currentCommandId, complete: false, role: event.content_block.type === "thinking" ? "thinking" : "assistant",
1253
1448
  text: event.content_block.text || event.content_block.thinking || "" });
1254
1449
  } else if (event.type === "content_block_delta") {
1255
1450
  const block = messages.get(id);
@@ -1274,6 +1469,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1274
1469
  await flushAssistantMessages(apiMessageId, "commentary");
1275
1470
  }
1276
1471
  for (const block of claudeMessageBlocks(frame)) {
1472
+ block.userId = currentCommandId;
1277
1473
  checkOutput(block.text, block.role);
1278
1474
  // Native split frames arrive before their block's stop event. Keep that
1279
1475
  // existing live identity without replacing the saved frame UUID.
@@ -1283,7 +1479,10 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1283
1479
  message.id.startsWith(prefix) && /^\d+$/u.test(message.id.slice(prefix.length)));
1284
1480
  if (live.length === 1 && (!Object.hasOwn(frame, "apiBlockIndex") ||
1285
1481
  Number.isSafeInteger(frame.apiBlockIndex) && frame.apiBlockIndex >= 0 &&
1286
- live[0].id === `${prefix}${frame.apiBlockIndex}`)) block.outputId = live[0].id;
1482
+ live[0].id === `${prefix}${frame.apiBlockIndex}`)) {
1483
+ block.outputId = live[0].id;
1484
+ block.userId = live[0].userId || block.userId;
1485
+ }
1287
1486
  }
1288
1487
  if (apiMessageId && block.role === "assistant") {
1289
1488
  const role = assistantRoles.get(apiMessageId);
@@ -1308,7 +1507,10 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1308
1507
  const message = failed ? (frame.errors || []).join("; ") || output || frame.subtype : "";
1309
1508
  result = failed ? "" : output;
1310
1509
  if (result && ![...messages.values()].some((block) => block.complete && block.role === "assistant" && block.text === result)) {
1311
- await publishMessage({ id: `claude_${frame.uuid || turnId}_result`, role: "assistant", text: result, complete: true });
1510
+ await publishMessage({ id: `claude_${frame.uuid || turnId}_result`, role: "assistant", text: result, complete: true,
1511
+ ...(typeof frame.local_command === "string" && frame.local_command ? {
1512
+ data: { native: { engineId: "claude", type: "result", command: frame.local_command, output: "stdout" } }
1513
+ } : {}) });
1312
1514
  }
1313
1515
  inFlight.delete(currentCommandId);
1314
1516
  if (steering) {
@@ -1320,7 +1522,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1320
1522
  await publish({ type: "diagnostic", stage: "native_result", status: outcome.status,
1321
1523
  terminalReason: frame.terminal_reason, active, pendingCommands: inFlight.size, backgroundTasks: tasks.size });
1322
1524
  }
1323
- if ((frame.type === "system" || frame.type === "result") &&
1525
+ if ((frame.type === "system" || frame.type === "result" || frame.type === "command_lifecycle" && frame.state === "completed") &&
1324
1526
  active && !cancelling && outcome && inFlight.size === 0 && tasks.size === 0) {
1325
1527
  active = false;
1326
1528
  phase = "";
@@ -1348,6 +1550,18 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1348
1550
  admissions.clear();
1349
1551
  inFlight.clear();
1350
1552
  tasks.clear();
1553
+ // Verified process cleanup can precede Claude's terminal generation frame.
1554
+ // Retain already displayed text for the existing transcript reader, with
1555
+ // its original input/output identity and no completed-answer receipt.
1556
+ for (const pending of pendingAssistantMessages.values()) for (const block of pending) {
1557
+ if (block.outputId && block.outputId !== block.id) messages.delete(block.outputId);
1558
+ messages.set(block.id, { ...block, role: "commentary" });
1559
+ }
1560
+ for (const [id, block] of messages) {
1561
+ if (block.complete === false && block.role === "assistant" && block.text) {
1562
+ messages.set(id, { ...block, complete: true, role: "commentary" });
1563
+ }
1564
+ }
1351
1565
  pendingAssistantMessages.clear();
1352
1566
  assistantRoles.clear();
1353
1567
  steering?.reject(failure(reason));
@@ -1404,8 +1618,12 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1404
1618
  acceptedInput = null;
1405
1619
  clearFinalAssistantResult();
1406
1620
  },
1407
- async send(client, message, { messageId: uuid, accept, timeoutMs = 30_000 } = {}) {
1408
- if (!active || stopped || cancelling) throw failure("Claude has no active turn for this message.");
1621
+ async send(client, message, { messageId: uuid, accept, timeoutMs = 30_000, sendNow = false } = {}) {
1622
+ if (!active || stopped || cancelling) throw Object.assign(
1623
+ failure("Claude has no active turn for this message.", !active && !stopped && !cancelling
1624
+ ? "conversation_steer_turn_finished" : "assistant_claude_turn_failed"),
1625
+ { delivery: "not-sent", ...(!active && !stopped && !cancelling ? { statusCode: 409 } : {}) }
1626
+ );
1409
1627
  if (!uuid || typeof accept !== "function" || admissions.has(uuid)) throw new TypeError("A distinct message id and admission writer are required.");
1410
1628
  clearFinalAssistantResult();
1411
1629
  acceptedInput = null;
@@ -1421,7 +1639,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1421
1639
  )), timeoutMs);
1422
1640
  admissions.set(uuid, { ...admitted, accept, timer, receiptOwner });
1423
1641
  try {
1424
- await Promise.all([client.send(message, { messageId: uuid, sessionId: conversationId }), admitted.promise]);
1642
+ await Promise.all([client.send(message, { messageId: uuid, sessionId: conversationId, ...(sendNow ? { sendNow: true } : {}) }), admitted.promise]);
1425
1643
  } catch (error) {
1426
1644
  if (inputOwner === receiptOwner) invalidateFinalAssistantResult();
1427
1645
  throw error;
@@ -1429,17 +1647,23 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1429
1647
  clearTimeout(timer);
1430
1648
  }
1431
1649
  },
1432
- async interruptGeneration(client, { timeoutMs = 30_000 } = {}) {
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) {
1650
+ async interruptGeneration(client, { timeoutMs = 30_000, sendNow = false } = {}) {
1651
+ if (!active || steering || cancelling) {
1652
+ const completed = !active && !steering && !stopped && !cancelling && outcome?.status === "completed";
1653
+ throw Object.assign(failure("This Claude turn cannot accept steering now.", completed
1654
+ ? "conversation_steer_turn_finished" : "assistant_claude_turn_failed"),
1655
+ { delivery: "not-sent", ...(completed ? { statusCode: 409 } : {}) });
1656
+ }
1657
+ // Targeted Send now admits the new input before asking the native loop to
1658
+ // make way; unlike plain Stop it preserves background agents. A completed
1659
+ // main loop also has no further interruption result to wait for.
1660
+ if (sendNow || outcome && inFlight.size === 0) {
1437
1661
  // The next input owns completion now. A task finishing during host
1438
1662
  // preparation must not settle this turn using the previous result.
1439
1663
  outcome = null;
1440
1664
  acceptedInput = null;
1441
1665
  clearFinalAssistantResult();
1442
- await publish({ type: "diagnostic", stage: "steering_background_only", backgroundTasks: tasks.size });
1666
+ await publish({ type: "diagnostic", stage: sendNow ? "steering_send_now" : "steering_background_only", backgroundTasks: tasks.size });
1443
1667
  return;
1444
1668
  }
1445
1669
  // Streaming user input otherwise queues behind the entire running turn.
@@ -1454,7 +1678,10 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1454
1678
  await Promise.all([client.interrupt(), Promise.race([
1455
1679
  steering.promise,
1456
1680
  new Promise((_, reject) => {
1457
- timer = setTimeout(() => reject(failure("Claude did not confirm the interrupted generation.")), timeoutMs);
1681
+ timer = setTimeout(() => reject(Object.assign(failure(
1682
+ "Claude did not confirm the interrupted generation. This instruction was not sent; retry after reconnecting.",
1683
+ "assistant_claude_interrupt_timeout"
1684
+ ), { statusCode: 503 })), timeoutMs);
1458
1685
  })
1459
1686
  ])]);
1460
1687
  await publish({ type: "diagnostic", stage: "steering_confirmed", pendingCommands: inFlight.size, backgroundTasks: tasks.size });