@jskit-ai/assistant-core 0.1.225 → 0.1.227
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/examples/conversation/package.json +1 -1
- package/fixtures/responsive-assistant/ConversationFixture.vue +5 -3
- package/fixtures/responsive-assistant/DeliveryFixture.vue +9 -0
- package/package.json +1 -1
- package/src/client/conversation/AssistantConversationElement.vue +7 -4
- package/src/client/conversation/AssistantGoalControl.vue +30 -16
- package/src/client/conversation/AssistantTranscript.vue +14 -7
- package/src/client/conversation/useAssistantAttachments.js +6 -3
- package/src/server/conversation/claudeClient.js +22 -5
- package/src/server/conversation/claudeHistory.js +62 -5
- package/src/server/conversation/claudeProcess.js +13 -10
- package/src/server/conversation/claudeTurn.js +292 -60
- package/src/server/conversation/codexConfiguration.js +24 -1
- package/src/server/conversation/codexGoalCommands.js +8 -3
- package/src/server/conversation/codexTurnRecovery.js +18 -8
- package/src/server/conversation/continuity.js +54 -17
- package/src/server/conversation/providers/claudeDriver.js +6 -5
- package/src/server/conversation/providers/codexDriver.js +3 -3
- package/src/server/conversation/providers/scoped.js +31 -10
- package/src/server/conversation/runtime.js +48 -13
- package/src/server/conversation/streams.js +7 -3
- package/src/server/conversation/transcript.js +2 -1
- package/src/server/hosts/codexConfiguration.js +1 -0
- package/src/shared/conversation/longTextBlocks.js +0 -4
- package/test/assistantScroll.browser.test.js +74 -3
- package/test/claudeClient.test.js +73 -0
- package/test/claudeConversationSession.test.js +139 -4
- package/test/claudeProcess.test.js +61 -0
- package/test/claudeTurn.test.js +1330 -10
- package/test/codexConversationSession.test.js +9 -1
- package/test/codexGoal.test.js +47 -0
- package/test/codexModelCatalog.test.js +55 -0
- package/test/codexTurn.test.js +228 -0
- package/test/conversationAttachments.test.js +17 -0
- package/test/conversationRuntime.test.js +40 -0
- package/test/conversationStreaming.test.js +18 -0
- 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,38 +104,51 @@ export async function runClaudeRenewalTurn(conversation, {
|
|
|
98
104
|
}
|
|
99
105
|
}
|
|
100
106
|
|
|
101
|
-
export function createClaudeConversationGoals({ conversationId, readHistory, readState, interrupt, send }) {
|
|
102
|
-
|
|
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:
|
|
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)) {
|
|
127
146
|
throw goalError("Enter a goal condition of up to 4,000 characters.");
|
|
128
147
|
}
|
|
129
|
-
if (
|
|
130
|
-
throw goalError("
|
|
148
|
+
if (action === "set" && goal && goal.status !== "complete") {
|
|
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);
|
|
@@ -264,7 +301,7 @@ export function createClaudeConversationOwner({
|
|
|
264
301
|
const providerId = account.providerId;
|
|
265
302
|
if (entry.accountIdentity !== identity) entry.nativeTurn.invalidateFinalAssistantResult();
|
|
266
303
|
try {
|
|
267
|
-
await bindClaudeConversationAccount(entry, { identity, providerId,
|
|
304
|
+
await bindClaudeConversationAccount(entry, { identity, providerId, allowAccountChange: account.allowAccountChange === true,
|
|
268
305
|
stop: (entry, reason) => entry.nativeTurn.stopProcess(reason, { preparing: true }), save: saveAccount });
|
|
269
306
|
} catch (failure) {
|
|
270
307
|
if (failure.code !== "claude_account_changed") throw failure;
|
|
@@ -273,10 +310,13 @@ export function createClaudeConversationOwner({
|
|
|
273
310
|
}
|
|
274
311
|
|
|
275
312
|
async function ensureReady(entry) {
|
|
276
|
-
if (entry.process)
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 } :
|
|
452
|
-
if (command) command.interrupted =
|
|
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
|
-
|
|
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)
|
|
555
|
-
|
|
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
|
-
|
|
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
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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,
|
|
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
|
-
|
|
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))
|
|
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}`))
|
|
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
|
|
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,12 +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)
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
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) {
|
|
1661
|
+
// The next input owns completion now. A task finishing during host
|
|
1662
|
+
// preparation must not settle this turn using the previous result.
|
|
1663
|
+
outcome = null;
|
|
1664
|
+
acceptedInput = null;
|
|
1665
|
+
clearFinalAssistantResult();
|
|
1666
|
+
await publish({ type: "diagnostic", stage: sendNow ? "steering_send_now" : "steering_background_only", backgroundTasks: tasks.size });
|
|
1438
1667
|
return;
|
|
1439
1668
|
}
|
|
1440
1669
|
// Streaming user input otherwise queues behind the entire running turn.
|
|
@@ -1449,7 +1678,10 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1449
1678
|
await Promise.all([client.interrupt(), Promise.race([
|
|
1450
1679
|
steering.promise,
|
|
1451
1680
|
new Promise((_, reject) => {
|
|
1452
|
-
timer = setTimeout(() => reject(failure(
|
|
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);
|
|
1453
1685
|
})
|
|
1454
1686
|
])]);
|
|
1455
1687
|
await publish({ type: "diagnostic", stage: "steering_confirmed", pendingCommands: inFlight.size, backgroundTasks: tasks.size });
|