@jskit-ai/assistant-core 0.2.6 → 0.2.8
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 +5 -5
- package/package.json +18 -7
- package/src/client/browser.js +21 -0
- package/src/client/conversation/AssistantAttachmentQueue.vue +25 -108
- package/src/client/conversation/AssistantComposerActions.vue +1 -1
- package/src/client/conversation/AssistantConversationElement.vue +14 -52
- package/src/client/conversation/AssistantGoalControl.vue +18 -12
- package/src/client/conversation/AssistantPromptInput.vue +6 -91
- package/src/client/conversation/AssistantTranscript.css +505 -0
- package/src/client/conversation/AssistantTranscript.vue +24 -1197
- package/src/client/conversation/attachmentFiles.js +158 -0
- package/src/client/conversation/attachmentQueueItems.js +121 -0
- package/src/client/conversation/attachmentUploadRows.js +265 -0
- package/src/client/conversation/conversationDisplay.js +54 -0
- package/src/client/conversation/messageDelivery.js +13 -9
- package/src/client/conversation/retainedConversation.js +14 -3
- package/src/client/conversation/submitText.js +13 -2
- package/src/client/conversation/transcriptEntries.js +225 -0
- package/src/client/conversation/useAssistantAttachments.js +86 -388
- package/src/client/conversation/useAssistantQuestions.js +16 -3
- package/src/client/conversation/useAssistantSuggestions.js +16 -2
- package/src/client/conversation/useScrollToBottom.js +17 -19
- package/src/client/conversation/useTextareaAutoGrow.js +115 -0
- package/src/client/conversation/useTranscriptScrollFollow.js +525 -0
- package/src/client/lib/assistantApi.js +192 -161
- package/src/client/types.js +110 -0
- package/src/server/conversation/attachments.js +21 -6
- package/src/server/conversation/boundedToolLoop.js +1 -0
- package/src/server/conversation/claudeClient.js +71 -20
- package/src/server/conversation/claudeCommandRun.js +191 -0
- package/src/server/conversation/claudeHistory.js +7 -0
- package/src/server/conversation/claudeNativeTurn.js +708 -0
- package/src/server/conversation/claudeProcess.js +186 -112
- package/src/server/conversation/claudeProcessStart.js +214 -0
- package/src/server/conversation/claudePromptDelivery.js +183 -0
- package/src/server/conversation/claudeSessionCleanup.js +248 -0
- package/src/server/conversation/claudeTools.js +15 -3
- package/src/server/conversation/claudeTurn.js +212 -1388
- package/src/server/conversation/codexAccountReader.js +168 -0
- package/src/server/conversation/codexAuthPreflight.js +5 -4
- package/src/server/conversation/codexClient.js +18 -6
- package/src/server/conversation/codexConfiguration.js +8 -844
- package/src/server/conversation/codexConversationStops.js +377 -0
- package/src/server/conversation/codexConversationStore.js +119 -85
- package/src/server/conversation/codexConversationTurns.js +378 -0
- package/src/server/conversation/codexConversations.js +134 -866
- package/src/server/conversation/codexDetachedConversation.js +203 -0
- package/src/server/conversation/codexDetachedTurn.js +145 -104
- package/src/server/conversation/codexEvents.js +5 -200
- package/src/server/conversation/codexGoalCommands.js +14 -4
- package/src/server/conversation/codexHelperExecution.js +57 -57
- package/src/server/conversation/codexHelperThreadLedger.js +30 -288
- package/src/server/conversation/codexHelperThreadRecord.js +304 -0
- package/src/server/conversation/codexHelperThreadRecovery.js +54 -76
- package/src/server/conversation/codexHelperThreads.js +94 -93
- package/src/server/conversation/codexHistoryAdapter.js +13 -8
- package/src/server/conversation/codexIsolation.js +672 -0
- package/src/server/conversation/codexLiveItems.js +379 -0
- package/src/server/conversation/codexMessageCommands.js +68 -286
- package/src/server/conversation/codexMessageDelivery.js +46 -37
- package/src/server/conversation/codexModelCatalog.js +86 -7
- package/src/server/conversation/codexNotificationObserver.js +81 -88
- package/src/server/conversation/codexNotificationQueue.js +21 -10
- package/src/server/conversation/codexObservationControl.js +105 -89
- package/src/server/conversation/codexOutput.js +84 -419
- package/src/server/conversation/codexProcess.js +29 -1053
- package/src/server/conversation/codexProcessLeader.js +1 -0
- package/src/server/conversation/codexPromptDispatch.js +241 -0
- package/src/server/conversation/codexProvider.js +147 -1807
- package/src/server/conversation/codexProviderAuth.js +348 -0
- package/src/server/conversation/codexProviderInvalidation.js +371 -0
- package/src/server/conversation/codexProviderInventories.js +437 -0
- package/src/server/conversation/codexProviderOwner.js +149 -957
- package/src/server/conversation/codexProviderRelease.js +465 -0
- package/src/server/conversation/codexProviderSelection.js +26 -15
- package/src/server/conversation/codexProviderThreads.js +698 -0
- package/src/server/conversation/codexProviderTurns.js +453 -0
- package/src/server/conversation/codexReasoning.js +32 -23
- package/src/server/conversation/codexRenewalCommands.js +18 -6
- package/src/server/conversation/codexRenewalThreads.js +7 -22
- package/src/server/conversation/codexResultSettlement.js +70 -88
- package/src/server/conversation/codexRunJournal.js +91 -97
- package/src/server/conversation/codexRuntime.js +426 -0
- package/src/server/conversation/codexRuntimeInventory.js +113 -0
- package/src/server/conversation/codexRuntimeLifecycle.js +39 -13
- package/src/server/conversation/codexRuntimeStart.js +431 -0
- package/src/server/conversation/codexRuntimeStop.js +324 -0
- package/src/server/conversation/codexSessionCleanup.js +25 -10
- package/src/server/conversation/codexSessionReconciliation.js +21 -7
- package/src/server/conversation/codexThreadReadiness.js +49 -52
- package/src/server/conversation/codexThreadSnapshot.js +210 -0
- package/src/server/conversation/codexThreadStatus.js +48 -62
- package/src/server/conversation/codexTurn.js +171 -55
- package/src/server/conversation/codexTurnActivity.js +210 -0
- package/src/server/conversation/codexTurnControl.js +44 -48
- package/src/server/conversation/codexTurnEndings.js +610 -0
- package/src/server/conversation/codexTurnRecovery.js +97 -848
- package/src/server/conversation/codexTurnState.js +138 -111
- package/src/server/conversation/completedResponses.js +227 -0
- package/src/server/conversation/configuration.js +2 -2
- package/src/server/conversation/continuity.js +43 -6
- package/src/server/conversation/conversationDelivery.js +394 -0
- package/src/server/conversation/conversationEntries.js +365 -0
- package/src/server/conversation/conversationEvents.js +192 -0
- package/src/server/conversation/conversationGoals.js +187 -0
- package/src/server/conversation/conversationHandles.js +237 -0
- package/src/server/conversation/conversationReads.js +206 -0
- package/src/server/conversation/conversationRefusals.js +58 -0
- package/src/server/conversation/conversationReplacement.js +254 -0
- package/src/server/conversation/conversationSending.js +273 -0
- package/src/server/conversation/conversationStopping.js +121 -0
- package/src/server/conversation/conversationTurnDelivery.js +413 -0
- package/src/server/conversation/conversationTurns.js +511 -0
- package/src/server/conversation/deliveryTiming.js +25 -3
- package/src/server/conversation/fileStorage.js +11 -6
- package/src/server/conversation/hookBridge.js +14 -3
- package/src/server/conversation/index.js +1 -1
- package/src/server/conversation/localExecution.js +82 -625
- package/src/server/conversation/nativeCommandCallbacks.js +10 -0
- package/src/server/conversation/nativeConversationLifecycle.js +379 -0
- package/src/server/conversation/nativeHistoryExport.js +27 -18
- package/src/server/conversation/normalize.js +9 -0
- package/src/server/conversation/openCodeClient.js +295 -366
- package/src/server/conversation/openCodeNativeHistory.js +141 -0
- package/src/server/conversation/openCodeNativeSession.js +79 -0
- package/src/server/conversation/openCodeProcess.js +109 -53
- package/src/server/conversation/openCodeProcessOwner.js +565 -0
- package/src/server/conversation/openCodeRuntime.js +111 -1814
- package/src/server/conversation/openCodeSessionTurns.js +547 -0
- package/src/server/conversation/openCodeTemporaryConversations.js +394 -0
- package/src/server/conversation/openCodeTools.js +28 -14
- package/src/server/conversation/openCodeTurn.js +80 -74
- package/src/server/conversation/openCodeTurnMonitor.js +388 -0
- package/src/server/conversation/processIdentity.js +582 -0
- package/src/server/conversation/provider.js +21 -6
- package/src/server/conversation/providers/api.js +268 -107
- package/src/server/conversation/providers/claude.js +29 -6
- package/src/server/conversation/providers/claudeDriver.js +325 -250
- package/src/server/conversation/providers/codex.js +69 -33
- package/src/server/conversation/providers/codexDriver.js +503 -1081
- package/src/server/conversation/providers/codexDriverConnection.js +331 -0
- package/src/server/conversation/providers/codexDriverTurns.js +464 -0
- package/src/server/conversation/providers/opencode.js +30 -4
- package/src/server/conversation/providers/opencodeDriver.js +321 -659
- package/src/server/conversation/providers/opencodeStandalone.js +475 -0
- package/src/server/conversation/providers/scoped.js +64 -19
- package/src/server/conversation/runtime.js +171 -2863
- package/src/server/conversation/storage.js +91 -50
- package/src/server/conversation/streams.js +15 -2
- package/src/server/conversation/structuredOutput.js +7 -11
- package/src/server/conversation/systemPrompt.js +13 -1
- package/src/server/conversation/tools.js +47 -15
- package/src/server/conversation/transcript.js +49 -20
- package/src/server/hosts/claudeTurn.js +2 -4
- package/src/server/hosts/codexConfiguration.js +6 -8
- package/src/server/hosts/codexEvents.js +4 -2
- package/src/server/hosts/codexProcess.js +1 -1
- package/src/server/hosts/codexProvider.js +11 -9
- package/src/server/hosts/codexTurn.js +3 -3
- package/src/server/hosts/openCodeClient.js +4 -2
- package/src/server/index.js +0 -2
- package/src/server/lib/aiConnectionClient.js +35 -25
- package/src/server/lib/ndjson.js +1 -0
- package/src/server/lib/providers/anthropicClient.js +15 -6
- package/src/server/lib/providers/common.js +10 -6
- package/src/server/lib/providers/openAiCompatibleClient.js +40 -23
- package/src/server/lib/serviceToolCatalog.js +29 -283
- package/src/server/lib/serviceToolEntries.js +270 -0
- package/src/server/types.js +78 -0
- package/src/shared/assistantResource.js +0 -23
- package/src/shared/conversation/answerChoiceSugar.js +2 -14
- package/src/shared/conversation/messageInputSugar.js +18 -0
- package/src/shared/conversation/numberedQuestionSugar.js +6 -16
- package/src/shared/conversation/pagination.js +1 -1
- package/src/shared/conversation/questions.js +1 -0
- package/src/shared/conversation/streaming.js +1 -0
- package/src/shared/index.js +0 -1
- package/src/testing/conversationStorageContract.js +20 -0
- package/test/aiConnectionClient.test.js +42 -1
- package/test/apiConversationDriver.test.js +217 -38
- package/test/assistantBoundedToolLoop.test.js +1 -1
- package/test/claudeConversationSession.test.js +15 -11
- package/test/claudeInstructionResume.test.js +1 -1
- package/test/claudeProcess.test.js +9 -9
- package/test/claudeTurn.test.js +21 -8
- package/test/codexAccountChange.test.js +5 -3
- package/test/codexConversationSession.test.js +22 -21
- package/test/codexEvents.test.js +3 -1
- package/test/codexHelperDeletion.test.js +3 -7
- package/test/codexIsolation.test.js +6 -6
- package/test/codexModelCatalog.test.js +6 -7
- package/test/codexProcess.test.js +3 -3
- package/test/codexThreadSelection.test.js +4 -1
- package/test/codexTurn.test.js +29 -20
- package/test/conversationApiFacilities.test.js +11 -9
- package/test/conversationAttachments.test.js +3 -3
- package/test/conversationConfiguration.test.js +4 -4
- package/test/conversationNativeStorage.test.js +11 -10
- package/test/conversationReplacement.test.js +13 -13
- package/test/conversationRuntime.test.js +9 -5
- package/test/conversationSession.test.js +78 -22
- package/test/conversationTools.test.js +47 -26
- package/test/fileConversationStorage.test.js +10 -3
- package/test/fixtures/codexHelperThreadLedger.js +3 -3
- package/test/frameworkActions.test.js +33 -18
- package/test/localConversationExecution.test.js +4 -4
- package/test/localExecution.test.js +1 -1
- package/test/openCodeConnectionChange.test.js +3 -2
- package/test/openCodeRuntime.test.js +8 -7
- package/test/openCodeTools.test.js +1 -1
- package/test/opencodeConversationSession.test.js +4 -3
- package/test/serviceToolCatalog.test.js +3 -8
- package/src/server/lib/assistantCompletion.js +0 -190
- package/src/server/lib/assistantToolLoop.js +0 -259
|
@@ -1,24 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
-
import path from "node:path";
|
|
4
4
|
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import { claudeNativeMessageId, readClaudeHistory, requireClaudeSessionId, retireClaudeConversationHistory } from "./claudeHistory.js";
|
|
6
|
+
import { claudeTurnFailure, createClaudeConversationTurn } from "./claudeNativeTurn.js";
|
|
7
|
+
import { bindClaudeConversationAccount, claudeCodeArguments, createClaudeAccountQueries } from "./claudeProcess.js";
|
|
8
|
+
import { createClaudeCommandRun } from "./claudeCommandRun.js";
|
|
9
|
+
import { createClaudeProcessStart, hasPendingClaudeStartup } from "./claudeProcessStart.js";
|
|
10
|
+
import { createClaudePromptDelivery } from "./claudePromptDelivery.js";
|
|
11
|
+
import { createClaudeSessionCleanup } from "./claudeSessionCleanup.js";
|
|
8
12
|
import { normalizeNullableText } from "./normalize.js";
|
|
9
|
-
import { createClaudeConversationAdapter } from "./providers/claude.js";
|
|
10
|
-
|
|
11
|
-
function failure(message, code = "assistant_claude_turn_failed") {
|
|
12
|
-
return Object.assign(new Error(message), { code });
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const text = normalizeNullableText;
|
|
16
|
-
const now = () => new Date().toISOString();
|
|
17
|
-
const CLAUDE_SIGNED_OUT_MESSAGE = "Claude isn't connected. Sign in to Claude, then send your message again.";
|
|
18
|
-
|
|
19
|
-
function hasPendingClaudeStartup(entry) {
|
|
20
|
-
return entry.startupCleanup?.pending === true && entry.startupCleanup.executionId === entry.executionId;
|
|
21
|
-
}
|
|
22
13
|
|
|
23
14
|
// Claude records an admitted prompt in its history, and only a send in
|
|
24
15
|
// progress writes prompts to a live process. With neither left, a prompt
|
|
@@ -36,16 +27,9 @@ function runsNativeWork(entry) {
|
|
|
36
27
|
return Boolean(entry.sending || entry.process || entry.command || entry.stopPending || hasPendingClaudeStartup(entry));
|
|
37
28
|
}
|
|
38
29
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
export function claudeNativeMessageId(id) {
|
|
42
|
-
const value = createHash("sha256").update(String(id)).digest("hex");
|
|
43
|
-
return `${value.slice(0, 8)}-${value.slice(8, 12)}-4${value.slice(13, 16)}-8${value.slice(17, 20)}-${value.slice(20, 32)}`;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Wait on the conversation's existing completion and cleanup owner. */
|
|
30
|
+
/** Waits for the conversation's turn through its completion and cleanup owner. */
|
|
47
31
|
export async function waitForClaudeConversationTurn(conversation, input = {}, {
|
|
48
|
-
signal, defaultTimeoutMs = 180_000, maximumTimeoutMs = Infinity, createError =
|
|
32
|
+
signal, defaultTimeoutMs = 180_000, maximumTimeoutMs = Infinity, createError = claudeTurnFailure,
|
|
49
33
|
interruptedMessage = "Claude was interrupted when the application disconnected."
|
|
50
34
|
} = {}) {
|
|
51
35
|
const entry = conversation.state;
|
|
@@ -65,7 +49,7 @@ export async function waitForClaudeConversationTurn(conversation, input = {}, {
|
|
|
65
49
|
return await Promise.race([entry.completion.promise, new Promise((_, reject) => {
|
|
66
50
|
timer = setTimeout(() => {
|
|
67
51
|
timedOut = true;
|
|
68
|
-
reject(createError("Claude did not finish within the time limit."));
|
|
52
|
+
reject(createError("Claude did not finish within the time limit.", { code: "conversation_claude_turn_timeout" }));
|
|
69
53
|
}, timeoutMs);
|
|
70
54
|
})]);
|
|
71
55
|
} catch (failure) {
|
|
@@ -77,42 +61,46 @@ export async function waitForClaudeConversationTurn(conversation, input = {}, {
|
|
|
77
61
|
}
|
|
78
62
|
}
|
|
79
63
|
|
|
80
|
-
/**
|
|
64
|
+
/** Runs one renewal turn: its native receipt, completion and cleanup. */
|
|
81
65
|
export async function runClaudeRenewalTurn(conversation, {
|
|
82
66
|
clientMessageId, prompt, outputSchema, expectedThreadId, forbiddenThreadId,
|
|
83
67
|
requireFreshHistory = false, timeoutMs = 180_000
|
|
84
68
|
}, {
|
|
85
|
-
createError =
|
|
69
|
+
createError = claudeTurnFailure, unreadableCode = "assistant_claude_turn_unreadable",
|
|
86
70
|
failedCode = "assistant_claude_turn_failed", acceptedField = "inputAccepted"
|
|
87
71
|
} = {}) {
|
|
88
72
|
const uuid = claudeNativeMessageId(clientMessageId);
|
|
89
73
|
const entry = conversation.state;
|
|
90
74
|
if ((expectedThreadId && expectedThreadId !== entry.id) ||
|
|
91
75
|
(requireFreshHistory && forbiddenThreadId === entry.id)) {
|
|
92
|
-
throw createError("The Claude renewal conversation does not match the expected native history.");
|
|
76
|
+
throw createError("The Claude renewal conversation does not match the expected native history.", { code: "conversation_claude_renewal_history_mismatch" });
|
|
93
77
|
}
|
|
94
78
|
const history = await conversation.readHistory();
|
|
95
|
-
if (requireFreshHistory && history.userIds.some((id) => id !== uuid))
|
|
79
|
+
if (requireFreshHistory && history.userIds.some((id) => id !== uuid)) {
|
|
80
|
+
throw createError("The successor Claude conversation already contains unrelated messages.", { code: "conversation_claude_renewal_unrelated_history" });
|
|
81
|
+
}
|
|
96
82
|
const accepted = history.userIds.includes(uuid);
|
|
97
83
|
let result;
|
|
98
84
|
try {
|
|
99
85
|
if (accepted) {
|
|
100
86
|
if (entry.turn?.id === uuid && entry.turn.active && entry.process) {
|
|
101
87
|
result = await conversation.wait({ conversationId: entry.id, timeoutMs });
|
|
102
|
-
if (result.status !== "completed") throw createError(result.error || "Claude did not complete the renewal turn.", failedCode);
|
|
88
|
+
if (result.status !== "completed") throw createError(result.error || "Claude did not complete the renewal turn.", { code: failedCode });
|
|
103
89
|
} else {
|
|
104
90
|
if (entry.turn?.id !== uuid || entry.turn.state !== "completed" || entry.turn.active) {
|
|
105
|
-
throw createError("The accepted Claude renewal turn has no confirmed completion; it will not be submitted again.", unreadableCode);
|
|
91
|
+
throw createError("The accepted Claude renewal turn has no confirmed completion; it will not be submitted again.", { code: unreadableCode });
|
|
106
92
|
}
|
|
107
93
|
result = { text: history.messages.filter((message) => message.userId === uuid && message.role === "assistant").map((message) => message.text).join("\n"),
|
|
108
94
|
threadId: entry.id, turnId: uuid, reconciled: true };
|
|
109
|
-
if (!result.text) throw createError("The accepted Claude renewal turn has no readable result; it will not be submitted again.", unreadableCode);
|
|
95
|
+
if (!result.text) throw createError("The accepted Claude renewal turn has no readable result; it will not be submitted again.", { code: unreadableCode });
|
|
110
96
|
}
|
|
111
97
|
} else {
|
|
112
|
-
if (entry.turn?.active && entry.process)
|
|
98
|
+
if (entry.turn?.active && entry.process) {
|
|
99
|
+
throw createError("Stop the current turn before renewing this conversation.", { code: "conversation_claude_renewal_turn_active" });
|
|
100
|
+
}
|
|
113
101
|
await conversation.send({ message: prompt, messageId: clientMessageId, outputSchema }, { renewal: true });
|
|
114
102
|
result = await conversation.wait({ conversationId: entry.id, timeoutMs });
|
|
115
|
-
if (result.status !== "completed") throw createError(result.error || "Claude did not complete the renewal turn.", failedCode);
|
|
103
|
+
if (result.status !== "completed") throw createError(result.error || "Claude did not complete the renewal turn.", { code: failedCode });
|
|
116
104
|
}
|
|
117
105
|
return { ...result, clientMessageId,
|
|
118
106
|
freshThread: history.userIds.length === 0, processExitProof: await conversation.stop() };
|
|
@@ -124,16 +112,28 @@ export async function runClaudeRenewalTurn(conversation, {
|
|
|
124
112
|
}
|
|
125
113
|
}
|
|
126
114
|
|
|
115
|
+
/**
|
|
116
|
+
* One conversation's goal: Claude's own goal attachment, or the Pause or
|
|
117
|
+
* blocker this owner retains while Claude's checker is suspended.
|
|
118
|
+
* @param {{
|
|
119
|
+
* conversationId: string,
|
|
120
|
+
* readHistory: () => Promise<Record<string, any>>,
|
|
121
|
+
* readState: () => { running: boolean, active: boolean },
|
|
122
|
+
* interrupt: () => Promise<unknown>,
|
|
123
|
+
* send: (input: Record<string, any>) => Promise<unknown>,
|
|
124
|
+
* readRetainedGoal: () => Record<string, any> | null,
|
|
125
|
+
* saveRetainedGoal: (goal: Record<string, any>) => Promise<void>
|
|
126
|
+
* }} options
|
|
127
|
+
*/
|
|
127
128
|
export function createClaudeConversationGoals({ conversationId, readHistory, readState, interrupt, send, readRetainedGoal, saveRetainedGoal }) {
|
|
128
129
|
// These checks run before interrupt/send. Native execution and receipt
|
|
129
130
|
// failures keep their own codes and must not be treated as safe rejections.
|
|
130
|
-
const goalError = (message, { messageCode }) => Object.assign(
|
|
131
|
-
|
|
132
|
-
});
|
|
131
|
+
const goalError = (message, { messageCode }) => Object.assign(
|
|
132
|
+
new AppError(400, message, { code: "conversation_invalid_goal", messageCode }), { delivery: "not-sent" });
|
|
133
133
|
|
|
134
134
|
async function read(history = null) {
|
|
135
135
|
const { goal = null } = history || await readHistory();
|
|
136
|
-
const retained = readRetainedGoal
|
|
136
|
+
const retained = readRetainedGoal();
|
|
137
137
|
if (retained) return { ...retained };
|
|
138
138
|
const state = readState();
|
|
139
139
|
return goal ? { ...goal, status: ["complete", "blocked"].includes(goal.status) ? goal.status :
|
|
@@ -173,19 +173,79 @@ export function createClaudeConversationGoals({ conversationId, readHistory, rea
|
|
|
173
173
|
} else throw goalError("Unknown Claude goal action.", { messageCode: "assistant.claude.goalActionUnknown" });
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
return Object.freeze({
|
|
176
|
+
return Object.freeze({
|
|
177
|
+
read,
|
|
178
|
+
update
|
|
179
|
+
});
|
|
177
180
|
}
|
|
178
181
|
|
|
179
|
-
/**
|
|
182
|
+
/**
|
|
183
|
+
* A retained Claude conversation: its saved custody, native process, turn
|
|
184
|
+
* view and the command that owns it.
|
|
185
|
+
* @typedef {Record<string, any>} ClaudeConversationEntry
|
|
186
|
+
*/
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Where an owner keeps its conversations' records. A runtime binding's store
|
|
190
|
+
* supplies releaseExecution; a store that retains entries supplies the rest.
|
|
191
|
+
* @typedef {object} ClaudeConversationStore
|
|
192
|
+
* @property {(context: Record<string, any>, conversationId: string) => string | null | Promise<string | null>} read
|
|
193
|
+
* @property {(entry: ClaudeConversationEntry, options?: { execution?: boolean }) => Promise<unknown>} save
|
|
194
|
+
* @property {(entry: ClaudeConversationEntry) => Promise<unknown>} [releaseExecution]
|
|
195
|
+
* @property {(context: Record<string, any>, conversationId: string) => unknown} [select]
|
|
196
|
+
* @property {(context: Record<string, any>, messageId: string) => Promise<boolean>} [hasMessage]
|
|
197
|
+
* @property {(entry: ClaudeConversationEntry) => Promise<unknown>} [remove]
|
|
198
|
+
* @property {(context: Record<string, any>) => Record<string, any> | Promise<Record<string, any>>} [readSessionConversations]
|
|
199
|
+
* @property {(entry: ClaudeConversationEntry) => string} [projectContextRoot]
|
|
200
|
+
* @property {(entry: ClaudeConversationEntry) => string} [modelProviderId]
|
|
201
|
+
*/
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* How a host prepares an entry's work. A store that retains entries also
|
|
205
|
+
* resolves contexts and entries, checks sends and binds accounts.
|
|
206
|
+
* @typedef {object} ClaudeConversationPreparation
|
|
207
|
+
* @property {(entry: ClaudeConversationEntry, input: Record<string, any>) => Promise<Record<string, any>>} configuration
|
|
208
|
+
* @property {(entry: ClaudeConversationEntry, input: Record<string, any>, options?: Record<string, any>) => unknown} message
|
|
209
|
+
* @property {(context: Record<string, any>) => Promise<Record<string, any>>} [context]
|
|
210
|
+
* @property {(ctx: Record<string, any>, context: Record<string, any>, conversationId: string, options: Record<string, any>) => { options: Record<string, any>, check: (entry: ClaudeConversationEntry) => void }} [entry]
|
|
211
|
+
* @property {(entry: ClaudeConversationEntry, context: Record<string, any>) => void} [check]
|
|
212
|
+
* @property {(entry: ClaudeConversationEntry) => Record<string, any>} [account]
|
|
213
|
+
* @property {(entry: ClaudeConversationEntry, input: Record<string, any>) => { sessionClosing?: boolean } | undefined} [checkConfiguration]
|
|
214
|
+
*/
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* How a host runs an entry's Claude process.
|
|
218
|
+
* @typedef {object} ClaudeConversationProcessHost
|
|
219
|
+
* @property {(executionId: string, options?: Record<string, any>) => Promise<Record<string, any>>} stopExecution
|
|
220
|
+
* @property {(entry: ClaudeConversationEntry, configuration: Record<string, any>, options: Record<string, any>) => Record<string, any> | Promise<Record<string, any>>} configure
|
|
221
|
+
* @property {(options: Record<string, any>) => Promise<import("./claudeProcess.js").ClaudeCodeProcess>} [create]
|
|
222
|
+
* @property {(entry: ClaudeConversationEntry, configuration: Record<string, any>, context: Record<string, any>) => Record<string, any> | Promise<Record<string, any>>} [prepare]
|
|
223
|
+
* @property {(entry: ClaudeConversationEntry, context: Record<string, any>) => Promise<Record<string, any>>} [configureTerminal]
|
|
224
|
+
*/
|
|
225
|
+
|
|
226
|
+
/** @typedef {(entry: ClaudeConversationEntry, event: Record<string, any>) => unknown} ClaudeConversationEventHandler */
|
|
227
|
+
|
|
228
|
+
/** @typedef {(message: string, options: { code: string }) => Error} ClaudeConversationErrorFactory */
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The owner of a conversation's retained entries and their commands; hosts supply authorized storage and message effects.
|
|
232
|
+
* Every refusal is built by `createError(message, { code })`, each with its own `conversation_claude_*` code.
|
|
233
|
+
* @param {{
|
|
234
|
+
* configRoot: string,
|
|
235
|
+
* store: ClaudeConversationStore,
|
|
236
|
+
* preparation: ClaudeConversationPreparation,
|
|
237
|
+
* process: ClaudeConversationProcessHost,
|
|
238
|
+
* onEvent: ClaudeConversationEventHandler,
|
|
239
|
+
* createError?: ClaudeConversationErrorFactory,
|
|
240
|
+
* disconnectedMessage?: string
|
|
241
|
+
* }} options
|
|
242
|
+
*/
|
|
180
243
|
export function createClaudeConversationOwner({
|
|
181
|
-
configRoot, store, preparation, process, onEvent, createError =
|
|
244
|
+
configRoot, store, preparation, process, onEvent, createError = claudeTurnFailure,
|
|
182
245
|
disconnectedMessage = "Claude was interrupted when the application disconnected."
|
|
183
246
|
}) {
|
|
184
247
|
const entries = new Map();
|
|
185
248
|
const applicationCall = new AsyncLocalStorage();
|
|
186
|
-
const closingSessions = new Set();
|
|
187
|
-
let closing = false;
|
|
188
|
-
const createProcess = process.create || createClaudeCodeProcess;
|
|
189
249
|
const saveAccount = store.save;
|
|
190
250
|
// Two hosts share this owner. A bound store (one with releaseExecution) is a
|
|
191
251
|
// runtime binding: the native turn owns activity and execution release.
|
|
@@ -196,21 +256,32 @@ export function createClaudeConversationOwner({
|
|
|
196
256
|
// active turn) that only a verified stop retires. An idle entry has nothing
|
|
197
257
|
// to stop, and stopping it would only rewrite its saved record.
|
|
198
258
|
const holdsNativeWork = entry => runsNativeWork(entry) || Boolean(entry.executionId) || isTurnActive(entry);
|
|
199
|
-
|
|
259
|
+
const cleanup = createClaudeSessionCleanup({ entries, store, configRoot, acquire, savedState, holdsNativeWork, disconnectedMessage });
|
|
260
|
+
const processStart = createClaudeProcessStart({ store, process, createError, onEvent, bound, readHistory,
|
|
261
|
+
isClosing: cleanup.isClosing, applicationCall, currentApplicationCommand });
|
|
262
|
+
const delivery = createClaudePromptDelivery({ store, preparation, createError, onEvent, readHistory, bindAccount, ensureProcess, snapshot });
|
|
263
|
+
const commands = createClaudeCommandRun({ bound, preparation, send: delivery.send, interrupt });
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* @param {{
|
|
267
|
+
* accountIdentity: (context: Record<string, any>) => Promise<string>,
|
|
268
|
+
* createProcess: (request: { signal: AbortSignal, purpose: string }) => Promise<import("./claudeProcess.js").ClaudeCodeProcess>
|
|
269
|
+
* }} options
|
|
270
|
+
*/
|
|
200
271
|
function createAccountQueries({ accountIdentity, createProcess }) {
|
|
201
272
|
return createClaudeAccountQueries({ accountIdentity, configRoot,
|
|
202
|
-
processes: () => entries.values(), isClosing: () => closing, createProcess });
|
|
273
|
+
processes: () => entries.values(), isClosing: () => cleanup.closing, createProcess });
|
|
203
274
|
}
|
|
204
275
|
|
|
205
276
|
function snapshot(entry) {
|
|
206
277
|
return entry?.turn ? { ...entry.turn, threadId: entry.id,
|
|
207
278
|
...(entry.turn.active && entry.observationError ? { status: "observation_lost", error: entry.observationError } : {}),
|
|
208
|
-
phase: entry.turn.active && !entry.observationError ?
|
|
279
|
+
phase: entry.turn.active && !entry.observationError ? normalizeNullableText(entry.turn.phase) : "" } : null;
|
|
209
280
|
}
|
|
210
281
|
|
|
211
282
|
function currentApplicationCommand(entry, command, owner = command?.nativeOwner) {
|
|
212
283
|
const native = entry?.nativeTurn.read();
|
|
213
|
-
return Boolean(owner && entry && !
|
|
284
|
+
return Boolean(owner && entry && !cleanup.isClosing(entry.context.key) &&
|
|
214
285
|
entries.get(entry.key) === entry && entry.command === command && command.entry === entry &&
|
|
215
286
|
command.admitted && !command.signal.aborted && !command.toolFailure &&
|
|
216
287
|
!entry.disposed && !entry.stopping && !entry.stopPending && !entry.goalToolStop && !entry.observationError &&
|
|
@@ -229,9 +300,10 @@ export function createClaudeConversationOwner({
|
|
|
229
300
|
} : null);
|
|
230
301
|
}
|
|
231
302
|
|
|
232
|
-
// This lookup uses only the
|
|
303
|
+
// This lookup uses only the current entry and its native receipt owner.
|
|
233
304
|
function readFinalAssistantResult(contextKey, conversationId = "", turnId = "") {
|
|
234
|
-
if (!
|
|
305
|
+
if (!normalizeNullableText(contextKey) || !normalizeNullableText(conversationId) || !normalizeNullableText(turnId) ||
|
|
306
|
+
cleanup.isClosing(contextKey)) return null;
|
|
235
307
|
const entry = entries.get(`${contextKey}\0${conversationId}`);
|
|
236
308
|
if (!entry || entry.disposed || entry.stopping || entry.observationError ||
|
|
237
309
|
entry.id !== conversationId || entry.context.key !== contextKey || entry.turn?.id !== turnId) return null;
|
|
@@ -245,13 +317,16 @@ export function createClaudeConversationOwner({
|
|
|
245
317
|
function savedState(saved, conversationId) {
|
|
246
318
|
if (!saved) return {};
|
|
247
319
|
try { return JSON.parse(saved); }
|
|
248
|
-
catch {
|
|
320
|
+
catch {
|
|
321
|
+
throw createError(`The saved Claude conversation ${conversationId} is unreadable. Restore its saved conversation state.`,
|
|
322
|
+
{ code: "conversation_claude_saved_state_unreadable" });
|
|
323
|
+
}
|
|
249
324
|
}
|
|
250
325
|
|
|
251
326
|
async function open(context, { conversationId = "", mainId, create = false,
|
|
252
327
|
cleanupExecutionId, recoveringCleanup = false, nativeWorkdir = context.workdir } = {}) {
|
|
253
328
|
const main = !conversationId || conversationId === mainId;
|
|
254
|
-
const id = conversationId ||
|
|
329
|
+
const id = conversationId || normalizeNullableText(mainId) ||
|
|
255
330
|
[...entries.values()].find(entry => entry.main && entry.context.key === context.key)?.id || randomUUID();
|
|
256
331
|
requireClaudeSessionId(id);
|
|
257
332
|
if (!conversationId) {
|
|
@@ -265,8 +340,9 @@ export function createClaudeConversationOwner({
|
|
|
265
340
|
return entry;
|
|
266
341
|
}
|
|
267
342
|
let saved = store.read(context, id);
|
|
343
|
+
// readable-check: allow method-probe - data: a store reads a saved record synchronously or with a promise; a synchronous read opens the entry without yielding
|
|
268
344
|
if (saved && typeof saved.then === "function") saved = await saved;
|
|
269
|
-
if (!main && !saved && !create && !recoveringCleanup) throw createError("This Claude conversation is unavailable.");
|
|
345
|
+
if (!main && !saved && !create && !recoveringCleanup) throw createError("This Claude conversation is unavailable.", { code: "conversation_claude_unavailable" });
|
|
270
346
|
const state = savedState(saved, id);
|
|
271
347
|
const entry = { id, key, main, persistent: state.persistent === true, context, process: null, startupCleanup: null,
|
|
272
348
|
executionId: state.executionId || (recoveringCleanup ? cleanupExecutionId : ""), preparationRevision: 0,
|
|
@@ -274,17 +350,19 @@ export function createClaudeConversationOwner({
|
|
|
274
350
|
accountIdentity: state.accountIdentity || "", sent: state.sent === true, nativeWorkdir: state.nativeWorkdir || nativeWorkdir,
|
|
275
351
|
lastMessageId: state.lastMessageId || "", pausedGoal: state.pausedGoal || null, blockedGoal: state.blockedGoal || null, turn: state.turnId ? {
|
|
276
352
|
id: state.turnId, active: ["starting", "active", "finalizing"].includes(state.state),
|
|
277
|
-
state: state.state, startedAt:
|
|
353
|
+
state: state.state, startedAt: new Date().toISOString(), updatedAt: new Date().toISOString()
|
|
278
354
|
} : null };
|
|
279
355
|
for (const [field, status] of [["pausedGoal", "paused"], ["blockedGoal", "blocked"]]) {
|
|
280
356
|
const retained = entry[field];
|
|
281
357
|
if (retained && (retained.threadId !== id || typeof retained.objective !== "string" ||
|
|
282
358
|
!retained.objective.trim() || !Number.isFinite(retained.createdAt) || retained.status !== status ||
|
|
283
359
|
status === "blocked" && (typeof retained.reason !== "string" || !retained.reason.trim() || retained.reason.length > 1000))) {
|
|
284
|
-
throw createError("The retained Claude goal is invalid. Restore its saved conversation state.");
|
|
360
|
+
throw createError("The retained Claude goal is invalid. Restore its saved conversation state.", { code: "conversation_claude_retained_goal_invalid" });
|
|
285
361
|
}
|
|
286
362
|
}
|
|
287
|
-
if (entry.pausedGoal && entry.blockedGoal)
|
|
363
|
+
if (entry.pausedGoal && entry.blockedGoal) {
|
|
364
|
+
throw createError("The saved Claude goal cannot be both paused and blocked.", { code: "conversation_claude_retained_goal_conflict" });
|
|
365
|
+
}
|
|
288
366
|
entry.nativeTurn = createClaudeConversationTurn({ conversationId: id, entry, bound,
|
|
289
367
|
onEvent: event => onEvent(entry, event),
|
|
290
368
|
process: {
|
|
@@ -292,7 +370,7 @@ export function createClaudeConversationOwner({
|
|
|
292
370
|
...(bound ? { releaseExecution: () => store.releaseExecution(entry) } : {}),
|
|
293
371
|
getProcess: () => entry.stopping ? null : entry.process,
|
|
294
372
|
isActive: () => isTurnActive(entry),
|
|
295
|
-
startProcess: input => startProcess(entry, input),
|
|
373
|
+
startProcess: input => processStart.startProcess(entry, input),
|
|
296
374
|
stopProcess: reason => entry.process || entry.executionId
|
|
297
375
|
? entry.nativeTurn.stopProcess(reason, { preparing: true }) : Promise.resolve()
|
|
298
376
|
}
|
|
@@ -308,7 +386,7 @@ export function createClaudeConversationOwner({
|
|
|
308
386
|
catch (error) { Object.assign(entry, previous); throw error; }
|
|
309
387
|
},
|
|
310
388
|
readState: () => ({ running: Boolean(entry.process), active: isTurnActive(entry) }),
|
|
311
|
-
interrupt: () => interrupt(entry), send: input => send(entry, input) });
|
|
389
|
+
interrupt: () => interrupt(entry), send: input => delivery.send(entry, input) });
|
|
312
390
|
entries.set(key, entry);
|
|
313
391
|
await store.save(entry);
|
|
314
392
|
return entry;
|
|
@@ -341,7 +419,7 @@ export function createClaudeConversationOwner({
|
|
|
341
419
|
stop: (entry, reason) => entry.nativeTurn.stopProcess(reason, { preparing: true }), save: saveAccount });
|
|
342
420
|
} catch (failure) {
|
|
343
421
|
if (failure.code !== "claude_account_changed") throw failure;
|
|
344
|
-
throw createError(failure.message, account.changedCode || failure.code);
|
|
422
|
+
throw createError(failure.message, { code: account.changedCode || failure.code });
|
|
345
423
|
}
|
|
346
424
|
}
|
|
347
425
|
|
|
@@ -360,14 +438,15 @@ export function createClaudeConversationOwner({
|
|
|
360
438
|
|
|
361
439
|
async function ensureProcess(entry, input = {}) {
|
|
362
440
|
const revision = entry.preparationRevision;
|
|
441
|
+
// readable-check: allow optional-call - optional operation: a host that tracks its own sessions' closing supplies checkConfiguration
|
|
363
442
|
const application = preparation.checkConfiguration?.(entry, input);
|
|
364
|
-
if (entry.disposed ||
|
|
365
|
-
throw createError("This session is closing.");
|
|
443
|
+
if (entry.disposed || cleanup.isClosing(entry.context.key) || application?.sessionClosing) {
|
|
444
|
+
throw createError("This session is closing.", { code: "conversation_claude_session_closing" });
|
|
366
445
|
}
|
|
367
446
|
if (preparation.account) await bindAccount(entry);
|
|
368
447
|
const configuration = await preparation.configuration(entry, input);
|
|
369
|
-
if (revision !== entry.preparationRevision || entry.disposed ||
|
|
370
|
-
throw createError("Claude preparation was stopped while this session was closing or changing.");
|
|
448
|
+
if (revision !== entry.preparationRevision || entry.disposed || cleanup.isClosing(entry.context.key)) {
|
|
449
|
+
throw createError("Claude preparation was stopped while this session was closing or changing.", { code: "conversation_claude_preparation_stopped" });
|
|
371
450
|
}
|
|
372
451
|
return entry.nativeTurn.prepare(configuration);
|
|
373
452
|
}
|
|
@@ -384,15 +463,17 @@ export function createClaudeConversationOwner({
|
|
|
384
463
|
if (!Number.isSafeInteger(usedTokens) || usedTokens < 0 ||
|
|
385
464
|
!Number.isSafeInteger(maximum) || maximum < 1 ||
|
|
386
465
|
!Number.isSafeInteger(threshold) || threshold < 1 || typeof usage.model !== "string" || !usage.model) {
|
|
387
|
-
throw createError("Claude's current context usage is unavailable.");
|
|
466
|
+
throw createError("Claude's current context usage is unavailable.", { code: "conversation_claude_context_usage_unavailable" });
|
|
388
467
|
}
|
|
389
468
|
return { model: usage.model, usedTokens,
|
|
390
469
|
windowTokens: usage.isAutoCompactEnabled === false ? maximum : Math.min(maximum, threshold) };
|
|
391
470
|
}
|
|
392
471
|
|
|
393
472
|
async function stopForTerminal(entry, application) {
|
|
394
|
-
if (entry.turn?.active) throw createError("Stop the current turn before opening the Claude Code terminal.");
|
|
395
|
-
if (
|
|
473
|
+
if (entry.turn?.active) throw createError("Stop the current turn before opening the Claude Code terminal.", { code: "conversation_claude_terminal_turn_active" });
|
|
474
|
+
if (cleanup.isClosing(entry.context.key) || application?.sessionClosing) {
|
|
475
|
+
throw createError("This session is closing.", { code: "conversation_claude_session_closing" });
|
|
476
|
+
}
|
|
396
477
|
await bindAccount(entry);
|
|
397
478
|
await entry.nativeTurn.stopProcess();
|
|
398
479
|
}
|
|
@@ -408,338 +489,27 @@ export function createClaudeConversationOwner({
|
|
|
408
489
|
env: { ...configuration.env, DISABLE_AUTOUPDATER: "1" } };
|
|
409
490
|
}
|
|
410
491
|
|
|
411
|
-
async function startProcess(entry, configuration) {
|
|
412
|
-
const context = entry.context;
|
|
413
|
-
const revision = entry.preparationRevision;
|
|
414
|
-
const assertPreparing = () => {
|
|
415
|
-
if (revision !== entry.preparationRevision || entry.disposed || closing || closingSessions.has(context.key)) {
|
|
416
|
-
throw createError("Claude preparation was stopped while this session was closing or changing.");
|
|
417
|
-
}
|
|
418
|
-
};
|
|
419
|
-
assertPreparing();
|
|
420
|
-
let prepared = process.prepare ? process.prepare(entry, configuration, context) : { workdir: entry.nativeWorkdir };
|
|
421
|
-
if (prepared && typeof prepared.then === "function") prepared = await prepared;
|
|
422
|
-
entry.nativeWorkdir = prepared.workdir;
|
|
423
|
-
if (!entry.sent) entry.sent = (await readHistory(entry)).exists;
|
|
424
|
-
entry.stopping = false;
|
|
425
|
-
entry.profile = configuration.profile;
|
|
426
|
-
let options = process.configure(entry, configuration, { context, prepared });
|
|
427
|
-
if (options && typeof options.then === "function") options = await options;
|
|
428
|
-
assertPreparing();
|
|
429
|
-
let startupCleanup;
|
|
430
|
-
let startedExecutionId;
|
|
431
|
-
const processToken = {};
|
|
432
|
-
try { entry.process = await createProcess({ ...options,
|
|
433
|
-
workdir: entry.nativeWorkdir, sessionId: entry.id, resume: entry.sent,
|
|
434
|
-
onStarted: async (executionId, stop) => {
|
|
435
|
-
startedExecutionId = executionId;
|
|
436
|
-
if (typeof stop === "function") {
|
|
437
|
-
startupCleanup = { executionId, stop, pending: true };
|
|
438
|
-
entry.startupCleanup = startupCleanup;
|
|
439
|
-
}
|
|
440
|
-
if (entry.executionId !== executionId) {
|
|
441
|
-
entry.executionId = executionId;
|
|
442
|
-
await store.save(entry, { execution: true });
|
|
443
|
-
await onEvent(entry, { type: "execution", context, executionId });
|
|
444
|
-
}
|
|
445
|
-
assertPreparing();
|
|
446
|
-
},
|
|
447
|
-
async onEvent(frame) {
|
|
448
|
-
const pending = entry.goalToolStop;
|
|
449
|
-
if (pending && pending.executionId === startedExecutionId && entry.process === pending.process &&
|
|
450
|
-
frame.type === "user" && !frame.parent_tool_use_id && Array.isArray(frame.message?.content) &&
|
|
451
|
-
frame.message.content.some(block => block.type === "tool_result" && block.tool_use_id === pending.toolUseId)) {
|
|
452
|
-
// The CLI has consumed the tool response. Do not join process cleanup
|
|
453
|
-
// on its event reader: interrupt/exit also needs that reader to drain.
|
|
454
|
-
pending.stop();
|
|
455
|
-
}
|
|
456
|
-
// Local commands (retained goal clear, compaction) are not AI turns.
|
|
457
|
-
// Their frames stay out of the turn view until their own result.
|
|
458
|
-
const local = entry.localCommand;
|
|
459
|
-
if (local?.processToken === processToken) {
|
|
460
|
-
if (frame.type === "result" && frame.user_message_uuid === local.id) {
|
|
461
|
-
if (frame.local_command === local.name && frame.is_error !== true && frame.subtype === "success") local.resolve();
|
|
462
|
-
else local.reject(createError(local.failureMessage));
|
|
463
|
-
}
|
|
464
|
-
return;
|
|
465
|
-
}
|
|
466
|
-
const current = entry.command;
|
|
467
|
-
if (current && frame.type === "assistant" && !frame.parent_tool_use_id) {
|
|
468
|
-
for (const tool of frame.message?.content || []) if (tool.type === "tool_use" && tool.id && tool.name?.startsWith("mcp__application__")) {
|
|
469
|
-
if (current.nativeToolCount >= current.maximumToolCalls && !current.nativeTools.has(tool.id)) {
|
|
470
|
-
throw new Error("Claude exceeded the application tool-call limit.");
|
|
471
|
-
}
|
|
472
|
-
if (!current.nativeTools.has(tool.id)) {
|
|
473
|
-
current.nativeToolCount++;
|
|
474
|
-
current.nativeTools.set(tool.id, { name: tool.name, input: tool.input, messageId: current.messageId });
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
await entry.nativeTurn.receive(frame);
|
|
479
|
-
},
|
|
480
|
-
...(bound || options.applicationTools || entry.command?.tools ? {
|
|
481
|
-
async onControlRequest(request, { signal }) {
|
|
482
|
-
const active = entry.command;
|
|
483
|
-
const suppliedTools = !bound && active?.tools;
|
|
484
|
-
const owner = active?.nativeOwner;
|
|
485
|
-
const work = applicationCall.run({ entry, command: active, owner,
|
|
486
|
-
toolUseId: request.message?.params?._meta?.["claudecode/toolUseId"] },
|
|
487
|
-
() => claudeApplicationToolResponse(request, { schemas: entry.toolSchemas || [], turn: active, signal,
|
|
488
|
-
...(suppliedTools ? {
|
|
489
|
-
assertCurrent(native) {
|
|
490
|
-
if (!currentApplicationCommand(entry, active, owner) || active.nativeOwner !== owner ||
|
|
491
|
-
native.messageId !== active.messageId) throw new Error("Claude's application call belongs to a retired input.");
|
|
492
|
-
},
|
|
493
|
-
onExecutionFailure(error, native) {
|
|
494
|
-
// Already-invoked old work retains its original result. Only
|
|
495
|
-
// this still-current authored input can fail this native run.
|
|
496
|
-
if (currentApplicationCommand(entry, active, owner) && active.nativeOwner === owner &&
|
|
497
|
-
native.messageId === active.messageId) {
|
|
498
|
-
active.toolFailure = error;
|
|
499
|
-
active.failure.reject(error);
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
} : {}) }));
|
|
503
|
-
active?.toolWork.add(work);
|
|
504
|
-
try { return await work; }
|
|
505
|
-
finally { active?.toolWork.delete(work); }
|
|
506
|
-
}
|
|
507
|
-
} : {}),
|
|
508
|
-
onFailure: async error => {
|
|
509
|
-
if (entry.localCommand?.processToken === processToken) entry.localCommand.reject(error);
|
|
510
|
-
if (bound) {
|
|
511
|
-
entry.nativeTurn.invalidateInstructions();
|
|
512
|
-
try { await entry.nativeTurn.stopProcess(error.message); }
|
|
513
|
-
catch (cleanupError) { error = cleanupError; await context.reportFailure(cleanupError); }
|
|
514
|
-
entry.command?.completion.reject(error);
|
|
515
|
-
return;
|
|
516
|
-
}
|
|
517
|
-
entry.observationError = error.message;
|
|
518
|
-
await entry.nativeTurn.stopProcess(error.message);
|
|
519
|
-
}
|
|
520
|
-
}); } catch (error) {
|
|
521
|
-
if (error.executionId && error.stopProof?.scopeEmpty !== true && !entry.executionId) {
|
|
522
|
-
// Streaming startup can fail after host admission, before onStarted.
|
|
523
|
-
// Retain that custody through the same binding owner for Stop recovery.
|
|
524
|
-
error.cleanupFailed = true;
|
|
525
|
-
entry.executionId = error.executionId;
|
|
526
|
-
try {
|
|
527
|
-
await store.save(entry, { execution: true });
|
|
528
|
-
await onEvent(entry, { type: "execution", context, executionId: entry.executionId });
|
|
529
|
-
} catch (bindingError) { error.bindingError = bindingError; }
|
|
530
|
-
}
|
|
531
|
-
if (bound && error.stopProof?.scopeEmpty) {
|
|
532
|
-
await store.releaseExecution(entry);
|
|
533
|
-
if (entry.startupCleanup?.executionId === error.executionId) entry.startupCleanup = null;
|
|
534
|
-
}
|
|
535
|
-
throw error;
|
|
536
|
-
} finally {
|
|
537
|
-
if (startupCleanup) startupCleanup.pending = false;
|
|
538
|
-
}
|
|
539
|
-
try { assertPreparing(); }
|
|
540
|
-
catch (error) {
|
|
541
|
-
await entry.nativeTurn.stopProcess(error.message);
|
|
542
|
-
throw error;
|
|
543
|
-
}
|
|
544
|
-
if (entry.startupCleanup?.executionId === entry.executionId) entry.startupCleanup = null;
|
|
545
|
-
entry.processToken = processToken;
|
|
546
|
-
if (entry.pausedGoal || entry.blockedGoal) {
|
|
547
|
-
try {
|
|
548
|
-
const id = await runLocalCommand(entry, "/goal clear", { name: "goal", timeoutMs: 30_000,
|
|
549
|
-
failureMessage: "Claude could not suspend its retained goal checker.",
|
|
550
|
-
timeoutMessage: "Claude timed out suspending its retained goal checker." });
|
|
551
|
-
const cleared = await readHistory(entry);
|
|
552
|
-
if (cleared.goal || cleared.goalCommandId !== id) {
|
|
553
|
-
throw createError("Claude has not saved the retained goal's native clear receipt.");
|
|
554
|
-
}
|
|
555
|
-
assertPreparing();
|
|
556
|
-
} catch (error) {
|
|
557
|
-
await entry.nativeTurn.stopProcess(error.message);
|
|
558
|
-
throw Object.assign(error, { delivery: "not-sent" });
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
return entry.process;
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
async function runLocalCommand(entry, command, { name, timeoutMs, failureMessage, timeoutMessage }) {
|
|
565
|
-
const completion = Promise.withResolvers();
|
|
566
|
-
completion.promise.catch(() => {});
|
|
567
|
-
const local = { ...completion, id: randomUUID(), name, failureMessage, processToken: entry.processToken };
|
|
568
|
-
entry.localCommand = local;
|
|
569
|
-
const timer = setTimeout(() => completion.reject(createError(timeoutMessage)), timeoutMs);
|
|
570
|
-
try {
|
|
571
|
-
await entry.process.client.send(command, { messageId: local.id, sessionId: entry.id });
|
|
572
|
-
await completion.promise;
|
|
573
|
-
return local.id;
|
|
574
|
-
} finally {
|
|
575
|
-
clearTimeout(timer);
|
|
576
|
-
if (entry.localCommand === local) entry.localCommand = null;
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
|
|
580
492
|
// Native /compact summarises Claude's own history in place, exactly like its
|
|
581
493
|
// automatic compaction. It is not an AI turn and adds no chat message.
|
|
582
494
|
async function compactContext(entry, input = {}) {
|
|
583
495
|
input.signal?.throwIfAborted();
|
|
584
496
|
if (entry.turn?.active || entry.nativeTurn.read().active) {
|
|
585
|
-
throw
|
|
586
|
-
messageCode: "assistant.claude.compactWhileWorking"
|
|
497
|
+
throw new AppError(409, "Claude is still working. Wait for it to finish before freeing its context.", {
|
|
498
|
+
code: "assistant_claude_compact_while_working", messageCode: "assistant.claude.compactWhileWorking" });
|
|
587
499
|
}
|
|
588
500
|
await ensureProcess(entry, input);
|
|
589
501
|
input.signal?.throwIfAborted();
|
|
590
|
-
await runLocalCommand(entry, "/compact", { name: "compact", timeoutMs: 15 * 60_000,
|
|
591
|
-
failureMessage: "Claude could not summarise its context.",
|
|
592
|
-
timeoutMessage: "Claude timed out summarising its context." });
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
async function send(entry, ...args) {
|
|
596
|
-
entry.sending = (entry.sending || 0) + 1;
|
|
597
|
-
try { return await dispatchPrompt(entry, ...args); }
|
|
598
|
-
finally { entry.sending -= 1; }
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
async function dispatchPrompt(entry, input = {}, { renewal = false, command, applicationCommand } = {}) {
|
|
602
|
-
const message = command ? input.text : text(input.message || input.prompt);
|
|
603
|
-
if (!command && !message) throw createError("Enter a message for Claude.");
|
|
604
|
-
const context = entry.context;
|
|
605
|
-
const messageId = command ? input.messageId : text(input.messageId) || randomUUID();
|
|
606
|
-
const uuid = claudeNativeMessageId(messageId);
|
|
607
|
-
const deliveryTiming = (command || input).deliveryTiming;
|
|
608
|
-
if (!command) {
|
|
609
|
-
if (entry.main && await store.hasMessage(context, messageId)) {
|
|
610
|
-
return { ok: true, delivered: true, duplicate: true, thread: { id: entry.id }, turn: snapshot(entry) };
|
|
611
|
-
}
|
|
612
|
-
deliveryTiming?.("duplicate-checked");
|
|
613
|
-
preparation.check(entry, context);
|
|
614
|
-
const history = await readHistory(entry);
|
|
615
|
-
deliveryTiming?.("native-history-read");
|
|
616
|
-
if (history.userIds.includes(uuid)) {
|
|
617
|
-
return { ok: true, delivered: true, duplicate: true, thread: { id: entry.id }, turn: snapshot(entry) };
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
let native;
|
|
621
|
-
try {
|
|
622
|
-
if (!command && entry.main && entry.turn?.active && entry.process) {
|
|
623
|
-
// Main steering keeps the active instruction installation, just like
|
|
624
|
-
// command steering. Refresh guidance on the next idle admission.
|
|
625
|
-
// Account replacement must still confirm cleanup before any new send.
|
|
626
|
-
if (preparation.account) await bindAccount(entry);
|
|
627
|
-
native = entry.process || await ensureProcess(entry, input);
|
|
628
|
-
} else {
|
|
629
|
-
native = command ? command.steering ? entry.process : await entry.nativeTurn.prepare(command.prepared) : await ensureProcess(entry, input);
|
|
630
|
-
}
|
|
631
|
-
} catch (error) {
|
|
632
|
-
// Preparation may start or resume the process, but has not written this
|
|
633
|
-
// authored input. Keep its failure distinct from a lost native receipt.
|
|
634
|
-
throw Object.assign(error, { delivery: "not-sent" });
|
|
635
|
-
}
|
|
636
|
-
deliveryTiming?.("process-ready");
|
|
637
|
-
if (command && !command.steering) command.signal.throwIfAborted();
|
|
638
|
-
const steering = command ? command.steering === true : Boolean(entry.turn?.active);
|
|
639
|
-
if (input.signal?.aborted) throw Object.assign(createError("The goal command was stopped before delivery."), { delivery: "not-sent" });
|
|
640
|
-
if (steering) {
|
|
641
|
-
if (command) {
|
|
642
|
-
await command.beforeDispatch({ threadId: entry.id });
|
|
643
|
-
command.signal.throwIfAborted();
|
|
644
|
-
}
|
|
645
|
-
// A mid-turn message is never an interrupt: Claude Code queues it and
|
|
646
|
-
// reads it after the running tool call. Only explicit Stop interrupts.
|
|
647
|
-
try { await entry.nativeTurn.prepareSteering(); }
|
|
648
|
-
catch (error) {
|
|
649
|
-
if (!command) await entry.nativeTurn.stopProcess(error.message);
|
|
650
|
-
// This precedes the input's pipe write: a refused steer is definitely not sent.
|
|
651
|
-
throw Object.assign(error, { delivery: "not-sent" });
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
const prompt = command ? undefined : await preparation.message(entry, input, { context, message, steering, renewal, deliveryTiming });
|
|
655
|
-
if (!command) deliveryTiming?.("prompt-rendered");
|
|
656
|
-
if (command) {
|
|
657
|
-
await command.beforeDispatch({ threadId: entry.id });
|
|
658
|
-
command.signal.throwIfAborted();
|
|
659
|
-
}
|
|
660
|
-
if (!steering) {
|
|
661
|
-
entry.renewal = renewal;
|
|
662
|
-
entry.nativeTurn.begin({ id: uuid, maxOutputCharacters: command ? command.maximumOutput : entry.profile?.limits.maxOutputCharacters });
|
|
663
|
-
entry.observationError = "";
|
|
664
|
-
entry.turn = { id: uuid, active: true, state: "starting", startedAt: now(), updatedAt: now(), error: "" };
|
|
665
|
-
if (!command) entry.completion = Promise.withResolvers();
|
|
666
|
-
if (!command) await entry.nativeTurn.updateState("starting");
|
|
667
|
-
}
|
|
668
|
-
entry.onEvent = context.onEvent;
|
|
669
|
-
entry.lastMessageId = uuid;
|
|
670
|
-
const actorMetadata = command ? undefined : await onEvent(entry, { type: "message-metadata", input, context });
|
|
671
|
-
if (!command) deliveryTiming?.("metadata-ready");
|
|
672
|
-
const nativeOwner = applicationCommand ? { process: native, executionId: entry.executionId,
|
|
673
|
-
accountIdentity: entry.accountIdentity, threadId: entry.id, turnId: entry.turn.id } : null;
|
|
674
|
-
let conversationTurn;
|
|
675
|
-
function deliveryResult() {
|
|
676
|
-
return { ok: true, delivered: true, deliveryMode: steering ? "steer" : "new_turn",
|
|
677
|
-
thread: { id: entry.id }, turn: snapshot(entry), workdir: context.workdir, conversationTurn };
|
|
678
|
-
}
|
|
679
|
-
async function accept() {
|
|
680
|
-
(command || input).onNativeReceipt?.();
|
|
681
|
-
// The new native ACK retires old effect custody while its canonical
|
|
682
|
-
// admission/commit is awaited; it cannot retag a delayed old failure.
|
|
683
|
-
if (applicationCommand) applicationCommand.active.nativeOwner = null;
|
|
684
|
-
if (command) {
|
|
685
|
-
if (!steering) await onEvent(entry, { type: "binding-admitted" });
|
|
686
|
-
await command.accept();
|
|
687
|
-
if (steering) {
|
|
688
|
-
command.active.messageId = input.messageId;
|
|
689
|
-
command.active.nativeToolCount = 0;
|
|
690
|
-
} else command.active.admitted = true;
|
|
691
|
-
command.admitted = true;
|
|
692
|
-
} else {
|
|
693
|
-
const admission = onEvent(entry, { type: "admit-message", input, context, renewal, message, messageId, uuid, actorMetadata });
|
|
694
|
-
if (admission) conversationTurn = await admission;
|
|
695
|
-
deliveryTiming?.("receipt-written");
|
|
696
|
-
}
|
|
697
|
-
if (!command) {
|
|
698
|
-
await entry.nativeTurn.updateState("active");
|
|
699
|
-
deliveryTiming?.("state-active");
|
|
700
|
-
}
|
|
701
|
-
(command || input).onReceiptPersisted?.();
|
|
702
|
-
if (applicationCommand && conversationTurn) {
|
|
703
|
-
const value = deliveryResult();
|
|
704
|
-
const active = applicationCommand.active;
|
|
705
|
-
const previousCompletion = active.completion;
|
|
706
|
-
if (active.finishIfCurrent) {
|
|
707
|
-
active.completion = Promise.withResolvers();
|
|
708
|
-
active.completion.promise.catch(() => {});
|
|
709
|
-
}
|
|
710
|
-
active.delivery = { value, completion: active.finishIfCurrent ? active.completion.promise : entry.completion?.promise };
|
|
711
|
-
if (active.finishIfCurrent) previousCompletion.resolve();
|
|
712
|
-
// The original canonical admission and publication precede Core's same
|
|
713
|
-
// accepted-request gate; native tools cannot race an unaccepted user.
|
|
714
|
-
await applicationCommand.accept({ nativeResult: { value }, conversationTurn, nativeTurnId: entry.turn?.id });
|
|
715
|
-
if (entry.process !== nativeOwner.process || entry.executionId !== nativeOwner.executionId ||
|
|
716
|
-
entry.accountIdentity !== nativeOwner.accountIdentity || entry.id !== nativeOwner.threadId ||
|
|
717
|
-
entry.turn?.id !== nativeOwner.turnId || entry.disposed || entry.stopping || entry.stopPending) {
|
|
718
|
-
throw new Error("Claude's admitted tools no longer belong to this native process.");
|
|
719
|
-
}
|
|
720
|
-
applicationCommand.active.nativeOwner = nativeOwner;
|
|
721
|
-
applicationCommand.active.messageId = messageId;
|
|
722
|
-
if (steering) applicationCommand.active.nativeToolCount = 0;
|
|
723
|
-
applicationCommand.active.admitted = true;
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
try {
|
|
727
|
-
if (command) command.attempted = true;
|
|
728
|
-
else await input.onPromptSending?.({ threadId: entry.id, displayAttachments: input.displayAttachments, turnMetadata: actorMetadata });
|
|
729
|
-
if (input.signal?.aborted) throw Object.assign(createError("The goal command was stopped before delivery."), { delivery: "not-sent" });
|
|
730
|
-
await entry.nativeTurn.send(command ? entry.process.client : native.client, command ? preparation.message(entry, input) : prompt,
|
|
731
|
-
{ messageId: uuid, accept, steer: steering, ...(command ? { timeoutMs: command.timeoutMs } : {}) });
|
|
732
|
-
} catch (error) {
|
|
733
|
-
if (!command) await entry.nativeTurn.stopProcess(error.message);
|
|
734
|
-
throw error;
|
|
735
|
-
}
|
|
736
|
-
return deliveryResult();
|
|
502
|
+
await processStart.runLocalCommand(entry, "/compact", { name: "compact", timeoutMs: 15 * 60_000,
|
|
503
|
+
failureMessage: "Claude could not summarise its context.", failureCode: "conversation_claude_compact_failed",
|
|
504
|
+
timeoutMessage: "Claude timed out summarising its context.", timeoutCode: "conversation_claude_compact_timeout" });
|
|
737
505
|
}
|
|
738
506
|
|
|
739
507
|
async function startTurn(entry, input = {}) {
|
|
740
508
|
entry.persistent ||= input.persistent === true;
|
|
741
|
-
if (entry.turn?.active && entry.process && input.steer !== true)
|
|
742
|
-
|
|
509
|
+
if (entry.turn?.active && entry.process && input.steer !== true) {
|
|
510
|
+
throw createError("This conversation is still working.", { code: "conversation_claude_still_working" });
|
|
511
|
+
}
|
|
512
|
+
await delivery.send(entry, input);
|
|
743
513
|
return { ...entry.nativeTurn.readResult(), ok: true, started: true };
|
|
744
514
|
}
|
|
745
515
|
|
|
@@ -804,201 +574,12 @@ export function createClaudeConversationOwner({
|
|
|
804
574
|
const saved = application.saved;
|
|
805
575
|
const active = hasActiveConversation(binding);
|
|
806
576
|
if (saved?.executionId || active || application.terminalRunning) {
|
|
807
|
-
throw createError("Stop the saved Claude process and its terminal before retiring native history.");
|
|
577
|
+
throw createError("Stop the saved Claude process and its terminal before retiring native history.", { code: "conversation_claude_history_retire_busy" });
|
|
808
578
|
}
|
|
809
579
|
}
|
|
810
580
|
});
|
|
811
581
|
}
|
|
812
582
|
|
|
813
|
-
async function reconcileSessions(sessions, options = {}, application) {
|
|
814
|
-
const results = [];
|
|
815
|
-
for (const session of sessions) {
|
|
816
|
-
const ctx = await application.readContext({ ...options, session, sessionId: session.sessionId });
|
|
817
|
-
await recover(await restoreSessionEntries(ctx, { acquire }));
|
|
818
|
-
results.push({ ok: true, sessionId: session.sessionId, resumed: false });
|
|
819
|
-
}
|
|
820
|
-
return { ok: true, results, failed: [], sessionCount: results.length };
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
async function restoreSessionEntries(context, { acquire }) {
|
|
824
|
-
let saved = store.readSessionConversations(context);
|
|
825
|
-
if (saved && typeof saved.then === "function") saved = await saved;
|
|
826
|
-
for (const conversationId of saved.ids) await acquire(saved.context, conversationId);
|
|
827
|
-
return [...entries.values()].filter((entry) => entry.context.key === context.key);
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
// A session's close stops what this server runs for it, then reopens only
|
|
831
|
-
// saved custody an earlier server left. Once the owner is closing for an
|
|
832
|
-
// account change or shutdown it reopens nothing.
|
|
833
|
-
async function closeSession(context, application) {
|
|
834
|
-
return closeRetained(await application.readContext(context), application,
|
|
835
|
-
{ recover: !closing, forget: context.forgetConversationBinding === true });
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
// Renewal proof release ends a session this server no longer uses. Its own
|
|
839
|
-
// close already recovered saved custody while its record could be written.
|
|
840
|
-
async function releaseSession(context, application) {
|
|
841
|
-
return closeRetained(await application.readContext(context), application, { recover: false, forget: true });
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
async function closeRetained(ctx, application, { recover: recoverCustody, forget: forgetEntries }) {
|
|
845
|
-
closingSessions.add(ctx.key);
|
|
846
|
-
try {
|
|
847
|
-
if (recoverCustody) await recoverSavedCustody(ctx);
|
|
848
|
-
const proofs = await drain(ctx.key);
|
|
849
|
-
const terminal = await application.terminals.close(ctx.sessionId);
|
|
850
|
-
// A forgotten entry is drained: everything it held is in its saved record.
|
|
851
|
-
if (terminal?.ok !== false && forgetEntries) forget(ctx.key);
|
|
852
|
-
return { ok: terminal?.ok !== false, closed: proofs.length + Number(terminal?.closed || 0),
|
|
853
|
-
processExitProof: proofs.at(-1) || { exited: true, scopeEmpty: true }, processExitProofs: proofs };
|
|
854
|
-
} finally {
|
|
855
|
-
closingSessions.delete(ctx.key);
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
// Saved custody an earlier server left: an execution whose exit is still
|
|
860
|
-
// unproven, or a turn saved as active. Only those conversations are reopened,
|
|
861
|
-
// so a verified stop can retire them; idle saved conversations stay closed.
|
|
862
|
-
async function recoverSavedCustody(ctx) {
|
|
863
|
-
let saved = store.readSessionConversations(ctx);
|
|
864
|
-
if (saved && typeof saved.then === "function") saved = await saved;
|
|
865
|
-
for (const conversationId of saved.ids) {
|
|
866
|
-
if (entries.has(`${ctx.key}\0${conversationId}`)) continue;
|
|
867
|
-
let record = store.read(saved.context, conversationId);
|
|
868
|
-
if (record && typeof record.then === "function") record = await record;
|
|
869
|
-
const state = savedState(record, conversationId);
|
|
870
|
-
if (state.executionId || ACTIVE_TURN_STATES.includes(state.state)) await acquire(saved.context, conversationId);
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
// Project close releases what this server runs for the project's sessions,
|
|
875
|
-
// from their retained entries alone, and forgets them: nothing uses them.
|
|
876
|
-
function closeProject(input = {}, application) {
|
|
877
|
-
const root = text(input.projectContextRoot);
|
|
878
|
-
return eachSession(entry => !root || path.resolve(store.projectContextRoot(entry)) === path.resolve(root),
|
|
879
|
-
ctx => closeRetained(ctx, application, { recover: false, forget: true }));
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
async function invalidateRuntimes(input = {}, application, account) {
|
|
883
|
-
if (input.provider && input.provider !== "claude") return { ok: true, closed: 0 };
|
|
884
|
-
if (input.modelProviderId) {
|
|
885
|
-
return stopSelected(entry => store.modelProviderId(entry) === input.modelProviderId,
|
|
886
|
-
"The provider connection changed.");
|
|
887
|
-
}
|
|
888
|
-
if (input.reason !== "server-shutdown") {
|
|
889
|
-
// An account change stops and waits on nothing. Claude Code reads its
|
|
890
|
-
// credentials for every request, so running work continues on the newly
|
|
891
|
-
// connected account or ends at once as signed out; each conversation
|
|
892
|
-
// rebinds its account at its next idle admission. Only the account's
|
|
893
|
-
// cached reads are dropped. A cancelled query's process stop that fails
|
|
894
|
-
// stays owned and is retried before the next query.
|
|
895
|
-
account.queries.invalidate().catch(() => {});
|
|
896
|
-
return { ok: true, closed: 0 };
|
|
897
|
-
}
|
|
898
|
-
closing = true;
|
|
899
|
-
// Neither waits for the other: a failed account query cleanup must not
|
|
900
|
-
// leave the sessions' processes running.
|
|
901
|
-
const [queries, retained] = await Promise.allSettled([account.queries.invalidate(), stopRetained(application)]);
|
|
902
|
-
if (queries.status === "rejected") await account.failure(queries.reason);
|
|
903
|
-
if (retained.status === "rejected") throw retained.reason;
|
|
904
|
-
return retained.value;
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
// Shutdown stops only what this server runs: a Claude process cannot outlive
|
|
908
|
-
// the server that started it, so retained entries hold every one. Saved
|
|
909
|
-
// conversations are not reopened just to be stopped.
|
|
910
|
-
function stopRetained(application) {
|
|
911
|
-
return eachSession(() => true, ctx => closeRetained(ctx, application, { recover: false, forget: false }));
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
// Sessions close independently and together: one that fails is reported by
|
|
915
|
-
// its ID while every other session still stops.
|
|
916
|
-
async function eachSession(select, close) {
|
|
917
|
-
const contexts = new Map([...entries.values()].filter(select).map(entry => [entry.context.key, entry.context]));
|
|
918
|
-
const settled = await Promise.allSettled([...contexts.values()].map(ctx => close(ctx)));
|
|
919
|
-
let closed = 0;
|
|
920
|
-
const failed = [];
|
|
921
|
-
[...contexts.values()].forEach((ctx, index) => {
|
|
922
|
-
const result = settled[index];
|
|
923
|
-
if (result.status === "fulfilled") closed += result.value.closed;
|
|
924
|
-
const error = result.status === "rejected" ? result.reason?.message || String(result.reason)
|
|
925
|
-
: result.value.ok === false ? "Its Claude Code terminal could not be closed." : "";
|
|
926
|
-
if (error) failed.push({ sessionId: ctx.sessionId, error });
|
|
927
|
-
});
|
|
928
|
-
return { ok: failed.length === 0, closed, failed,
|
|
929
|
-
...(failed.length ? { error: failed.map(({ sessionId, error }) => `Claude session ${sessionId}: ${error}`).join(" ") } : {}) };
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
async function drain(key) {
|
|
933
|
-
const selected = [...entries.values()].filter(entry => entry.context.key === key);
|
|
934
|
-
// Fence configuration still in progress: it must not start a process later.
|
|
935
|
-
for (const entry of selected) entry.preparationRevision++;
|
|
936
|
-
const proofs = [];
|
|
937
|
-
let failure;
|
|
938
|
-
async function stop() {
|
|
939
|
-
for (const entry of selected) if (holdsNativeWork(entry)) {
|
|
940
|
-
try { proofs.push(await entry.nativeTurn.stopProcess()); }
|
|
941
|
-
catch (error) { failure ||= error; }
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
// Startup already lends cleanup before initialization completes. Stop it
|
|
945
|
-
// first; waiting for the preparation queue first can exhaust host shutdown.
|
|
946
|
-
await stop();
|
|
947
|
-
await Promise.allSettled(selected.map(entry => entry.nativeTurn.whenIdle()));
|
|
948
|
-
await stop();
|
|
949
|
-
// Every other entry still stopped; the first failure keeps the close retryable.
|
|
950
|
-
if (failure) throw failure;
|
|
951
|
-
return proofs;
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
function forget(contextKey) {
|
|
955
|
-
for (const [key, entry] of entries) if (entry.context.key === contextKey) {
|
|
956
|
-
// Nothing would ever stop a process a forgotten entry started.
|
|
957
|
-
entry.disposed = true;
|
|
958
|
-
entries.delete(key);
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
async function close(entry) {
|
|
963
|
-
await entry.nativeTurn.stopProcess("Conversation closed.");
|
|
964
|
-
entry.disposed = true;
|
|
965
|
-
await entry.nativeTurn.whenIdle().catch(() => {});
|
|
966
|
-
await entry.nativeTurn.stopProcess("Conversation closed.");
|
|
967
|
-
if (entries.get(entry.key) === entry) entries.delete(entry.key);
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
async function deleteConversation(entry) {
|
|
971
|
-
await entry.nativeTurn.stopProcess();
|
|
972
|
-
await deleteClaudeConversationHistory({ configRoot, workdir: entry.nativeWorkdir, conversationId: entry.id });
|
|
973
|
-
await store.remove(entry);
|
|
974
|
-
entries.delete(entry.key);
|
|
975
|
-
return { ok: true, deleted: true, conversationId: entry.id };
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
async function recover(restoredEntries) {
|
|
979
|
-
for (const entry of restoredEntries) {
|
|
980
|
-
if (!entry.process && !hasPendingClaudeStartup(entry) && (entry.executionId || entry.turn?.active)) await entry.nativeTurn.stopProcess(disconnectedMessage);
|
|
981
|
-
}
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
// A provider connection change stops the matching work this server runs.
|
|
985
|
-
// Each entry stops on its own, so one failure is reported by ID.
|
|
986
|
-
async function stopSelected(select, reason) {
|
|
987
|
-
let closed = 0;
|
|
988
|
-
const failed = [];
|
|
989
|
-
for (const entry of [...entries.values()].filter(select)) {
|
|
990
|
-
entry.preparationRevision++;
|
|
991
|
-
if (!holdsNativeWork(entry)) continue;
|
|
992
|
-
try {
|
|
993
|
-
await entry.nativeTurn.stopProcess(reason);
|
|
994
|
-
closed++;
|
|
995
|
-
} catch (error) {
|
|
996
|
-
failed.push({ sessionId: entry.context.sessionId, conversationId: entry.id, error: error.message });
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
return { ok: failed.length === 0, closed, failed };
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
583
|
async function wait(entry, input, { context, acquire }) {
|
|
1003
584
|
return waitForClaudeConversationTurn({ state: entry,
|
|
1004
585
|
stop: reason => entry.nativeTurn.stopProcess(reason),
|
|
@@ -1036,7 +617,7 @@ export function createClaudeConversationOwner({
|
|
|
1036
617
|
const messageId = input.messageId || randomUUID();
|
|
1037
618
|
await entry.goals.update(input, { ...operations,
|
|
1038
619
|
...(selfStop ? { interrupt: async () => {} } : {}),
|
|
1039
|
-
send: command => operations?.send ? operations.send(command) : send(entry, { ...command, messageId }) });
|
|
620
|
+
send: command => operations?.send ? operations.send(command) : delivery.send(entry, { ...command, messageId }) });
|
|
1040
621
|
if (selfStop && currentApplicationCommand(entry, call.command, call.owner)) {
|
|
1041
622
|
// This scope identifies the already-authorized native invocation; it does
|
|
1042
623
|
// not grant tool authority. Retain its exact process until its tool_result
|
|
@@ -1068,9 +649,9 @@ export function createClaudeConversationOwner({
|
|
|
1068
649
|
break;
|
|
1069
650
|
}
|
|
1070
651
|
if (operations?.signal?.aborted) throw operations.signal.reason;
|
|
1071
|
-
if (Date.now() >= deadline) throw Object.assign(
|
|
652
|
+
if (Date.now() >= deadline) throw Object.assign(new AppError(503,
|
|
1072
653
|
"Claude accepted the goal command, but its goal attachment is not available yet. Check its original receipt before trying again.",
|
|
1073
|
-
"assistant_claude_goal_attachment_pending"), { delivery: "accepted"
|
|
654
|
+
{ code: "assistant_claude_goal_attachment_pending" }), { delivery: "accepted" });
|
|
1074
655
|
await delay(Math.min(250, Math.max(1, deadline - Date.now())), undefined, { signal: operations?.signal });
|
|
1075
656
|
}
|
|
1076
657
|
}
|
|
@@ -1080,817 +661,60 @@ export function createClaudeConversationOwner({
|
|
|
1080
661
|
|
|
1081
662
|
function renewal(entry, input, options, { context, acquire }) {
|
|
1082
663
|
return runClaudeRenewalTurn({ state: entry, readHistory: () => readHistory(entry),
|
|
1083
|
-
send: (message, sendOptions) => send(entry, message, sendOptions),
|
|
664
|
+
send: (message, sendOptions) => delivery.send(entry, message, sendOptions),
|
|
1084
665
|
wait: async current => wait(await acquire(context, current.conversationId || current.threadId), current, { context, acquire }),
|
|
1085
666
|
stop: reason => entry.nativeTurn.stopProcess(reason)
|
|
1086
667
|
}, input, options);
|
|
1087
668
|
}
|
|
1088
669
|
|
|
1089
|
-
async function cancel(control, { context, acquire, reportFailure, entry }) {
|
|
1090
|
-
if (bound) {
|
|
1091
|
-
await entry.nativeTurn.stopProcess("Work stopped.");
|
|
1092
|
-
return;
|
|
1093
|
-
}
|
|
1094
|
-
try {
|
|
1095
|
-
const value = await interrupt(await acquire(context || control.current?.context));
|
|
1096
|
-
if (control.cleanupFailure) {
|
|
1097
|
-
await reportFailure(control.cleanupFailure, { recovered: true });
|
|
1098
|
-
control.cleanupFailure = null;
|
|
1099
|
-
}
|
|
1100
|
-
return value;
|
|
1101
|
-
} catch (error) {
|
|
1102
|
-
error.cleanupFailed = true;
|
|
1103
|
-
control.cleanupFailure = error;
|
|
1104
|
-
control.current?.failure.reject(error);
|
|
1105
|
-
await reportFailure(error);
|
|
1106
|
-
throw error;
|
|
1107
|
-
}
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
async function dispatch(control, command, { acquire, entry: selectedEntry, dispatch: nativeDispatch }) {
|
|
1111
|
-
if (bound) {
|
|
1112
|
-
await send(selectedEntry, command.input, { command: nativeDispatch });
|
|
1113
|
-
return;
|
|
1114
|
-
}
|
|
1115
|
-
command.signal.throwIfAborted();
|
|
1116
|
-
const entry = await acquire(command.context);
|
|
1117
|
-
const active = control.current;
|
|
1118
|
-
const applicationCommand = active && (active.tools || active.finishIfCurrent) ? { active, accept: command.accept } : null;
|
|
1119
|
-
if (applicationCommand) {
|
|
1120
|
-
if (entry.disposed || entry.command && entry.command !== active) throw new Error("This Claude conversation is closed or already working.");
|
|
1121
|
-
if (active.entry && active.entry !== entry) throw new Error("Claude's application tools belong to another native conversation.");
|
|
1122
|
-
active.entry = entry;
|
|
1123
|
-
entry.command = active;
|
|
1124
|
-
entry.toolSchemas = active.tools?.schemas || [];
|
|
1125
|
-
}
|
|
1126
|
-
const value = await send(entry, { ...command.input.nativeMessage, signal: command.input.signal, onPromptSending: command.beforeDispatch,
|
|
1127
|
-
onNativeReceipt: command.onNativeReceipt, onReceiptPersisted: command.onReceiptPersisted, deliveryTiming: command.deliveryTiming },
|
|
1128
|
-
{ applicationCommand });
|
|
1129
|
-
const completion = value.conversationTurn ? entry.completion?.promise : null;
|
|
1130
|
-
const delivered = { value, completion };
|
|
1131
|
-
if (!applicationCommand && value.delivered === true && value.conversationTurn) {
|
|
1132
|
-
if (control.current) control.current.delivery = delivered;
|
|
1133
|
-
await command.accept({ nativeResult: { value }, conversationTurn: value.conversationTurn, nativeTurnId: value.turn?.id });
|
|
1134
|
-
}
|
|
1135
|
-
return delivered;
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
function steer(control, command, options) {
|
|
1139
|
-
if (!bound) return dispatch(control, command, options).then(delivered => ({ value: delivered.value }));
|
|
1140
|
-
if (!control.current?.steer || !control.current.entry.nativeTurn.read().active) throw new Error("Claude has no active turn to steer.");
|
|
1141
|
-
return control.current.steer(command);
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
async function run(control, command, { acquire, reportFailure, entry: selectedEntry, maximumOutput, maximumToolCalls = 32, timeoutMs } = {}) {
|
|
1145
|
-
if (control.disposed || control.current) throw new Error("This Claude conversation is closed or already working.");
|
|
1146
|
-
const { configuration, context, input, tools, signal, beforeDispatch, accept, onMessage, onEvent, onNativeToolUse } = command;
|
|
1147
|
-
const completion = Promise.withResolvers();
|
|
1148
|
-
completion.promise.catch(() => {});
|
|
1149
|
-
const active = { completion, onMessage, onEvent, onNativeToolUse, signal, tools, admitted: false, nativeTools: new Map(),
|
|
1150
|
-
messageId: input.messageId, goal: input.goal, nativeToolCount: 0, toolWork: new Set(), maximumToolCalls,
|
|
1151
|
-
context, finishIfCurrent: command.finishIfCurrent, failure: Promise.withResolvers() };
|
|
1152
|
-
let failure;
|
|
1153
|
-
active.failure.promise.catch(error => { failure = error; });
|
|
1154
|
-
control.current = active;
|
|
1155
|
-
const ready = Promise.withResolvers();
|
|
1156
|
-
ready.promise.catch(() => {});
|
|
1157
|
-
active.steer = async ({ input, beforeDispatch, accept, onNativeReceipt, onReceiptPersisted, deliveryTiming }) => {
|
|
1158
|
-
await ready.promise;
|
|
1159
|
-
const nativeDispatch = { active, signal, beforeDispatch, accept, onNativeReceipt, onReceiptPersisted, deliveryTiming,
|
|
1160
|
-
timeoutMs, steering: true, attempted: false, admitted: false };
|
|
1161
|
-
try { await dispatch(control, { input, context, signal }, { acquire, entry, dispatch: nativeDispatch }); }
|
|
1162
|
-
catch (error) {
|
|
1163
|
-
if (nativeDispatch.attempted && !nativeDispatch.admitted && error.delivery !== "not-sent") error.delivery = "uncertain";
|
|
1164
|
-
if (nativeDispatch.attempted) completion.reject(error);
|
|
1165
|
-
throw error;
|
|
1166
|
-
}
|
|
1167
|
-
};
|
|
1168
|
-
const nativeDispatch = { active, signal, beforeDispatch, accept, onNativeReceipt: command.onNativeReceipt,
|
|
1169
|
-
onReceiptPersisted: command.onReceiptPersisted, deliveryTiming: command.deliveryTiming, timeoutMs, maximumOutput, attempted: false, admitted: false };
|
|
1170
|
-
let entry;
|
|
1171
|
-
let cancellation;
|
|
1172
|
-
let problem;
|
|
1173
|
-
let delivered;
|
|
1174
|
-
function abort() {
|
|
1175
|
-
if (cancellation) return;
|
|
1176
|
-
if (bound) {
|
|
1177
|
-
cancellation = entry.nativeTurn.cancel(entry.process?.client, () => entry.nativeTurn.stopProcess("Work stopped."));
|
|
1178
|
-
cancellation.then(() => completion.reject(signal.reason), error => completion.reject(error));
|
|
1179
|
-
} else {
|
|
1180
|
-
cancellation = cancel(control, { context, acquire, reportFailure }).then(() => { throw signal.reason; });
|
|
1181
|
-
cancellation.catch(active.failure.reject);
|
|
1182
|
-
}
|
|
1183
|
-
}
|
|
1184
|
-
try {
|
|
1185
|
-
if (bound) {
|
|
1186
|
-
entry = selectedEntry;
|
|
1187
|
-
if (entry.disposed || entry.command) throw new Error("This Claude conversation is closed or already working.");
|
|
1188
|
-
active.entry = entry;
|
|
1189
|
-
entry.command = active;
|
|
1190
|
-
entry.completion = completion;
|
|
1191
|
-
entry.toolSchemas = tools?.schemas || [];
|
|
1192
|
-
active.delivery = { completion: completion.promise };
|
|
1193
|
-
nativeDispatch.prepared = await preparation.configuration(entry, { configuration, context, signal, tools });
|
|
1194
|
-
}
|
|
1195
|
-
signal.addEventListener("abort", abort, { once: true });
|
|
1196
|
-
let dispatchFailure;
|
|
1197
|
-
try {
|
|
1198
|
-
delivered = await dispatch(control, command, { acquire, entry, dispatch: nativeDispatch });
|
|
1199
|
-
if (bound) ready.resolve();
|
|
1200
|
-
} catch (error) { dispatchFailure = error; }
|
|
1201
|
-
for (;;) {
|
|
1202
|
-
const settling = active.delivery;
|
|
1203
|
-
let result;
|
|
1204
|
-
problem = dispatchFailure || failure;
|
|
1205
|
-
if (!problem && settling?.completion) {
|
|
1206
|
-
try { result = await Promise.race([settling.completion, active.failure.promise]); }
|
|
1207
|
-
catch (error) { problem = error; }
|
|
1208
|
-
}
|
|
1209
|
-
if (command.finishIfCurrent && !await command.finishIfCurrent(() => active.delivery === settling)) continue;
|
|
1210
|
-
if (!problem && typeof result === "function") {
|
|
1211
|
-
try { result = await result(); }
|
|
1212
|
-
catch (error) { problem = error; }
|
|
1213
|
-
}
|
|
1214
|
-
problem = dispatchFailure || failure || problem;
|
|
1215
|
-
if (!problem && settling?.completion && (bound ? result.status !== "completed" : !result.ok)) {
|
|
1216
|
-
problem = Object.assign(new Error(result.error || (bound ? "Claude interrupted this answer." : `Claude turn ${result.status}.`)), { status: result.status });
|
|
1217
|
-
}
|
|
1218
|
-
break;
|
|
1219
|
-
}
|
|
1220
|
-
} catch (error) { problem = error; }
|
|
1221
|
-
ready.reject(problem || new Error("Claude work ended."));
|
|
1222
|
-
signal.removeEventListener("abort", abort);
|
|
1223
|
-
if (bound) {
|
|
1224
|
-
try {
|
|
1225
|
-
if (problem || signal.aborted) await (cancellation || entry.nativeTurn.stopProcess(problem?.message || "Work stopped."));
|
|
1226
|
-
} catch (error) {
|
|
1227
|
-
if (problem && error !== problem) error.cause ||= problem;
|
|
1228
|
-
problem = error;
|
|
1229
|
-
}
|
|
1230
|
-
finally {
|
|
1231
|
-
await Promise.allSettled([...active.toolWork]);
|
|
1232
|
-
if (entry?.command === active) entry.command = null;
|
|
1233
|
-
}
|
|
1234
|
-
} else {
|
|
1235
|
-
await cancellation?.catch(() => {});
|
|
1236
|
-
if (active.entry) {
|
|
1237
|
-
try {
|
|
1238
|
-
// A failed Stop already retained custody for the caller's retry.
|
|
1239
|
-
// Do not race that retry with another implicit cleanup attempt.
|
|
1240
|
-
if ((problem || signal.aborted) && !problem?.cleanupFailed) {
|
|
1241
|
-
await active.entry.nativeTurn.stopProcess(problem?.message || "Work stopped.");
|
|
1242
|
-
}
|
|
1243
|
-
} catch (error) {
|
|
1244
|
-
if (problem && error !== problem) error.cause ||= problem;
|
|
1245
|
-
problem = error;
|
|
1246
|
-
}
|
|
1247
|
-
finally {
|
|
1248
|
-
await Promise.allSettled([...active.toolWork]);
|
|
1249
|
-
if (active.entry.command === active) active.entry.command = null;
|
|
1250
|
-
}
|
|
1251
|
-
}
|
|
1252
|
-
}
|
|
1253
|
-
if (control.current === active) control.current = null;
|
|
1254
|
-
if (problem) {
|
|
1255
|
-
if (bound && nativeDispatch.attempted && !nativeDispatch.admitted && problem.delivery !== "not-sent") problem.delivery = "uncertain";
|
|
1256
|
-
throw problem;
|
|
1257
|
-
}
|
|
1258
|
-
if (!bound) return { value: delivered.value };
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
return Object.freeze({ applicationToolsSupported: true, entries, closingSessions, get closing() { return closing; }, createAccountQueries, open, acquire, createConversation, snapshot, readRetainedTurn, readFinalAssistantResult, readHistory, nativeWorkSettled, ensureReady, ensureProcess, readContextUsage, compactContext, releaseRetainedGoal, stopForTerminal, prepareTerminal, send, startTurn,
|
|
1262
|
-
interrupt, read, inspectAdmission, hasActiveTemporaryConversation, hasActiveConversation, retireConversationHistory, reconcileSessions, restoreSessionEntries, closeSession, releaseSession, closeProject, invalidateRuntimes, drain, forget, close, deleteConversation, recover, stopSelected, wait, stopConversation,
|
|
1263
|
-
readGoal, updateGoal, renewal, run, steer, cancel });
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
/** Native turn handling moved from the production session provider; hosts persist its events. */
|
|
1267
|
-
export function createClaudeConversationTurn({ conversationId, onEvent = async () => {}, process, entry, bound = false } = {}) {
|
|
1268
|
-
if (!conversationId) throw new TypeError("A Claude conversation id is required.");
|
|
1269
|
-
const instructions = process ? createClaudeConversationAdapter(process) : null;
|
|
1270
|
-
const publish = entry ? publishEntryEvent : onEvent;
|
|
1271
|
-
const messages = new Map();
|
|
1272
|
-
const pendingAssistantMessages = new Map();
|
|
1273
|
-
const assistantRoles = new Map();
|
|
1274
|
-
const admissions = new Map();
|
|
1275
|
-
const inFlight = new Set();
|
|
1276
|
-
// Inputs Claude has begun reading. A queued steer joins the running loop, so
|
|
1277
|
-
// one result can finish several of them; queued-only input stays in flight.
|
|
1278
|
-
const startedInputs = new Set();
|
|
1279
|
-
// Steers written into a running loop that Claude has not begun reading. Claude
|
|
1280
|
-
// Code reads queued input only when the running tool call returns.
|
|
1281
|
-
const waitingSteers = new Set();
|
|
1282
|
-
let steeringClient = null;
|
|
1283
|
-
const tasks = new Set();
|
|
1284
|
-
let turnId = "";
|
|
1285
|
-
let messageId = "";
|
|
1286
|
-
let currentCommandId = "";
|
|
1287
|
-
let result = "";
|
|
1288
|
-
let phase = "";
|
|
1289
|
-
let outcome;
|
|
1290
|
-
let active = false;
|
|
1291
|
-
let generation = 0;
|
|
1292
|
-
let stopped = false;
|
|
1293
|
-
let cancelling = false;
|
|
1294
|
-
let maximumOutput = 4 * 1024 * 1024;
|
|
1295
|
-
let inputOwner = null;
|
|
1296
|
-
let acceptedInput = null;
|
|
1297
|
-
let publishedAssistant = null;
|
|
1298
|
-
let finalAssistantResult = null;
|
|
1299
|
-
let resolvedCompletion;
|
|
1300
|
-
let latestSettlement;
|
|
1301
|
-
// A common command fences its first checkpoint; later native continuations
|
|
1302
|
-
// keep the same input but cannot enqueue onto that already resolved command.
|
|
1303
|
-
let checkpointedCommand;
|
|
1304
|
-
|
|
1305
|
-
function clearFinalAssistantResult() {
|
|
1306
|
-
publishedAssistant = null;
|
|
1307
|
-
finalAssistantResult = null;
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
function invalidateFinalAssistantResult() {
|
|
1311
|
-
inputOwner = null;
|
|
1312
|
-
acceptedInput = null;
|
|
1313
|
-
clearFinalAssistantResult();
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
// A waiting steer would otherwise sit behind a long foreground command (or a
|
|
1317
|
-
// wait loop) until it returns. Claude's Ctrl+B control moves foreground Bash
|
|
1318
|
-
// and subagents to the background: they keep running and report completion,
|
|
1319
|
-
// and the blocked tool call returns so Claude reads the steer now.
|
|
1320
|
-
function backgroundForegroundWork() {
|
|
1321
|
-
const client = steeringClient;
|
|
1322
|
-
if (!waitingSteers.size || !client?.request || stopped || cancelling) return;
|
|
1323
|
-
void client.request({ subtype: "background_tasks" }).then(
|
|
1324
|
-
() => publish({ type: "diagnostic", stage: "steering_backgrounded", pendingCommands: inFlight.size, backgroundTasks: tasks.size }),
|
|
1325
|
-
// An older CLI still reads the steer when its tool call returns.
|
|
1326
|
-
error => publish({ type: "diagnostic", stage: "steering_background_failed", error: error.message })
|
|
1327
|
-
).catch(() => {});
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
function currentReceiptOwner(owner) {
|
|
1331
|
-
return Boolean(entry && owner && inputOwner === owner && acceptedInput === owner &&
|
|
1332
|
-
owner.commandId === currentCommandId && entry.process && entry.process === owner.process &&
|
|
1333
|
-
entry.executionId === owner.executionId && entry.accountIdentity === owner.accountIdentity &&
|
|
1334
|
-
entry.turn?.id === turnId && !entry.stopping && !entry.stopPending && !entry.disposed &&
|
|
1335
|
-
!entry.observationError && !entry.command?.toolWork.size && !entry.command?.toolFailure);
|
|
1336
|
-
}
|
|
1337
|
-
|
|
1338
|
-
function readFinalAssistantResult(threadId, expectedTurnId) {
|
|
1339
|
-
if (threadId !== conversationId || expectedTurnId !== turnId || !finalAssistantResult ||
|
|
1340
|
-
active || stopped || cancelling || outcome?.status !== "completed" || outcome.error ||
|
|
1341
|
-
inFlight.size || tasks.size || admissions.size || !currentReceiptOwner(acceptedInput)) return null;
|
|
1342
|
-
return structuredClone(finalAssistantResult);
|
|
1343
|
-
}
|
|
1344
|
-
|
|
1345
|
-
function read() {
|
|
1346
|
-
return { turnId, active, text: result, phase, status: active ? "inProgress" : outcome?.status || "ready",
|
|
1347
|
-
error: outcome?.error || "", messages: structuredClone([...messages.values()]),
|
|
1348
|
-
pendingCommands: inFlight.size, backgroundTasks: tasks.size };
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
function readResult() {
|
|
1352
|
-
const current = read();
|
|
1353
|
-
const failed = entry.turn?.state === "failed";
|
|
1354
|
-
let status = "completed";
|
|
1355
|
-
if (entry.turn?.active) status = "inProgress";
|
|
1356
|
-
else if (failed) status = "failed";
|
|
1357
|
-
else if (entry.turn?.state === "interrupted") status = "interrupted";
|
|
1358
|
-
return { ok: !failed, conversationId: entry.id, threadId: entry.id,
|
|
1359
|
-
runId: entry.turn?.id || "", turnId: entry.turn?.id || "", text: current.text,
|
|
1360
|
-
messages: current.messages, error: entry.turn?.error || "", status };
|
|
1361
|
-
}
|
|
1362
|
-
|
|
1363
|
-
function resolveCompletion(result) {
|
|
1364
|
-
entry.completion?.resolve(result);
|
|
1365
|
-
resolvedCompletion = entry.completion;
|
|
1366
|
-
if (entry.command?.finishIfCurrent) entry.command.completion.resolve(result);
|
|
1367
|
-
}
|
|
1368
|
-
|
|
1369
|
-
async function updateState(state, message = "", expectedGeneration = generation) {
|
|
1370
|
-
if (!entry.turn || expectedGeneration !== generation) return false;
|
|
1371
|
-
const active = ["starting", "active", "finalizing"].includes(state);
|
|
1372
|
-
const checkpoint = onEvent({ type: "before-state", state, active, message });
|
|
1373
|
-
if (checkpoint) await checkpoint;
|
|
1374
|
-
if (expectedGeneration !== generation) return false;
|
|
1375
|
-
setState(state, message);
|
|
1376
|
-
await onEvent({ type: "save" });
|
|
1377
|
-
if (expectedGeneration !== generation) return false;
|
|
1378
|
-
await onEvent({ type: "state", state, active, message });
|
|
1379
|
-
return expectedGeneration === generation;
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
|
-
function setState(state, message = "") {
|
|
1383
|
-
if (!entry.turn) return;
|
|
1384
|
-
const active = ["starting", "active", "finalizing"].includes(state);
|
|
1385
|
-
Object.assign(entry.turn, { active, state, error: message, updatedAt: new Date().toISOString(),
|
|
1386
|
-
phase: active && !entry.observationError ? String(entry.turn.phase ?? "").trim() : "" });
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
async function stopProcess(reason = "", { preparing = false } = {}) {
|
|
1390
|
-
clearTimeout(entry.goalToolStop?.timer);
|
|
1391
|
-
entry.goalToolStop = null;
|
|
1392
|
-
if (!preparing) entry.preparationRevision++;
|
|
1393
|
-
invalidateFinalAssistantResult();
|
|
1394
|
-
// Runtime bindings commit execution release before dropping the handle.
|
|
1395
|
-
// Retained host entries keep their original post-proof checkpoint order.
|
|
1396
|
-
if (entry.stopPending) return entry.stopPending;
|
|
1397
|
-
const active = read().active;
|
|
1398
|
-
if (!bound) entry.stopping = true;
|
|
1399
|
-
const operation = async () => {
|
|
1400
|
-
const startup = entry.startupCleanup?.executionId === entry.executionId ? entry.startupCleanup : null;
|
|
1401
|
-
let proof;
|
|
1402
|
-
try {
|
|
1403
|
-
if (!bound || entry.process || entry.executionId) {
|
|
1404
|
-
proof = await stopClaudeCodeProcess({ process: entry.process || startup, executionId: entry.executionId,
|
|
1405
|
-
stopExecution: process.stopExecution });
|
|
1406
|
-
}
|
|
1407
|
-
} catch (error) {
|
|
1408
|
-
if (bound || error.code !== "claude_stop_unconfirmed") throw error;
|
|
1409
|
-
entry.observationError = reason || "Claude process exit has not been confirmed. Try Stop again.";
|
|
1410
|
-
await onEvent({ type: "save" });
|
|
1411
|
-
await onEvent({ type: "stop-unconfirmed" });
|
|
1412
|
-
throw error;
|
|
1413
|
-
}
|
|
1414
|
-
if (bound && (entry.process || entry.executionId)) await process.releaseExecution();
|
|
1415
|
-
entry.process = null;
|
|
1416
|
-
entry.executionId = "";
|
|
1417
|
-
entry.exitProof = proof;
|
|
1418
|
-
stop(bound ? reason : reason || "Claude stopped before acknowledging the prompt.");
|
|
1419
|
-
if (bound) {
|
|
1420
|
-
if (entry.startupCleanup === startup) entry.startupCleanup = null;
|
|
1421
|
-
if (active) entry.completion?.resolve({ status: "interrupted", error: reason || "Work stopped." });
|
|
1422
|
-
return;
|
|
1423
|
-
}
|
|
1424
|
-
if (entry.turn?.active) {
|
|
1425
|
-
try { await updateState("interrupted", reason); }
|
|
1426
|
-
catch (error) {
|
|
1427
|
-
// Native cleanup is verified even when the host refuses its checkpoint.
|
|
1428
|
-
// Retain that refusal without publishing a completed answer or active work.
|
|
1429
|
-
setState("interrupted", error.message);
|
|
1430
|
-
await onEvent({ type: "save" });
|
|
1431
|
-
await onEvent({ type: "state", state: "interrupted", active: false, message: error.message });
|
|
1432
|
-
resolveCompletion(readResult());
|
|
1433
|
-
if (entry.startupCleanup === startup) entry.startupCleanup = null;
|
|
1434
|
-
throw error;
|
|
1435
|
-
}
|
|
1436
|
-
} else await onEvent({ type: "save" });
|
|
1437
|
-
resolveCompletion(readResult());
|
|
1438
|
-
if (entry.startupCleanup === startup) entry.startupCleanup = null;
|
|
1439
|
-
return entry.exitProof;
|
|
1440
|
-
};
|
|
1441
|
-
entry.stopPending = operation().catch(error => { if (bound) error.cleanupFailed = true; throw error; })
|
|
1442
|
-
.finally(() => { entry.stopPending = null; });
|
|
1443
|
-
return entry.stopPending;
|
|
1444
|
-
}
|
|
1445
|
-
|
|
1446
|
-
async function publishEntryEvent(event, deferred = false) {
|
|
1447
|
-
if (entry.stopping) return;
|
|
1448
|
-
if (event.type === "settled" && event.generation !== generation) return;
|
|
1449
|
-
const command = entry.command;
|
|
1450
|
-
if (event.type === "settled" && !bound && !deferred && command?.finishIfCurrent && command !== checkpointedCommand) {
|
|
1451
|
-
const delivery = command.delivery;
|
|
1452
|
-
const completion = entry.completion;
|
|
1453
|
-
const owner = acceptedInput;
|
|
1454
|
-
// Release the native queue before the common run joins reserved steering.
|
|
1455
|
-
// That same accepted delivery then performs the original retained checkpoint.
|
|
1456
|
-
command.completion.resolve(async () => {
|
|
1457
|
-
try {
|
|
1458
|
-
for (;;) {
|
|
1459
|
-
if (entry.command !== command || command.delivery !== delivery || entry.completion !== completion ||
|
|
1460
|
-
command.signal.aborted || !currentReceiptOwner(owner)) {
|
|
1461
|
-
throw failure("Claude's completion belongs to a retired native input.");
|
|
1462
|
-
}
|
|
1463
|
-
// Follow-ups share this input, but can finish while its checkpoint
|
|
1464
|
-
// awaits storage. Apply the latest settlement after the same fence.
|
|
1465
|
-
if (active) return readResult();
|
|
1466
|
-
const settlement = latestSettlement;
|
|
1467
|
-
await publishEntryEvent(settlement, true);
|
|
1468
|
-
if (settlement === latestSettlement) return readResult();
|
|
1469
|
-
}
|
|
1470
|
-
} catch (error) {
|
|
1471
|
-
clearFinalAssistantResult();
|
|
1472
|
-
throw error;
|
|
1473
|
-
} finally {
|
|
1474
|
-
checkpointedCommand = command;
|
|
1475
|
-
}
|
|
1476
|
-
});
|
|
1477
|
-
return;
|
|
1478
|
-
}
|
|
1479
|
-
if (event.type === "continued") {
|
|
1480
|
-
if (entry.completion === resolvedCompletion) entry.completion = Promise.withResolvers();
|
|
1481
|
-
if (bound) setState("active");
|
|
1482
|
-
else await updateState("active");
|
|
1483
|
-
} else if (event.type === "admitted") {
|
|
1484
|
-
if (event.userEcho) entry.sent = true;
|
|
1485
|
-
if (!bound) await onEvent({ type: "save" });
|
|
1486
|
-
} else if (event.type === "phase") {
|
|
1487
|
-
entry.turn.phase = event.phase;
|
|
1488
|
-
if (!bound) await updateState(entry.turn.state);
|
|
1489
|
-
} else if (event.type === "settled") {
|
|
1490
|
-
const state = event.status === "interrupted" ? "interrupted" : event.status === "failed" ? "failed" : "completed";
|
|
1491
|
-
if (bound) setState(state, event.error);
|
|
1492
|
-
else if (!await updateState(state, event.error, event.generation)) return;
|
|
1493
|
-
}
|
|
1494
|
-
const published = await onEvent(event);
|
|
1495
|
-
if (event.type === "provider-event") {
|
|
1496
|
-
const frame = event.event;
|
|
1497
|
-
const change = frame.type === "active_goal" ? "goal" : frame.type === "rate_limit_event" ? "usage" : "";
|
|
1498
|
-
if (change) await onEvent({ type: "provider-update", change });
|
|
1499
|
-
}
|
|
1500
|
-
if (event.type === "settled") {
|
|
1501
|
-
if (event.generation !== generation) return;
|
|
1502
|
-
if (event.source === "result") await onEvent({ type: "provider-update", change: "goal" });
|
|
1503
|
-
resolveCompletion(bound ? event : readResult());
|
|
1504
|
-
}
|
|
1505
|
-
return published;
|
|
1506
|
-
}
|
|
1507
|
-
|
|
1508
|
-
function checkOutput(value, role = "assistant") {
|
|
1509
|
-
if (value.length > (role === "thinking" ? 4 * 1024 * 1024 : maximumOutput)) {
|
|
1510
|
-
throw failure("Claude output exceeded its size limit.");
|
|
1511
|
-
}
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
async function publishMessage(message) {
|
|
1515
|
-
if (!message.userId && currentCommandId) message = { ...message, userId: currentCommandId };
|
|
1516
|
-
messages.set(message.id, message);
|
|
1517
|
-
const owner = acceptedInput;
|
|
1518
|
-
const completeAssistant = message.complete && message.role === "assistant";
|
|
1519
|
-
if (completeAssistant) clearFinalAssistantResult();
|
|
1520
|
-
const conversationTurn = await publish({ type: "message", message });
|
|
1521
|
-
if (completeAssistant && message.text && currentReceiptOwner(owner) && !stopped && !cancelling &&
|
|
1522
|
-
conversationTurn && typeof conversationTurn === "object" && !Array.isArray(conversationTurn)) {
|
|
1523
|
-
publishedAssistant = { owner, itemId: message.id, outputId: message.outputId || message.id,
|
|
1524
|
-
text: message.text, conversationTurn: structuredClone(conversationTurn) };
|
|
1525
|
-
}
|
|
1526
|
-
return conversationTurn;
|
|
1527
|
-
}
|
|
1528
|
-
|
|
1529
|
-
async function flushAssistantMessages(apiMessageId, role) {
|
|
1530
|
-
assistantRoles.set(apiMessageId, role);
|
|
1531
|
-
const pending = pendingAssistantMessages.get(apiMessageId) || [];
|
|
1532
|
-
pendingAssistantMessages.delete(apiMessageId);
|
|
1533
|
-
for (const block of pending) {
|
|
1534
|
-
await publishMessage({ ...block, role });
|
|
1535
|
-
if (block.outputId) {
|
|
1536
|
-
if (block.outputId !== block.id) messages.delete(block.outputId);
|
|
1537
|
-
await publish({ type: "message-complete", messageId: block.outputId });
|
|
1538
|
-
}
|
|
1539
|
-
}
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
|
-
async function receive(frame) {
|
|
1543
|
-
if (stopped) return;
|
|
1544
|
-
if (frame.session_id && frame.session_id !== conversationId) throw failure("Claude returned a different conversation id.");
|
|
1545
|
-
if (frame.parent_tool_use_id) return; // Nested agents do not replace the main reply.
|
|
1546
|
-
await publish({ type: "provider-event", event: frame });
|
|
1547
|
-
const continuation = (frame.type === "user" && frame.origin?.kind === "task-notification") ||
|
|
1548
|
-
(frame.type === "command_lifecycle" && frame.state === "started") ||
|
|
1549
|
-
(frame.type === "stream_event" && frame.event?.type === "message_start") || frame.type === "assistant";
|
|
1550
|
-
if (continuation && turnId && !cancelling) {
|
|
1551
|
-
const wasActive = active;
|
|
1552
|
-
if (outcome || !active) {
|
|
1553
|
-
generation++;
|
|
1554
|
-
outcome = null;
|
|
1555
|
-
result = "";
|
|
1556
|
-
clearFinalAssistantResult();
|
|
1557
|
-
}
|
|
1558
|
-
active = true;
|
|
1559
|
-
if (!wasActive) await publish({ type: "continued", source: frame.type });
|
|
1560
|
-
if (phase === "background") {
|
|
1561
|
-
phase = "";
|
|
1562
|
-
await publish({ type: "phase", phase });
|
|
1563
|
-
}
|
|
1564
|
-
}
|
|
1565
|
-
if (frame.type === "command_lifecycle" || frame.type === "user") {
|
|
1566
|
-
const uuid = frame.type === "user" ? frame.uuid : frame.command_uuid;
|
|
1567
|
-
if (frame.type === "user" && !inFlight.has(uuid) && !admissions.has(uuid)) return;
|
|
1568
|
-
// Native notifications have their own lifecycle UUIDs, including work
|
|
1569
|
-
// folded into this input. They must not steal its receipt/result owner.
|
|
1570
|
-
if ((frame.state === "started" || frame.type === "user") && inFlight.has(uuid)) {
|
|
1571
|
-
currentCommandId = uuid;
|
|
1572
|
-
startedInputs.add(uuid);
|
|
1573
|
-
}
|
|
1574
|
-
if (frame.state !== "queued") waitingSteers.delete(uuid);
|
|
1575
|
-
if (frame.state === "completed") {
|
|
1576
|
-
inFlight.delete(uuid);
|
|
1577
|
-
startedInputs.delete(uuid);
|
|
1578
|
-
}
|
|
1579
|
-
if (frame.state === "queued" || frame.type === "user") {
|
|
1580
|
-
const pending = admissions.get(uuid);
|
|
1581
|
-
if (pending) {
|
|
1582
|
-
// Once acknowledged, storage owns the admission decision. Its write
|
|
1583
|
-
// must not race a native-ack timeout and a second submission.
|
|
1584
|
-
clearTimeout(pending.timer);
|
|
1585
|
-
try {
|
|
1586
|
-
await pending.accept();
|
|
1587
|
-
if (inputOwner === pending.receiptOwner && !stopped && !cancelling) acceptedInput = pending.receiptOwner;
|
|
1588
|
-
admissions.delete(uuid);
|
|
1589
|
-
pending.resolve();
|
|
1590
|
-
} catch (error) {
|
|
1591
|
-
acceptedInput = null;
|
|
1592
|
-
clearFinalAssistantResult();
|
|
1593
|
-
pending.reject(error);
|
|
1594
|
-
throw error;
|
|
1595
|
-
}
|
|
1596
|
-
}
|
|
1597
|
-
await publish({ type: "admitted", messageId: uuid, userEcho: frame.type === "user" });
|
|
1598
|
-
}
|
|
1599
|
-
} else if (frame.type === "system") {
|
|
1600
|
-
if (active && ["status", "compact_boundary"].includes(frame.subtype)) {
|
|
1601
|
-
const nextPhase = frame.subtype === "status" && frame.status === "compacting" ? "compacting" : "";
|
|
1602
|
-
// An idle status does not end a wait that only background tasks own.
|
|
1603
|
-
if (nextPhase !== phase && (nextPhase || phase !== "background")) {
|
|
1604
|
-
phase = nextPhase;
|
|
1605
|
-
await publish({ type: "phase", phase });
|
|
1606
|
-
}
|
|
1607
|
-
}
|
|
1608
|
-
if (frame.subtype === "local_command_output" && frame.content) {
|
|
1609
|
-
await publishMessage({ id: `claude_${frame.uuid}`, role: "commentary", text: frame.content, complete: true,
|
|
1610
|
-
data: { native: { engineId: "claude", type: "system", subtype: "local_command_output" } } });
|
|
1611
|
-
}
|
|
1612
|
-
const previousTaskCount = tasks.size;
|
|
1613
|
-
if (frame.subtype === "background_tasks_changed") {
|
|
1614
|
-
tasks.clear();
|
|
1615
|
-
for (const task of frame.tasks || []) if (task.task_id && !task.ambient) tasks.add(task.task_id);
|
|
1616
|
-
}
|
|
1617
|
-
if (frame.subtype === "task_started" && frame.task_id && !frame.ambient) tasks.add(frame.task_id);
|
|
1618
|
-
// A command Claude started before reading a waiting steer would hold it too.
|
|
1619
|
-
if (frame.subtype === "task_started") backgroundForegroundWork();
|
|
1620
|
-
if (["task_notification", "task_updated"].includes(frame.subtype) &&
|
|
1621
|
-
["completed", "failed", "stopped", "killed"].includes(frame.status || frame.patch?.status)) {
|
|
1622
|
-
tasks.delete(frame.task_id);
|
|
1623
|
-
}
|
|
1624
|
-
// Inventory removal can precede the task notification and the main model's
|
|
1625
|
-
// response to it. A foreground result written while that worker was live
|
|
1626
|
-
// cannot complete this follow-up or retire its admitted application tools.
|
|
1627
|
-
if (tasks.size < previousTaskCount) outcome = null;
|
|
1628
|
-
if (phase === "background" && !outcome) {
|
|
1629
|
-
phase = "";
|
|
1630
|
-
await publish({ type: "phase", phase });
|
|
1631
|
-
}
|
|
1632
|
-
if (["background_tasks_changed", "task_started", "task_notification", "task_updated"].includes(frame.subtype)) {
|
|
1633
|
-
if (tasks.size && turnId && !active && !cancelling) {
|
|
1634
|
-
generation++;
|
|
1635
|
-
active = true;
|
|
1636
|
-
clearFinalAssistantResult();
|
|
1637
|
-
await publish({ type: "continued", source: frame.subtype });
|
|
1638
|
-
}
|
|
1639
|
-
await publish({ type: "diagnostic", stage: "background_tasks", nativeEvent: frame.subtype,
|
|
1640
|
-
active, pendingCommands: inFlight.size, backgroundTasks: tasks.size });
|
|
1641
|
-
}
|
|
1642
|
-
} else if (frame.type === "stream_event") {
|
|
1643
|
-
const event = frame.event || {};
|
|
1644
|
-
if (event.type === "message_start") messageId = event.message?.id || frame.uuid;
|
|
1645
|
-
const id = `claude_${messageId}_${event.index}`;
|
|
1646
|
-
if (event.type === "content_block_start" && ["text", "thinking"].includes(event.content_block?.type)) {
|
|
1647
|
-
messages.set(id, { id, outputId: id, userId: currentCommandId, complete: false, role: event.content_block.type === "thinking" ? "thinking" : "assistant",
|
|
1648
|
-
text: event.content_block.text || event.content_block.thinking || "" });
|
|
1649
|
-
} else if (event.type === "content_block_delta") {
|
|
1650
|
-
const block = messages.get(id);
|
|
1651
|
-
const delta = event.delta?.text ?? event.delta?.thinking;
|
|
1652
|
-
if (block && typeof delta === "string") {
|
|
1653
|
-
block.text += delta;
|
|
1654
|
-
checkOutput(block.text, block.role);
|
|
1655
|
-
await publishMessage(block);
|
|
1656
|
-
await publish({ type: block.role === "thinking" ? "thinking" : "text", text: delta, messageId: id });
|
|
1657
|
-
}
|
|
1658
|
-
} else if (event.type === "content_block_stop") {
|
|
1659
|
-
if (![...pendingAssistantMessages.values()].some(blocks => blocks.some(block => block.outputId === id))) {
|
|
1660
|
-
messages.delete(id);
|
|
1661
|
-
await publish({ type: "message-complete", messageId: id });
|
|
1662
|
-
}
|
|
1663
|
-
} else if (event.type === "message_delta" && event.delta?.stop_reason) {
|
|
1664
|
-
await flushAssistantMessages(messageId, event.delta.stop_reason === "tool_use" ? "commentary" : "assistant");
|
|
1665
|
-
}
|
|
1666
|
-
} else if (frame.type === "assistant") {
|
|
1667
|
-
const apiMessageId = frame.message?.id;
|
|
1668
|
-
if (apiMessageId && frame.message.content?.some(block => block.type === "tool_use")) {
|
|
1669
|
-
await flushAssistantMessages(apiMessageId, "commentary");
|
|
1670
|
-
}
|
|
1671
|
-
for (const block of claudeMessageBlocks(frame)) {
|
|
1672
|
-
block.userId = currentCommandId;
|
|
1673
|
-
checkOutput(block.text, block.role);
|
|
1674
|
-
// Native split frames arrive before their block's stop event. Keep that
|
|
1675
|
-
// existing live identity without replacing the saved frame UUID.
|
|
1676
|
-
if (typeof messageId === "string" && messageId && frame.message.id === messageId && frame.message.content.length === 1) {
|
|
1677
|
-
const prefix = `claude_${messageId}_`;
|
|
1678
|
-
const live = [...messages.values()].filter(message => !message.complete && message.role === block.role &&
|
|
1679
|
-
message.id.startsWith(prefix) && /^\d+$/u.test(message.id.slice(prefix.length)));
|
|
1680
|
-
if (live.length === 1 && (!Object.hasOwn(frame, "apiBlockIndex") ||
|
|
1681
|
-
Number.isSafeInteger(frame.apiBlockIndex) && frame.apiBlockIndex >= 0 &&
|
|
1682
|
-
live[0].id === `${prefix}${frame.apiBlockIndex}`)) {
|
|
1683
|
-
block.outputId = live[0].id;
|
|
1684
|
-
block.userId = live[0].userId || block.userId;
|
|
1685
|
-
}
|
|
1686
|
-
}
|
|
1687
|
-
if (apiMessageId && block.role === "assistant") {
|
|
1688
|
-
const role = assistantRoles.get(apiMessageId);
|
|
1689
|
-
if (role) await publishMessage({ ...block, role });
|
|
1690
|
-
else {
|
|
1691
|
-
const pending = pendingAssistantMessages.get(apiMessageId) || [];
|
|
1692
|
-
pending.push(block);
|
|
1693
|
-
pendingAssistantMessages.set(apiMessageId, pending);
|
|
1694
|
-
}
|
|
1695
|
-
} else await publishMessage(block);
|
|
1696
|
-
}
|
|
1697
|
-
if (apiMessageId && frame.message.stop_reason) {
|
|
1698
|
-
await flushAssistantMessages(apiMessageId, frame.message.stop_reason === "tool_use" ? "commentary" : "assistant");
|
|
1699
|
-
}
|
|
1700
|
-
} else if (frame.type === "result") {
|
|
1701
|
-
const failed = frame.is_error || frame.subtype !== "success";
|
|
1702
|
-
const interrupted = ["aborted_streaming", "aborted_tools"].includes(frame.terminal_reason);
|
|
1703
|
-
const role = failed || interrupted ? "commentary" : "assistant";
|
|
1704
|
-
for (const id of pendingAssistantMessages.keys()) await flushAssistantMessages(id, role);
|
|
1705
|
-
const output = typeof frame.structured_output === "object" ? JSON.stringify(frame.structured_output) : String(frame.result || "");
|
|
1706
|
-
checkOutput(output);
|
|
1707
|
-
// A conversation whose account signed out under it ends its next request
|
|
1708
|
-
// at once with Claude Code's own "Not logged in" answer.
|
|
1709
|
-
const message = !failed ? "" : /^Not logged in\b/u.test(output) ? CLAUDE_SIGNED_OUT_MESSAGE
|
|
1710
|
-
: (frame.errors || []).join("; ") || output || frame.subtype;
|
|
1711
|
-
result = failed ? "" : output;
|
|
1712
|
-
if (result && ![...messages.values()].some((block) => block.complete && block.role === "assistant" && block.text === result)) {
|
|
1713
|
-
await publishMessage({ id: `claude_${frame.uuid || turnId}_result`, role: "assistant", text: result, complete: true,
|
|
1714
|
-
...(typeof frame.local_command === "string" && frame.local_command ? {
|
|
1715
|
-
data: { native: { engineId: "claude", type: "result", command: frame.local_command, output: "stdout" } }
|
|
1716
|
-
} : {}) });
|
|
1717
|
-
}
|
|
1718
|
-
inFlight.delete(currentCommandId);
|
|
1719
|
-
for (const uuid of startedInputs) inFlight.delete(uuid);
|
|
1720
|
-
startedInputs.clear();
|
|
1721
|
-
if (cancelling) return;
|
|
1722
|
-
outcome = { status: interrupted ? "interrupted" : failed ? "failed" : "completed", error: message };
|
|
1723
|
-
// The reply is final, but native background tasks still own the turn
|
|
1724
|
-
// (and may continue it). Keep it active and say what it is waiting for.
|
|
1725
|
-
if (active && tasks.size && !inFlight.size && phase !== "background") {
|
|
1726
|
-
phase = "background";
|
|
1727
|
-
await publish({ type: "phase", phase });
|
|
1728
|
-
}
|
|
1729
|
-
await publish({ type: "diagnostic", stage: "native_result", status: outcome.status,
|
|
1730
|
-
terminalReason: frame.terminal_reason, active, pendingCommands: inFlight.size, backgroundTasks: tasks.size });
|
|
1731
|
-
}
|
|
1732
|
-
if ((frame.type === "system" || frame.type === "result" || frame.type === "command_lifecycle" && frame.state === "completed") &&
|
|
1733
|
-
active && !cancelling && outcome && inFlight.size === 0 && tasks.size === 0) {
|
|
1734
|
-
active = false;
|
|
1735
|
-
phase = "";
|
|
1736
|
-
const candidate = publishedAssistant;
|
|
1737
|
-
finalAssistantResult = outcome.status === "completed" && !outcome.error && !stopped && !cancelling &&
|
|
1738
|
-
admissions.size === 0 && candidate && currentReceiptOwner(candidate.owner) && candidate.text === result
|
|
1739
|
-
? { threadId: conversationId, turnId, inputMessageId: candidate.owner.commandId,
|
|
1740
|
-
itemId: candidate.itemId, outputId: candidate.outputId, text: candidate.text,
|
|
1741
|
-
conversationTurn: candidate.conversationTurn } : null;
|
|
1742
|
-
latestSettlement = { type: "settled", ...read(), generation, source: frame.type };
|
|
1743
|
-
try { await publish(latestSettlement); }
|
|
1744
|
-
catch (error) { clearFinalAssistantResult(); throw error; }
|
|
1745
|
-
}
|
|
1746
|
-
}
|
|
1747
|
-
|
|
1748
|
-
function stop(reason = "Claude stopped before acknowledging the prompt.") {
|
|
1749
|
-
generation++;
|
|
1750
|
-
stopped = true;
|
|
1751
|
-
inputOwner = null;
|
|
1752
|
-
acceptedInput = null;
|
|
1753
|
-
clearFinalAssistantResult();
|
|
1754
|
-
for (const pending of admissions.values()) {
|
|
1755
|
-
pending.reject(failure(reason));
|
|
1756
|
-
}
|
|
1757
|
-
admissions.clear();
|
|
1758
|
-
inFlight.clear();
|
|
1759
|
-
startedInputs.clear();
|
|
1760
|
-
waitingSteers.clear();
|
|
1761
|
-
steeringClient = null;
|
|
1762
|
-
tasks.clear();
|
|
1763
|
-
// Verified process cleanup can precede Claude's terminal generation frame.
|
|
1764
|
-
// Retain already displayed text for the existing transcript reader, with
|
|
1765
|
-
// its original input/output identity and no completed-answer receipt.
|
|
1766
|
-
for (const pending of pendingAssistantMessages.values()) for (const block of pending) {
|
|
1767
|
-
if (block.outputId && block.outputId !== block.id) messages.delete(block.outputId);
|
|
1768
|
-
messages.set(block.id, { ...block, role: "commentary" });
|
|
1769
|
-
}
|
|
1770
|
-
for (const [id, block] of messages) {
|
|
1771
|
-
if (block.complete === false && block.role === "assistant" && block.text) {
|
|
1772
|
-
messages.set(id, { ...block, complete: true, role: "commentary" });
|
|
1773
|
-
}
|
|
1774
|
-
}
|
|
1775
|
-
pendingAssistantMessages.clear();
|
|
1776
|
-
assistantRoles.clear();
|
|
1777
|
-
if (active) outcome = { status: "interrupted", error: reason };
|
|
1778
|
-
active = false;
|
|
1779
|
-
phase = "";
|
|
1780
|
-
}
|
|
1781
|
-
|
|
1782
|
-
async function interrupt(client) {
|
|
1783
|
-
cancelling = true;
|
|
1784
|
-
clearFinalAssistantResult();
|
|
1785
|
-
if (client) await client.interrupt();
|
|
1786
|
-
// The process host still must drain its owned scope and call stop().
|
|
1787
|
-
}
|
|
1788
|
-
|
|
1789
670
|
return Object.freeze({
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
clearFinalAssistantResult();
|
|
1839
|
-
acceptedInput = null;
|
|
1840
|
-
inputOwner = entry ? { commandId: uuid, process: entry.process, executionId: entry.executionId,
|
|
1841
|
-
accountIdentity: entry.accountIdentity } : null;
|
|
1842
|
-
const receiptOwner = inputOwner;
|
|
1843
|
-
const admitted = Promise.withResolvers();
|
|
1844
|
-
void admitted.promise.catch(() => {});
|
|
1845
|
-
inFlight.add(uuid);
|
|
1846
|
-
if (steer) {
|
|
1847
|
-
waitingSteers.add(uuid);
|
|
1848
|
-
steeringClient = client;
|
|
1849
|
-
}
|
|
1850
|
-
const timer = setTimeout(() => admitted.reject(Object.assign(
|
|
1851
|
-
failure("Claude has not acknowledged this prompt. Its delivery is uncertain.", "assistant_claude_admission_unknown"),
|
|
1852
|
-
{ delivery: "uncertain" }
|
|
1853
|
-
)), timeoutMs);
|
|
1854
|
-
admissions.set(uuid, { ...admitted, accept, timer, receiptOwner });
|
|
1855
|
-
try {
|
|
1856
|
-
await Promise.all([client.send(message, { messageId: uuid, sessionId: conversationId })
|
|
1857
|
-
.then(() => { if (steer) backgroundForegroundWork(); }), admitted.promise]);
|
|
1858
|
-
} catch (error) {
|
|
1859
|
-
waitingSteers.delete(uuid);
|
|
1860
|
-
if (inputOwner === receiptOwner) invalidateFinalAssistantResult();
|
|
1861
|
-
throw error;
|
|
1862
|
-
} finally {
|
|
1863
|
-
clearTimeout(timer);
|
|
1864
|
-
}
|
|
1865
|
-
},
|
|
1866
|
-
async prepareSteering() {
|
|
1867
|
-
if (!active || cancelling) {
|
|
1868
|
-
const completed = !active && !stopped && !cancelling && outcome?.status === "completed";
|
|
1869
|
-
throw Object.assign(failure("This Claude turn cannot accept steering now.", completed
|
|
1870
|
-
? "conversation_steer_turn_finished" : "assistant_claude_turn_failed"),
|
|
1871
|
-
{ delivery: "not-sent", messageCode: "assistant.claude.steeringUnavailable", messageParams: {}, ...(completed ? { statusCode: 409 } : {}) });
|
|
1872
|
-
}
|
|
1873
|
-
// Claude Code acknowledges streamed input as queued and folds it into the
|
|
1874
|
-
// running turn after the current tool call, or starts it once the main
|
|
1875
|
-
// loop is idle. Interrupting instead would kill a running foreground tool;
|
|
1876
|
-
// send() moves such a tool to the background so the steer is read now.
|
|
1877
|
-
// The next input owns completion now: a result or task finishing before
|
|
1878
|
-
// it starts must not settle this turn with the previous answer.
|
|
1879
|
-
outcome = null;
|
|
1880
|
-
acceptedInput = null;
|
|
1881
|
-
clearFinalAssistantResult();
|
|
1882
|
-
await publish({ type: "diagnostic", stage: "steering_queued", pendingCommands: inFlight.size, backgroundTasks: tasks.size });
|
|
1883
|
-
},
|
|
1884
|
-
async cancel(client, stopProcess) {
|
|
1885
|
-
try {
|
|
1886
|
-
await interrupt(client);
|
|
1887
|
-
} catch {
|
|
1888
|
-
// A verified managed-scope stop also handles an unresponsive control pipe.
|
|
1889
|
-
} finally {
|
|
1890
|
-
// A control acknowledgement is not a process-exit proof. Draining also
|
|
1891
|
-
// cancels queued steering prompts and native background tools.
|
|
1892
|
-
await stopProcess();
|
|
1893
|
-
}
|
|
1894
|
-
}
|
|
671
|
+
applicationToolsSupported: true,
|
|
672
|
+
entries,
|
|
673
|
+
closingSessions: cleanup.closingSessions,
|
|
674
|
+
get closing() { return cleanup.closing; },
|
|
675
|
+
createAccountQueries,
|
|
676
|
+
open,
|
|
677
|
+
acquire,
|
|
678
|
+
createConversation,
|
|
679
|
+
snapshot,
|
|
680
|
+
readRetainedTurn,
|
|
681
|
+
readFinalAssistantResult,
|
|
682
|
+
readHistory,
|
|
683
|
+
nativeWorkSettled,
|
|
684
|
+
ensureReady,
|
|
685
|
+
ensureProcess,
|
|
686
|
+
readContextUsage,
|
|
687
|
+
compactContext,
|
|
688
|
+
releaseRetainedGoal,
|
|
689
|
+
stopForTerminal,
|
|
690
|
+
prepareTerminal,
|
|
691
|
+
send: delivery.send,
|
|
692
|
+
startTurn,
|
|
693
|
+
interrupt,
|
|
694
|
+
read,
|
|
695
|
+
inspectAdmission,
|
|
696
|
+
hasActiveTemporaryConversation,
|
|
697
|
+
hasActiveConversation,
|
|
698
|
+
retireConversationHistory,
|
|
699
|
+
reconcileSessions: cleanup.reconcileSessions,
|
|
700
|
+
restoreSessionEntries: cleanup.restoreSessionEntries,
|
|
701
|
+
closeSession: cleanup.closeSession,
|
|
702
|
+
releaseSession: cleanup.releaseSession,
|
|
703
|
+
closeProject: cleanup.closeProject,
|
|
704
|
+
invalidateRuntimes: cleanup.invalidateRuntimes,
|
|
705
|
+
drain: cleanup.drain,
|
|
706
|
+
forget: cleanup.forget,
|
|
707
|
+
close: cleanup.close,
|
|
708
|
+
deleteConversation: cleanup.deleteConversation,
|
|
709
|
+
recover: cleanup.recover,
|
|
710
|
+
stopSelected: cleanup.stopSelected,
|
|
711
|
+
wait,
|
|
712
|
+
stopConversation,
|
|
713
|
+
readGoal,
|
|
714
|
+
updateGoal,
|
|
715
|
+
renewal,
|
|
716
|
+
run: commands.run,
|
|
717
|
+
steer: commands.steer,
|
|
718
|
+
cancel: commands.cancel
|
|
1895
719
|
});
|
|
1896
720
|
}
|